mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-10 18:50:55 +02:00
Compare commits
40 Commits
scratch/de
...
v3.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7ef8a3891 | ||
| 90222f3082 | |||
| 79af15a88b | |||
|
|
ad01011a3e | ||
| d4ecefd80a | |||
| d4afcb6832 | |||
| 2b0f575733 | |||
| 0c6f3f8352 | |||
| 48c9c83bb0 | |||
| ab223d5fdc | |||
| 137e572a94 | |||
| b14b046882 | |||
| a7a9458180 | |||
| 23c146b3e6 | |||
| df44d9b50e | |||
| de941d1bc5 | |||
| 34e80ee8f9 | |||
| d1a1d85abd | |||
| 8e53ae2d39 | |||
| 889e9c0994 | |||
| f565deb71d | |||
| 895b318990 | |||
| 3a17a249ed | |||
| 598c453a18 | |||
| 63059a4ef8 | |||
| ec58fbd6d8 | |||
| 17708730fc | |||
| 1384a329ab | |||
|
|
da1dc85b44 | ||
| 28be696f7c | |||
|
|
008c3a223a | ||
| b9145d762c | |||
| 37a5dc2e9e | |||
| 1351fcd47b | |||
|
|
14a6b04b11 | ||
| 4c9d7fddce | |||
|
|
39ecb89196 | ||
| 974f25997d | |||
| e061fa31a9 | |||
| 718f99527c |
102
.github/workflows/ci.yml
vendored
102
.github/workflows/ci.yml
vendored
@@ -1,19 +1,19 @@
|
||||
name: Full CI
|
||||
on:
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: "Branch of BEC Widgets to install"
|
||||
description: 'Branch of BEC Widgets to install'
|
||||
required: false
|
||||
type: string
|
||||
BEC_CORE_BRANCH:
|
||||
description: "Branch of BEC Core to install"
|
||||
description: 'Branch of BEC Core to install'
|
||||
required: false
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: "Branch of Ophyd Devices to install"
|
||||
description: 'Branch of Ophyd Devices to install'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
@@ -25,58 +25,60 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# check_pr_status:
|
||||
# uses: ./.github/workflows/check_pr.yml
|
||||
check_pr_status:
|
||||
uses: ./.github/workflows/check_pr.yml
|
||||
|
||||
# formatter:
|
||||
# needs: check_pr_status
|
||||
# if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
# uses: ./.github/workflows/formatter.yml
|
||||
formatter:
|
||||
needs: check_pr_status
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/formatter.yml
|
||||
|
||||
# unit-test:
|
||||
# needs: [check_pr_status, formatter]
|
||||
# if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
# uses: ./.github/workflows/pytest.yml
|
||||
# with:
|
||||
# BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref }}
|
||||
# BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
# OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
|
||||
# secrets:
|
||||
# CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
unit-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/pytest.yml
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref }}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
|
||||
secrets:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
# unit-test-matrix:
|
||||
# needs: [check_pr_status, formatter]
|
||||
# if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
# uses: ./.github/workflows/pytest-matrix.yml
|
||||
# with:
|
||||
# BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha}}
|
||||
# BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
# OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
|
||||
unit-test-matrix:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/pytest-matrix.yml
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha}}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
|
||||
|
||||
# generate-cli-test:
|
||||
# needs: [check_pr_status, formatter]
|
||||
# if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
# uses: ./.github/workflows/generate-cli-check.yml
|
||||
generate-cli-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/generate-cli-check.yml
|
||||
|
||||
end2end-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/end2end-conda.yml
|
||||
|
||||
# child-repos:
|
||||
# needs: [check_pr_status, formatter]
|
||||
# if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
# uses: ./.github/workflows/child_repos.yml
|
||||
# with:
|
||||
# BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
# OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
|
||||
# BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
|
||||
child-repos:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/child_repos.yml
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
|
||||
|
||||
plugin_repos:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: bec-project/bec/.github/workflows/plugin_repos.yml@main
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
|
||||
|
||||
# plugin_repos:
|
||||
# needs: [check_pr_status, formatter]
|
||||
# if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
# uses: bec-project/bec/.github/workflows/plugin_repos.yml@main
|
||||
# with:
|
||||
# BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
# BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
|
||||
|
||||
# secrets:
|
||||
# GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
|
||||
secrets:
|
||||
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
|
||||
4
.github/workflows/end2end-conda.yml
vendored
4
.github/workflows/end2end-conda.yml
vendored
@@ -48,9 +48,7 @@ jobs:
|
||||
source ./bin/install_bec_dev.sh -t
|
||||
cd ../
|
||||
pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin
|
||||
pip install pytest-repeat
|
||||
|
||||
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end/user_interaction/test_user_interaction_e2e.py
|
||||
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
|
||||
|
||||
- name: Upload logs if job fails
|
||||
if: failure()
|
||||
|
||||
141
CHANGELOG.md
141
CHANGELOG.md
@@ -1,6 +1,147 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v3.3.1 (2026-03-20)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **dap_combobox**: Added safeguard for no DAP models
|
||||
([`79af15a`](https://github.com/bec-project/bec_widgets/commit/79af15a88b993cd5b6bf730796f995f20cf6f188))
|
||||
|
||||
- **dap_combobox**: Rewritten as proper combobox
|
||||
([`90222f3`](https://github.com/bec-project/bec_widgets/commit/90222f30821f822eb24b0179401d4e43050e0156))
|
||||
|
||||
|
||||
## v3.3.0 (2026-03-20)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix black 2026 formatting
|
||||
([`d4ecefd`](https://github.com/bec-project/bec_widgets/commit/d4ecefd80a6ab944b4da51c1ee35e5dea67770f2))
|
||||
|
||||
- **actions**: Allow minimum icon size for actions in toolbar
|
||||
([`de941d1`](https://github.com/bec-project/bec_widgets/commit/de941d1bc565e444f84696b1de046d50b62f3c1b))
|
||||
|
||||
- **admin-view**: Generate RPC interface for AdminView
|
||||
([`137e572`](https://github.com/bec-project/bec_widgets/commit/137e572a942281a9c7478a6be83f815848917e26))
|
||||
|
||||
- **admin-widget**: Cleanup and minor improvements
|
||||
([`0c6f3f8`](https://github.com/bec-project/bec_widgets/commit/0c6f3f8352e7318a4f0579c83066b5b433fd1144))
|
||||
|
||||
- **admin_view**: Minor changes
|
||||
([`48c9c83`](https://github.com/bec-project/bec_widgets/commit/48c9c83bb0c9432905d347b2d2cf46c05e58c098))
|
||||
|
||||
- **bec-atlas-admin-view**: Fix connect_slot for dispatcher
|
||||
([`23c146b`](https://github.com/bec-project/bec_widgets/commit/23c146b3e6bbbabfb35f1892bc8653a65652ae6a))
|
||||
|
||||
- **login-dialog**: Remove login_dialog
|
||||
([`d1a1d85`](https://github.com/bec-project/bec_widgets/commit/d1a1d85abd3331ebab696580c692c69b71482f37))
|
||||
|
||||
- **main-app**: Fix id for main-app init of AdminView
|
||||
([`b14b046`](https://github.com/bec-project/bec_widgets/commit/b14b04688284eb875ea4469765786834e74fceb3))
|
||||
|
||||
- **main-app**: Skip on_enter/exit hooks if darkmodebutton clicked
|
||||
([`f565deb`](https://github.com/bec-project/bec_widgets/commit/f565deb71db8fa5206fa2b4eea436e5055030bbc))
|
||||
|
||||
- **pyproject**: Add PyJWT as dependency
|
||||
([`889e9c0`](https://github.com/bec-project/bec_widgets/commit/889e9c0994a960b93c93143b6dc5845dc96f9f96))
|
||||
|
||||
- **RPC**: Fix rpc access
|
||||
([`8e53ae2`](https://github.com/bec-project/bec_widgets/commit/8e53ae2d3938e9c0a4c11082300156994447faaf))
|
||||
|
||||
### Features
|
||||
|
||||
- **admin-view**: Add admin view to views
|
||||
([`63059a4`](https://github.com/bec-project/bec_widgets/commit/63059a4ef897a919f296c68ada066e0b228f8248))
|
||||
|
||||
- **bec-atlas-admin-view**: Add http service through QNetworkAccessManager
|
||||
([`1770873`](https://github.com/bec-project/bec_widgets/commit/17708730fcff41713638c17d0cc1f5d9d0b75122))
|
||||
|
||||
- **bec-atlas-admin-view**: Add initial admin view
|
||||
([`ec58fbd`](https://github.com/bec-project/bec_widgets/commit/ec58fbd6d859058f518b88ba15670a3a715c3cc3))
|
||||
|
||||
- **bec-atlas-admin-view**: Add login dilaog
|
||||
([`1384a32`](https://github.com/bec-project/bec_widgets/commit/1384a329abf873b5496e540a542088c7f13b7270))
|
||||
|
||||
- **experiment-selection**: Add experiment selection widget
|
||||
([`598c453`](https://github.com/bec-project/bec_widgets/commit/598c453a1876cebc2482d55bf6c2728ec247def0))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Address review comments
|
||||
([`a7a9458`](https://github.com/bec-project/bec_widgets/commit/a7a9458180c18bf2bba652c2ff8a68875af36a22))
|
||||
|
||||
- Cleanup widgets
|
||||
([`895b318`](https://github.com/bec-project/bec_widgets/commit/895b3189904778c269200365b264a32ff15dda21))
|
||||
|
||||
- Fix formatting, running black 2026.1
|
||||
([`ab223d5`](https://github.com/bec-project/bec_widgets/commit/ab223d5fdc00b1a7bc9fd61abce5fabe4409654b))
|
||||
|
||||
- **admin-view**: Refactor experiment selection, http service, admin view, and add main view
|
||||
([`3a17a24`](https://github.com/bec-project/bec_widgets/commit/3a17a249ed179fb8a11591f948c7b6338e10a60d))
|
||||
|
||||
- **atlas-http-service**: Rename AtlasEndpoints
|
||||
([`2b0f575`](https://github.com/bec-project/bec_widgets/commit/2b0f575733412a96e54dff2dca15082d64caf7ee))
|
||||
|
||||
- **fuzzy-search**: Unify is_match for fuzzy search
|
||||
([`d4afcb6`](https://github.com/bec-project/bec_widgets/commit/d4afcb68324f63ac8aea7cc3b2c82e79d2e643ca))
|
||||
|
||||
### Testing
|
||||
|
||||
- **bec-atlas-admin-view**: Complement tests for BECAtlasAdminView, ExperimentSelection,
|
||||
BECAtlasHTTPService
|
||||
([`df44d9b`](https://github.com/bec-project/bec_widgets/commit/df44d9b50eb289a7851579c64a2a8c0e2363b06a))
|
||||
|
||||
- **bec-atlas-http-service**: Add tests for http service
|
||||
([`34e80ee`](https://github.com/bec-project/bec_widgets/commit/34e80ee8f9a2b2373c97ae7cde90690ab6fb37ce))
|
||||
|
||||
|
||||
## v3.2.4 (2026-03-19)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **main_app**: Setapplicationname("bec")
|
||||
([`28be696`](https://github.com/bec-project/bec_widgets/commit/28be696f7c7d9762c742c6d5fb5b03867d5e92ea))
|
||||
|
||||
|
||||
## v3.2.3 (2026-03-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Check adding parent for filesystemmodel
|
||||
([`b9145d7`](https://github.com/bec-project/bec_widgets/commit/b9145d762cdf946f184834928a6404f21b4802a9))
|
||||
|
||||
- Refactor client mock with global fakeredis
|
||||
([`37a5dc2`](https://github.com/bec-project/bec_widgets/commit/37a5dc2e9eeb447d174f4d7087051672f308c84c))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- Fix path for uploading logs on failure
|
||||
([`1351fcd`](https://github.com/bec-project/bec_widgets/commit/1351fcd47b909c1a33cb389c096041eb1449e3d3))
|
||||
|
||||
|
||||
## v3.2.2 (2026-03-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **image**: Disconnecting of 2d monitor
|
||||
([`4c9d7fd`](https://github.com/bec-project/bec_widgets/commit/4c9d7fddce7aa5b7f13a00ac332bd54b301e3c28))
|
||||
|
||||
|
||||
## v3.2.1 (2026-03-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **e2e**: Bec dock rpc fixed synchronization
|
||||
([`e061fa3`](https://github.com/bec-project/bec_widgets/commit/e061fa31a9a5e5c00e44337d7cc52c51d8e259b5))
|
||||
|
||||
- **e2e**: Bec shell excluded from e2e testing
|
||||
([`974f259`](https://github.com/bec-project/bec_widgets/commit/974f25997d68d13ff1063026f9e5c4c8dd4d49f3))
|
||||
|
||||
- **e2e**: Timeout for maybe_remove_dock_area
|
||||
([`718f995`](https://github.com/bec-project/bec_widgets/commit/718f99527c3bebb96845d3305aba69434eb83f77))
|
||||
|
||||
|
||||
## v3.2.0 (2026-03-11)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -5,6 +5,7 @@ from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
|
||||
from bec_widgets.applications.navigation_centre.side_bar import SideBar
|
||||
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
|
||||
from bec_widgets.applications.views.admin_view.admin_view import AdminView
|
||||
from bec_widgets.applications.views.developer_view.developer_view import DeveloperView
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
|
||||
from bec_widgets.applications.views.dock_area_view.dock_area_view import DockAreaView
|
||||
@@ -63,6 +64,8 @@ class BECMainApp(BECMainWindow):
|
||||
self.dock_area = DockAreaView(self)
|
||||
self.device_manager = DeviceManagerView(self)
|
||||
# self.developer_view = DeveloperView(self) #TODO temporary disable until the bugs with BECShell are resolved
|
||||
self.admin_view = AdminView(self)
|
||||
|
||||
self.add_view(icon="widgets", title="Dock Area", widget=self.dock_area, mini_text="Docks")
|
||||
self.add_view(
|
||||
icon="display_settings",
|
||||
@@ -78,6 +81,13 @@ class BECMainApp(BECMainWindow):
|
||||
# mini_text="IDE",
|
||||
# exclusive=True,
|
||||
# )
|
||||
self.add_view(
|
||||
icon="admin_panel_settings",
|
||||
title="Admin View",
|
||||
widget=self.admin_view,
|
||||
mini_text="Admin",
|
||||
from_top=False,
|
||||
)
|
||||
|
||||
if self._show_examples:
|
||||
self.add_section("Examples", "examples")
|
||||
@@ -181,6 +191,12 @@ class BECMainApp(BECMainWindow):
|
||||
|
||||
# Internal: route sidebar selection to the stack
|
||||
def _on_view_selected(self, vid: str) -> None:
|
||||
# Special handling for views that can not be switched to (e.g. dark mode toggle)
|
||||
# Not registered as proper view with a stack index, so we ignore any logic below
|
||||
# as it will anyways not result in a stack switch.
|
||||
idx = self._view_index.get(vid)
|
||||
if idx is None or not (0 <= idx < self.stack.count()):
|
||||
return
|
||||
# Determine current view
|
||||
current_index = self.stack.currentIndex()
|
||||
current_view = (
|
||||
@@ -378,6 +394,7 @@ def main(): # pragma: no cover
|
||||
args, qt_args = parser.parse_known_args(sys.argv[1:])
|
||||
|
||||
app = QApplication([sys.argv[0], *qt_args])
|
||||
app.setApplicationName("BEC")
|
||||
apply_theme("dark")
|
||||
w = BECMainApp(show_examples=args.examples)
|
||||
|
||||
|
||||
35
bec_widgets/applications/views/admin_view/admin_view.py
Normal file
35
bec_widgets/applications/views/admin_view/admin_view.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Module for Admin View."""
|
||||
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.applications.views.view import ViewBase
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view import BECAtlasAdminView
|
||||
|
||||
|
||||
class AdminView(ViewBase):
|
||||
"""
|
||||
A view for administrators to change the current active experiment, manage messaging
|
||||
services, and more tasks reserved for users with admin privileges.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
view_id: str | None = None,
|
||||
title: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, content=content, view_id=view_id, title=title)
|
||||
self.admin_widget = BECAtlasAdminView(parent=self)
|
||||
self.set_content(self.admin_widget)
|
||||
|
||||
@SafeSlot()
|
||||
def on_exit(self) -> None:
|
||||
"""Called before the view is hidden.
|
||||
|
||||
Default implementation does nothing. Override in subclasses.
|
||||
"""
|
||||
self.admin_widget.logout()
|
||||
@@ -89,6 +89,16 @@ except ImportError as e:
|
||||
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}")
|
||||
|
||||
|
||||
class AdminView(RPCBase):
|
||||
"""A view for administrators to change the current active experiment, manage messaging"""
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
Switch the parent application to this view.
|
||||
"""
|
||||
|
||||
|
||||
class AutoUpdates(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
@@ -975,7 +985,7 @@ class Curve(RPCBase):
|
||||
|
||||
|
||||
class DapComboBox(RPCBase):
|
||||
"""The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC."""
|
||||
"""Editable combobox listing the available DAP models."""
|
||||
|
||||
@rpc_call
|
||||
def select_y_axis(self, y_axis: str):
|
||||
@@ -1001,7 +1011,7 @@ class DapComboBox(RPCBase):
|
||||
Slot to update the fit model.
|
||||
|
||||
Args:
|
||||
default_device(str): Default device name.
|
||||
fit_name(str): Fit model name.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# pylint: skip-file
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from bec_lib.config_helper import ConfigHelper
|
||||
from bec_lib.device import Device as BECDevice
|
||||
from bec_lib.device import Positioner as BECPositioner
|
||||
from bec_lib.device import ReadoutPriority
|
||||
@@ -219,7 +220,9 @@ class Device(FakeDevice):
|
||||
|
||||
|
||||
class DMMock:
|
||||
def __init__(self):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._service = args[0]
|
||||
self.config_helper = ConfigHelper(self._service.connector, self._service._service_name)
|
||||
self.devices = DeviceContainer()
|
||||
self.enabled_devices = [device for device in self.devices if device.enabled]
|
||||
|
||||
@@ -273,6 +276,10 @@ class DMMock:
|
||||
configs.append(device._config)
|
||||
return configs
|
||||
|
||||
def initialize(*_): ...
|
||||
|
||||
def shutdown(self): ...
|
||||
|
||||
|
||||
DEVICES = [
|
||||
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
|
||||
|
||||
@@ -123,17 +123,16 @@ class BECDispatcher:
|
||||
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
|
||||
collections.defaultdict()
|
||||
)
|
||||
self.client = client
|
||||
|
||||
if self.client is None:
|
||||
if config is not None:
|
||||
if not isinstance(config, ServiceConfig):
|
||||
# config is supposed to be a path
|
||||
config = ServiceConfig(config)
|
||||
if client is None:
|
||||
if config is not None and not isinstance(config, ServiceConfig):
|
||||
# config is supposed to be a path
|
||||
config = ServiceConfig(config)
|
||||
self.client = BECClient(
|
||||
config=config, connector_cls=QtRedisConnector, name="BECWidgets"
|
||||
)
|
||||
else:
|
||||
self.client = client
|
||||
if self.client.started:
|
||||
# have to reinitialize client to use proper connector
|
||||
logger.info("Shutting down BECClient to switch to QtRedisConnector")
|
||||
|
||||
35
bec_widgets/utils/fuzzy_search.py
Normal file
35
bec_widgets/utils/fuzzy_search.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Module providing fuzzy search utilities for the BEC widgets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from thefuzz import fuzz
|
||||
|
||||
FUZZY_SEARCH_THRESHOLD = 80
|
||||
|
||||
|
||||
def is_match(
|
||||
text: str, row_data: dict[str, Any], relevant_keys: list[str], enable_fuzzy: bool
|
||||
) -> bool:
|
||||
"""
|
||||
Check if the text matches any of the relevant keys in the row data.
|
||||
|
||||
Args:
|
||||
text (str): The text to search for.
|
||||
row_data (dict[str, Any]): The row data to search in.
|
||||
relevant_keys (list[str]): The keys to consider for searching.
|
||||
enable_fuzzy (bool): Whether to use fuzzy matching.
|
||||
Returns:
|
||||
bool: True if a match is found, False otherwise.
|
||||
"""
|
||||
for key in relevant_keys:
|
||||
data = str(row_data.get(key, "") or "")
|
||||
if enable_fuzzy:
|
||||
match_ratio = fuzz.partial_ratio(text.lower(), data.lower())
|
||||
if match_ratio >= FUZZY_SEARCH_THRESHOLD:
|
||||
return True
|
||||
else:
|
||||
if text.lower() in data.lower():
|
||||
return True
|
||||
return False
|
||||
@@ -118,9 +118,6 @@ class RPCServer:
|
||||
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
|
||||
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
|
||||
try:
|
||||
logger.info(
|
||||
f"Processing RPC instruction: {msg['action']} with request_id: {request_id}. Parameters: {msg.get('parameter')}"
|
||||
)
|
||||
method = msg["action"]
|
||||
args = msg["parameter"].get("args", [])
|
||||
kwargs = msg["parameter"].get("kwargs", {})
|
||||
@@ -260,10 +257,6 @@ class RPCServer:
|
||||
else:
|
||||
name = WidgetContainerUtils.generate_unique_name("dock_area", existing_dock_areas)
|
||||
|
||||
logger.info(
|
||||
f"Launching new dock area with name: {name} and startup_profile: {startup_profile}"
|
||||
)
|
||||
|
||||
result_widget = bw_launch.dock_area(object_name=name, startup_profile=startup_profile)
|
||||
result_widget.window().setWindowTitle(f"BEC - {name}")
|
||||
|
||||
@@ -303,9 +296,6 @@ class RPCServer:
|
||||
else:
|
||||
res = self.serialize_object(res)
|
||||
except RegistryNotReadyError:
|
||||
logger.info(
|
||||
f"Object not registered yet for RPC request {request_id}, retrying serialization after {retry_delay} ms"
|
||||
)
|
||||
try:
|
||||
self._rpc_singleshot_repeats[request_id] += retry_delay
|
||||
QTimer.singleShot(
|
||||
|
||||
@@ -35,16 +35,19 @@ logger = bec_logger.logger
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
def create_action_with_text(toolbar_action, toolbar: QToolBar):
|
||||
def create_action_with_text(toolbar_action, toolbar: QToolBar, min_size: QSize | None = None):
|
||||
"""
|
||||
Helper function to create a toolbar button with text beside or under the icon.
|
||||
|
||||
Args:
|
||||
toolbar_action(ToolBarAction): The toolbar action to create the button for.
|
||||
toolbar(ModularToolBar): The toolbar to add the button to.
|
||||
min_size(QSize, optional): The minimum size for the button. Defaults to None.
|
||||
"""
|
||||
|
||||
btn = QToolButton(parent=toolbar)
|
||||
if min_size is not None:
|
||||
btn.setMinimumSize(min_size)
|
||||
if getattr(toolbar_action, "label_text", None):
|
||||
toolbar_action.action.setText(toolbar_action.label_text)
|
||||
if getattr(toolbar_action, "tooltip", None):
|
||||
|
||||
@@ -63,7 +63,7 @@ class ScriptTreeWidget(QWidget):
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Create tree view
|
||||
self.tree = QTreeView()
|
||||
self.tree = QTreeView(parent=self)
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.setRootIsDecorated(True)
|
||||
|
||||
@@ -71,12 +71,12 @@ class ScriptTreeWidget(QWidget):
|
||||
self.tree.setMouseTracking(True)
|
||||
|
||||
# Create file system model
|
||||
self.model = QFileSystemModel()
|
||||
self.model = QFileSystemModel(parent=self)
|
||||
self.model.setNameFilters(["*.py"])
|
||||
self.model.setNameFilterDisables(False)
|
||||
|
||||
# Create proxy model to filter out underscore directories
|
||||
self.proxy_model = QSortFilterProxyModel()
|
||||
self.proxy_model = QSortFilterProxyModel(parent=self)
|
||||
self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*"))
|
||||
self.proxy_model.setSourceModel(self.model)
|
||||
self.tree.setModel(self.proxy_model)
|
||||
|
||||
@@ -5,9 +5,8 @@ in DeviceTableRow entries.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from copy import deepcopy
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterable, Tuple
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceModel
|
||||
from bec_lib.callback_handler import EventType
|
||||
@@ -19,6 +18,7 @@ from thefuzz import fuzz
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.fuzzy_search import is_match
|
||||
from bec_widgets.widgets.control.device_manager.components.device_table.device_table_row import (
|
||||
DeviceTableRow,
|
||||
)
|
||||
@@ -37,34 +37,6 @@ _DeviceCfgIter = Iterable[dict[str, Any]]
|
||||
# DeviceValidationResult: device_config, config_status, connection_status, error_message
|
||||
_ValidationResultIter = Iterable[Tuple[dict[str, Any], ConfigStatus, ConnectionStatus, str]]
|
||||
|
||||
FUZZY_SEARCH_THRESHOLD = 80
|
||||
|
||||
|
||||
def is_match(
|
||||
text: str, row_data: dict[str, Any], relevant_keys: list[str], enable_fuzzy: bool
|
||||
) -> bool:
|
||||
"""
|
||||
Check if the text matches any of the relevant keys in the row data.
|
||||
|
||||
Args:
|
||||
text (str): The text to search for.
|
||||
row_data (dict[str, Any]): The row data to search in.
|
||||
relevant_keys (list[str]): The keys to consider for searching.
|
||||
enable_fuzzy (bool): Whether to use fuzzy matching.
|
||||
Returns:
|
||||
bool: True if a match is found, False otherwise.
|
||||
"""
|
||||
for key in relevant_keys:
|
||||
data = str(row_data.get(key, "") or "")
|
||||
if enable_fuzzy:
|
||||
match_ratio = fuzz.partial_ratio(text.lower(), data.lower())
|
||||
if match_ratio >= FUZZY_SEARCH_THRESHOLD:
|
||||
return True
|
||||
else:
|
||||
if text.lower() in data.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class TableSortOnHold:
|
||||
"""Context manager for putting table sorting on hold. Works with nested calls."""
|
||||
|
||||
@@ -2,22 +2,19 @@
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property, Signal, Slot
|
||||
from qtpy.QtWidgets import QComboBox, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QComboBox
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DapComboBox(BECWidget, QWidget):
|
||||
class DapComboBox(BECWidget, QComboBox):
|
||||
"""
|
||||
The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC.
|
||||
Editable combobox listing the available DAP models.
|
||||
|
||||
Args:
|
||||
parent: Parent widget.
|
||||
client: BEC client object.
|
||||
gui_id: GUI ID.
|
||||
default: Default device name.
|
||||
The widget behaves as a plain QComboBox and keeps ``fit_model_combobox`` as an alias to itself
|
||||
for backwards compatibility with older call sites.
|
||||
"""
|
||||
|
||||
ICON_NAME = "data_exploration"
|
||||
@@ -45,19 +42,20 @@ class DapComboBox(BECWidget, QWidget):
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, **kwargs)
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.fit_model_combobox = QComboBox(self)
|
||||
self.layout.addWidget(self.fit_model_combobox)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._available_models = None
|
||||
self.fit_model_combobox = self # Just for backwards compatibility with older call sites, the widget itself is the combobox
|
||||
self._available_models: list[str] = []
|
||||
self._x_axis = None
|
||||
self._y_axis = None
|
||||
self.populate_fit_model_combobox()
|
||||
self.fit_model_combobox.currentTextChanged.connect(self._update_current_fit)
|
||||
# Set default fit model
|
||||
self.select_default_fit(default_fit)
|
||||
self._is_valid_input = False
|
||||
|
||||
def select_default_fit(self, default_fit: str | None):
|
||||
self.setEditable(True)
|
||||
|
||||
self.populate_fit_model_combobox()
|
||||
self.currentTextChanged.connect(self._on_text_changed)
|
||||
self.select_default_fit(default_fit)
|
||||
self.check_validity(self.currentText())
|
||||
|
||||
def select_default_fit(self, default_fit: str | None = "GaussianModel"):
|
||||
"""Set the default fit model.
|
||||
|
||||
Args:
|
||||
@@ -65,8 +63,8 @@ class DapComboBox(BECWidget, QWidget):
|
||||
"""
|
||||
if self._validate_dap_model(default_fit):
|
||||
self.select_fit_model(default_fit)
|
||||
else:
|
||||
self.select_fit_model("GaussianModel")
|
||||
elif self.available_models:
|
||||
self.select_fit_model(self.available_models[0])
|
||||
|
||||
@property
|
||||
def available_models(self):
|
||||
@@ -114,12 +112,40 @@ class DapComboBox(BECWidget, QWidget):
|
||||
self._y_axis = y_axis
|
||||
self.y_axis_updated.emit(y_axis)
|
||||
|
||||
def _update_current_fit(self, fit_name: str):
|
||||
"""Update the current fit."""
|
||||
@Slot(str)
|
||||
def _on_text_changed(self, fit_name: str):
|
||||
"""
|
||||
Validate and emit updates for the current text.
|
||||
|
||||
Args:
|
||||
fit_name(str): The current text in the combobox, representing the selected fit model.
|
||||
"""
|
||||
self.check_validity(fit_name)
|
||||
if not self._is_valid_input:
|
||||
return
|
||||
|
||||
self.fit_model_updated.emit(fit_name)
|
||||
if self.x_axis is not None and self.y_axis is not None:
|
||||
self.new_dap_config.emit(self._x_axis, self._y_axis, fit_name)
|
||||
|
||||
@Slot(str)
|
||||
def check_validity(self, fit_name: str):
|
||||
"""
|
||||
Highlight invalid manual entries similarly to DeviceComboBox.
|
||||
|
||||
Args:
|
||||
fit_name(str): The current text in the combobox, representing the selected fit model.
|
||||
"""
|
||||
if self._validate_dap_model(fit_name):
|
||||
self._is_valid_input = True
|
||||
self.setStyleSheet("border: 1px solid transparent;")
|
||||
else:
|
||||
self._is_valid_input = False
|
||||
if self.isEnabled():
|
||||
self.setStyleSheet("border: 1px solid red;")
|
||||
else:
|
||||
self.setStyleSheet("border: 1px solid transparent;")
|
||||
|
||||
@Slot(str)
|
||||
def select_x_axis(self, x_axis: str):
|
||||
"""Slot to update the x axis.
|
||||
@@ -128,7 +154,7 @@ class DapComboBox(BECWidget, QWidget):
|
||||
x_axis(str): X axis.
|
||||
"""
|
||||
self.x_axis = x_axis
|
||||
self._update_current_fit(self.fit_model_combobox.currentText())
|
||||
self._on_text_changed(self.currentText())
|
||||
|
||||
@Slot(str)
|
||||
def select_y_axis(self, y_axis: str):
|
||||
@@ -138,25 +164,26 @@ class DapComboBox(BECWidget, QWidget):
|
||||
y_axis(str): Y axis.
|
||||
"""
|
||||
self.y_axis = y_axis
|
||||
self._update_current_fit(self.fit_model_combobox.currentText())
|
||||
self._on_text_changed(self.currentText())
|
||||
|
||||
@Slot(str)
|
||||
def select_fit_model(self, fit_name: str | None):
|
||||
"""Slot to update the fit model.
|
||||
|
||||
Args:
|
||||
default_device(str): Default device name.
|
||||
fit_name(str): Fit model name.
|
||||
"""
|
||||
if not self._validate_dap_model(fit_name):
|
||||
raise ValueError(f"Fit {fit_name} is not valid.")
|
||||
self.fit_model_combobox.setCurrentText(fit_name)
|
||||
self.setCurrentText(fit_name)
|
||||
|
||||
def populate_fit_model_combobox(self):
|
||||
"""Populate the fit_model_combobox with the devices."""
|
||||
# pylint: disable=protected-access
|
||||
self.available_models = [model for model in self.client.dap._available_dap_plugins.keys()]
|
||||
self.fit_model_combobox.clear()
|
||||
self.fit_model_combobox.addItems(self.available_models)
|
||||
available_plugins = getattr(getattr(self.client, "dap", None), "_available_dap_plugins", {})
|
||||
self.available_models = [model for model in available_plugins.keys()]
|
||||
self.clear()
|
||||
self.addItems(self.available_models)
|
||||
|
||||
def _validate_dap_model(self, model: str | None) -> bool:
|
||||
"""Validate the DAP model.
|
||||
@@ -166,23 +193,23 @@ class DapComboBox(BECWidget, QWidget):
|
||||
"""
|
||||
if model is None:
|
||||
return False
|
||||
if model not in self.available_models:
|
||||
return False
|
||||
return True
|
||||
return model in self.available_models
|
||||
|
||||
@property
|
||||
def is_valid_input(self) -> bool:
|
||||
"""Whether the current text matches an available DAP model."""
|
||||
return self._is_valid_input
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication([])
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
widget = QWidget()
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
widget.setLayout(layout)
|
||||
layout.addWidget(DapComboBox())
|
||||
widget.show()
|
||||
app.exec_()
|
||||
dialog = DapComboBox()
|
||||
dialog.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -270,6 +270,16 @@ class Image(ImageBase):
|
||||
return
|
||||
|
||||
old_device = self._config.device
|
||||
old_signal = self._config.signal
|
||||
old_config = self.subscriptions["main"]
|
||||
if old_device and old_signal and old_device != value:
|
||||
self._disconnect_monitor_subscription(
|
||||
device=old_device,
|
||||
signal=old_signal,
|
||||
source=old_config.source,
|
||||
async_update=self.async_update,
|
||||
async_signal_name=old_config.async_signal_name,
|
||||
)
|
||||
self._config.device = value
|
||||
|
||||
# If we have a signal, reconnect with the new device
|
||||
@@ -325,6 +335,16 @@ class Image(ImageBase):
|
||||
self._set_connection_status("disconnected")
|
||||
return
|
||||
|
||||
old_signal = self._config.signal
|
||||
old_config = self.subscriptions["main"]
|
||||
if self._config.device and old_signal and old_signal != value:
|
||||
self._disconnect_monitor_subscription(
|
||||
device=self._config.device,
|
||||
signal=old_signal,
|
||||
source=old_config.source,
|
||||
async_update=self.async_update,
|
||||
async_signal_name=old_config.async_signal_name,
|
||||
)
|
||||
self._config.signal = value
|
||||
|
||||
# If we have a device, try to connect
|
||||
@@ -447,6 +467,61 @@ class Image(ImageBase):
|
||||
)
|
||||
self._autorange_on_next_update = True
|
||||
|
||||
def _disconnect_monitor_subscription(
|
||||
self,
|
||||
*,
|
||||
device: str,
|
||||
signal: str,
|
||||
source: Literal["device_monitor_1d", "device_monitor_2d"] | None,
|
||||
async_update: bool,
|
||||
async_signal_name: str | None,
|
||||
) -> None:
|
||||
if not device or not signal:
|
||||
return
|
||||
|
||||
if async_update:
|
||||
async_signal_name = async_signal_name or signal
|
||||
ids_to_check = [self.scan_id, self.old_scan_id]
|
||||
|
||||
if source == "device_monitor_1d":
|
||||
for scan_id in ids_to_check:
|
||||
if scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d,
|
||||
MessageEndpoints.device_async_signal(scan_id, device, async_signal_name),
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting 1d update ScanID:{scan_id}, Device Name:{device},Device Entry:{async_signal_name}"
|
||||
)
|
||||
elif source == "device_monitor_2d":
|
||||
for scan_id in ids_to_check:
|
||||
if scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d,
|
||||
MessageEndpoints.device_async_signal(scan_id, device, async_signal_name),
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting 2d update ScanID:{scan_id}, Device Name:{device},Device Entry:{async_signal_name}"
|
||||
)
|
||||
return
|
||||
|
||||
if source == "device_monitor_1d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d, MessageEndpoints.device_preview(device, signal)
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting preview 1d update Device Name:{device}, Device Entry:{signal}"
|
||||
)
|
||||
elif source == "device_monitor_2d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d, MessageEndpoints.device_preview(device, signal)
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting preview 2d update Device Name:{device}, Device Entry:{signal}"
|
||||
)
|
||||
|
||||
def _disconnect_current_monitor(self):
|
||||
"""
|
||||
Internal method to disconnect the current monitor subscriptions.
|
||||
@@ -455,55 +530,13 @@ class Image(ImageBase):
|
||||
return
|
||||
|
||||
config = self.subscriptions["main"]
|
||||
|
||||
if self.async_update:
|
||||
async_signal_name = config.async_signal_name or self._config.signal
|
||||
ids_to_check = [self.scan_id, self.old_scan_id]
|
||||
|
||||
if config.source == "device_monitor_1d":
|
||||
for scan_id in ids_to_check:
|
||||
if scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d,
|
||||
MessageEndpoints.device_async_signal(
|
||||
scan_id, self._config.device, async_signal_name
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting 1d update ScanID:{scan_id}, Device Name:{self._config.device},Device Entry:{async_signal_name}"
|
||||
)
|
||||
elif config.source == "device_monitor_2d":
|
||||
for scan_id in ids_to_check:
|
||||
if scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d,
|
||||
MessageEndpoints.device_async_signal(
|
||||
scan_id, self._config.device, async_signal_name
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting 2d update ScanID:{scan_id}, Device Name:{self._config.device},Device Entry:{async_signal_name}"
|
||||
)
|
||||
|
||||
else:
|
||||
if config.source == "device_monitor_1d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d,
|
||||
MessageEndpoints.device_preview(self._config.device, self._config.signal),
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting preview 1d update Device Name:{self._config.device}, Device Entry:{self._config.signal}"
|
||||
)
|
||||
elif config.source == "device_monitor_2d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d,
|
||||
MessageEndpoints.device_preview(self._config.device, self._config.signal),
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting preview 2d update Device Name:{self._config.device}, Device Entry:{self._config.signal}"
|
||||
)
|
||||
self._disconnect_monitor_subscription(
|
||||
device=self._config.device,
|
||||
signal=self._config.signal,
|
||||
source=config.source,
|
||||
async_update=self.async_update,
|
||||
async_signal_name=config.async_signal_name,
|
||||
)
|
||||
|
||||
# Reset async state
|
||||
self.async_update = False
|
||||
@@ -860,45 +893,19 @@ class Image(ImageBase):
|
||||
logger.warning("Cannot disconnect monitor without both device and signal")
|
||||
return
|
||||
|
||||
if self.async_update:
|
||||
async_signal_name = config.async_signal_name or target_entry
|
||||
ids_to_check = [self.scan_id, self.old_scan_id]
|
||||
if config.source == "device_monitor_1d":
|
||||
for scan_id in ids_to_check:
|
||||
if scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d,
|
||||
MessageEndpoints.device_async_signal(
|
||||
scan_id, target_device, async_signal_name
|
||||
),
|
||||
)
|
||||
elif config.source == "device_monitor_2d":
|
||||
for scan_id in ids_to_check:
|
||||
if scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d,
|
||||
MessageEndpoints.device_async_signal(
|
||||
scan_id, target_device, async_signal_name
|
||||
),
|
||||
)
|
||||
else:
|
||||
if config.source == "device_monitor_1d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d,
|
||||
MessageEndpoints.device_preview(target_device, target_entry),
|
||||
)
|
||||
elif config.source == "device_monitor_2d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d,
|
||||
MessageEndpoints.device_preview(target_device, target_entry),
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Cannot disconnect monitor {target_device}.{target_entry} with source {self.subscriptions['main'].source}"
|
||||
)
|
||||
return
|
||||
if config.source not in {"device_monitor_1d", "device_monitor_2d"}:
|
||||
logger.warning(
|
||||
f"Cannot disconnect monitor {target_device}.{target_entry} with source {self.subscriptions['main'].source}"
|
||||
)
|
||||
return
|
||||
|
||||
self._disconnect_monitor_subscription(
|
||||
device=target_device,
|
||||
signal=target_entry,
|
||||
source=config.source,
|
||||
async_update=self.async_update,
|
||||
async_signal_name=config.async_signal_name,
|
||||
)
|
||||
|
||||
self.subscriptions["main"].async_signal_name = None
|
||||
self.async_update = False
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
"""Admin View panel for setting up account and messaging services in BEC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import DeploymentInfoMessage, ExperimentInfoMessage
|
||||
from qtpy.QtCore import QSize, Qt, QTimer, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QFrame,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QStackedLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_login import BECLogin
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import (
|
||||
MaterialIconAction,
|
||||
WidgetAction,
|
||||
create_action_with_text,
|
||||
)
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_http_service import (
|
||||
AtlasEndpoints,
|
||||
AuthenticatedUserInfo,
|
||||
BECAtlasHTTPService,
|
||||
HTTPResponse,
|
||||
)
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_mat_card import (
|
||||
ExperimentMatCard,
|
||||
)
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_selection import (
|
||||
ExperimentSelection,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from qtpy.QtWidgets import QToolBar
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class OverviewWidget(QGroupBox):
|
||||
"""Overview Widget for the BEC Atlas Admin view"""
|
||||
|
||||
login_requested = Signal(str, str)
|
||||
change_experiment_requested = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.setContentsMargins(12, 0, 12, 6)
|
||||
self._authenticated = False
|
||||
# Root layout
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.root_layout.setSpacing(0)
|
||||
|
||||
# Stacked Layout to switch between login form and overview content
|
||||
self.stacked_layout = QStackedLayout()
|
||||
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.stacked_layout.setSpacing(0)
|
||||
self.stacked_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
|
||||
self.root_layout.addLayout(self.stacked_layout)
|
||||
|
||||
self._init_login_view()
|
||||
self._init_experiment_overview()
|
||||
self.stacked_layout.setCurrentWidget(self._login_widget)
|
||||
self._experiment_overview_widget.setVisible(False)
|
||||
|
||||
def set_experiment_info(self, experiment_info: ExperimentInfoMessage):
|
||||
"""Set the experiment information for the overview widget."""
|
||||
self._experiment_overview_widget.set_experiment_info(experiment_info)
|
||||
|
||||
@SafeSlot(bool)
|
||||
def set_authenticated(self, authenticated: bool):
|
||||
"""Set the authentication state of the overview widget."""
|
||||
self._authenticated = authenticated
|
||||
if authenticated:
|
||||
self.stacked_layout.setCurrentWidget(self._experiment_overview_widget)
|
||||
self._experiment_overview_widget.setVisible(True)
|
||||
else:
|
||||
self.stacked_layout.setCurrentWidget(self._login_widget)
|
||||
self._experiment_overview_widget.setVisible(False)
|
||||
|
||||
def _init_login_view(self):
|
||||
"""Initialize the login view."""
|
||||
self._login_widget = QWidget()
|
||||
layout = QHBoxLayout(self._login_widget)
|
||||
self._login_widget.setAutoFillBackground(True)
|
||||
self._login_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
layout.setSpacing(16)
|
||||
|
||||
content = QFrame()
|
||||
content_layout = QVBoxLayout(content)
|
||||
content.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
content.setFrameShadow(QFrame.Shadow.Raised)
|
||||
content.setStyleSheet("""
|
||||
QFrame
|
||||
{
|
||||
border: 1px solid #cccccc;
|
||||
}
|
||||
QLabel
|
||||
{
|
||||
border: none;
|
||||
}
|
||||
""")
|
||||
content_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
content.setFixedSize(400, 280)
|
||||
|
||||
self._login = BECLogin(parent=self)
|
||||
self._login.credentials_entered.connect(self.login_requested.emit)
|
||||
content_layout.addWidget(self._login)
|
||||
layout.addWidget(content)
|
||||
self.stacked_layout.addWidget(self._login_widget)
|
||||
|
||||
def _init_experiment_overview(self):
|
||||
"""Initialize the experiment overview content."""
|
||||
self._experiment_overview_widget = ExperimentMatCard(
|
||||
show_activate_button=True,
|
||||
parent=self,
|
||||
title="Current Experiment",
|
||||
button_text="Change Experiment",
|
||||
)
|
||||
self._experiment_overview_widget.experiment_selected.connect(self._on_experiment_selected)
|
||||
layout = QVBoxLayout(self._experiment_overview_widget)
|
||||
self._experiment_overview_widget.setAutoFillBackground(True)
|
||||
self._experiment_overview_widget.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
|
||||
)
|
||||
layout.setSpacing(16)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.stacked_layout.addWidget(self._experiment_overview_widget)
|
||||
|
||||
@SafeSlot(dict)
|
||||
def _on_experiment_selected(self, _):
|
||||
"""Handle the change experiment button click."""
|
||||
self.change_experiment_requested.emit()
|
||||
|
||||
|
||||
class CustomLogoutAction(MaterialIconAction):
|
||||
"""Custom logout action that can be enabled/disabled based on authentication state."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(
|
||||
icon_name="logout",
|
||||
tooltip="Logout",
|
||||
label_text="Logout",
|
||||
text_position="under",
|
||||
parent=parent,
|
||||
filled=True,
|
||||
)
|
||||
self.action.setEnabled(False) # Initially disabled until authenticated
|
||||
self._tick_timer = QTimer(parent)
|
||||
self._tick_timer.setInterval(1000)
|
||||
self._tick_timer.timeout.connect(self._on_tick)
|
||||
self._login_remaining_s = 0
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""
|
||||
Adds the action to the toolbar.
|
||||
|
||||
Args:
|
||||
toolbar(QToolBar): The toolbar to add the action to.
|
||||
target(QWidget): The target widget for the action.
|
||||
"""
|
||||
create_action_with_text(toolbar_action=self, toolbar=toolbar, min_size=QSize(70, 40))
|
||||
|
||||
def set_authenticated(self, auth_info: AuthenticatedUserInfo | None):
|
||||
"""Enable or disable the logout action based on authentication state."""
|
||||
if not auth_info:
|
||||
self._tick_timer.stop()
|
||||
self._login_remaining_s = 0
|
||||
self.action.setEnabled(False)
|
||||
self.update_label() # Reset Label text
|
||||
return # No need to set the timer if we're not authenticated
|
||||
self._login_remaining_s = max(0, int(auth_info.exp - time.time())) if auth_info else 0
|
||||
self.action.setEnabled(True)
|
||||
if self._login_remaining_s > 0:
|
||||
self._tick_timer.start()
|
||||
|
||||
def _on_tick(self) -> None:
|
||||
"""Handle the timer countdown tick to update the remaining logout time."""
|
||||
self._login_remaining_s -= 1
|
||||
if self._login_remaining_s <= 0:
|
||||
self.set_authenticated(None) # This will disable the action and stop the timer
|
||||
return
|
||||
|
||||
self.update_label() # Optionally update the label to show remaining time
|
||||
|
||||
def update_label(self):
|
||||
"""Update the label text of the logout action."""
|
||||
if self._login_remaining_s > 0:
|
||||
label_text = f"{self.label_text}\n({self._login_remaining_s}s)"
|
||||
else:
|
||||
label_text = self.label_text
|
||||
self.action.setText(label_text)
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the timer when the action is destroyed."""
|
||||
if self._tick_timer.isActive():
|
||||
self._tick_timer.stop()
|
||||
|
||||
|
||||
class AtlasConnectionInfo(QWidget):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
layout.setContentsMargins(6, 6, 6, 12)
|
||||
layout.setSpacing(8)
|
||||
self._bl_info_label = QLabel(self)
|
||||
self._atlas_url_label = QLabel(self)
|
||||
layout.addWidget(self._bl_info_label)
|
||||
layout.addWidget(self._atlas_url_label)
|
||||
self._atlas_url_text = ""
|
||||
|
||||
def set_info(self, realm_id: str, bl_name: str, atlas_url: str):
|
||||
"""Set the connection information for the BEC Atlas API."""
|
||||
bl_info = f"{realm_id} @ {bl_name}"
|
||||
self._bl_info_label.setText(bl_info)
|
||||
self._atlas_url_label.setText(atlas_url)
|
||||
self._atlas_url_text = atlas_url
|
||||
|
||||
def set_logged_in(self, email: str):
|
||||
"""Show login status in the atlas info widget."""
|
||||
self._atlas_url_label.setText(f"{self._atlas_url_text} | {email}")
|
||||
|
||||
def clear_login(self):
|
||||
"""Clear login status from the atlas info widget."""
|
||||
self._atlas_url_label.setText(self._atlas_url_text)
|
||||
|
||||
|
||||
class BECAtlasAdminView(BECWidget, QWidget):
|
||||
|
||||
RPC = False
|
||||
|
||||
authenticated = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
atlas_url: str = "https://bec-atlas-dev.psi.ch/api/v1",
|
||||
client=None,
|
||||
**kwargs,
|
||||
):
|
||||
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
|
||||
# State variables
|
||||
self._current_deployment_info: DeploymentInfoMessage | None = None
|
||||
self._current_deployment_info = None
|
||||
self._current_session_info = None
|
||||
self._current_experiment_info = None
|
||||
self._authenticated = False
|
||||
self._atlas_url = atlas_url
|
||||
|
||||
# Root layout
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.root_layout.setSpacing(0)
|
||||
|
||||
# Toolbar for navigation between different views in the admin panel
|
||||
self.toolbar = ModularToolBar(self)
|
||||
self.init_toolbar()
|
||||
self.root_layout.insertWidget(0, self.toolbar)
|
||||
self.toolbar.show_bundles(["view", "atlas_info", "auth"])
|
||||
|
||||
# Stacked layout to switch between overview, experiment selection and messaging services
|
||||
# It is added below the toolbar
|
||||
self.stacked_layout = QStackedLayout()
|
||||
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.stacked_layout.setSpacing(0)
|
||||
self.stacked_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
|
||||
self.root_layout.addLayout(self.stacked_layout)
|
||||
|
||||
# BEC Atlas HTTP Service
|
||||
self.atlas_http_service = BECAtlasHTTPService(
|
||||
parent=self, base_url=atlas_url, headers={"accept": "application/json"}
|
||||
)
|
||||
|
||||
# Overview widget
|
||||
self.overview_widget = OverviewWidget(parent=self)
|
||||
self.stacked_layout.addWidget(self.overview_widget)
|
||||
|
||||
# Experiment Selection widget
|
||||
self.experiment_selection = ExperimentSelection(parent=self)
|
||||
self.experiment_selection.setVisible(False)
|
||||
self.stacked_layout.addWidget(self.experiment_selection)
|
||||
|
||||
# Connect signals
|
||||
self.overview_widget.login_requested.connect(self._on_login_requested)
|
||||
self.overview_widget.change_experiment_requested.connect(
|
||||
self._on_experiment_selection_selected
|
||||
)
|
||||
self.authenticated.connect(self.overview_widget.set_authenticated)
|
||||
self.experiment_selection.experiment_selected.connect(self._on_experiment_selected)
|
||||
self.atlas_http_service.http_response.connect(self._on_http_response_received)
|
||||
self.atlas_http_service.authenticated.connect(self._on_authenticated)
|
||||
self._connect_dispatcher()
|
||||
|
||||
def _connect_dispatcher(self):
|
||||
self.bec_dispatcher.connect_slot(
|
||||
slot=self._update_deployment_info,
|
||||
topics=MessageEndpoints.deployment_info(),
|
||||
from_start=True,
|
||||
)
|
||||
|
||||
def init_toolbar(self):
|
||||
"""Initialize the toolbar for the admin view. This allows to switch between different views in the admin panel."""
|
||||
# Overview
|
||||
overview = MaterialIconAction(
|
||||
icon_name="home",
|
||||
tooltip="Show Overview Panel",
|
||||
label_text="Overview",
|
||||
text_position="under",
|
||||
parent=self,
|
||||
filled=True,
|
||||
)
|
||||
overview.action.triggered.connect(self._on_overview_selected)
|
||||
self.toolbar.components.add_safe("overview", overview)
|
||||
|
||||
# Experiment Selection
|
||||
experiment_selection = MaterialIconAction(
|
||||
icon_name="experiment",
|
||||
tooltip="Show Experiment Selection Panel",
|
||||
label_text="Experiment Selection",
|
||||
text_position="under",
|
||||
parent=self,
|
||||
filled=True,
|
||||
)
|
||||
experiment_selection.action.triggered.connect(self._on_experiment_selection_selected)
|
||||
experiment_selection.action.setEnabled(False) # Initially disabled until authenticated
|
||||
self.toolbar.components.add_safe("experiment_selection", experiment_selection)
|
||||
|
||||
# Messaging Services
|
||||
messaging_services = MaterialIconAction(
|
||||
icon_name="chat",
|
||||
tooltip="Show Messaging Services Panel",
|
||||
label_text="Messaging Services",
|
||||
text_position="under",
|
||||
parent=self,
|
||||
filled=True,
|
||||
)
|
||||
messaging_services.action.triggered.connect(self._on_messaging_services_selected)
|
||||
messaging_services.action.setEnabled(False) # Initially disabled until authenticated
|
||||
self.toolbar.components.add_safe("messaging_services", messaging_services)
|
||||
|
||||
# Atlas Info
|
||||
self._atlas_info_widget = AtlasConnectionInfo(parent=self)
|
||||
atlas_info = WidgetAction(widget=self._atlas_info_widget, parent=self)
|
||||
self.toolbar.components.add_safe("atlas_info", atlas_info)
|
||||
|
||||
logout_action = CustomLogoutAction(parent=self)
|
||||
logout_action.action.triggered.connect(self.logout)
|
||||
logout_action.action.setEnabled(False) # Initially disabled until authenticated
|
||||
self.toolbar.components.add_safe("logout", logout_action)
|
||||
|
||||
# Add view_bundle to toolbar
|
||||
view_bundle = ToolbarBundle("view", self.toolbar.components)
|
||||
view_bundle.add_action("overview")
|
||||
view_bundle.add_action("experiment_selection")
|
||||
view_bundle.add_action("messaging_services")
|
||||
self.toolbar.add_bundle(view_bundle)
|
||||
|
||||
# Add atlas_info to toolbar
|
||||
atlas_info_bundle = ToolbarBundle("atlas_info", self.toolbar.components)
|
||||
atlas_info_bundle.add_action("atlas_info")
|
||||
self.toolbar.add_bundle(atlas_info_bundle)
|
||||
|
||||
# Add auth_bundle to toolbar
|
||||
auth_bundle = ToolbarBundle("auth", self.toolbar.components)
|
||||
auth_bundle.add_action("logout")
|
||||
self.toolbar.add_bundle(auth_bundle)
|
||||
|
||||
########################
|
||||
## Toolbar icon slots
|
||||
########################
|
||||
|
||||
def _on_overview_selected(self):
|
||||
"""Show the overview panel."""
|
||||
self.overview_widget.setVisible(True)
|
||||
self.experiment_selection.setVisible(False)
|
||||
self.stacked_layout.setCurrentWidget(self.overview_widget)
|
||||
|
||||
def _on_experiment_selection_selected(self):
|
||||
"""Show the experiment selection panel."""
|
||||
if not self._authenticated:
|
||||
logger.warning("Attempted to access experiment selection without authentication.")
|
||||
return
|
||||
self.overview_widget.setVisible(False)
|
||||
self.experiment_selection.setVisible(True)
|
||||
self.stacked_layout.setCurrentWidget(self.experiment_selection)
|
||||
|
||||
def _on_messaging_services_selected(self):
|
||||
"""Show the messaging services panel."""
|
||||
logger.info("Messaging services panel is not implemented yet.")
|
||||
return
|
||||
|
||||
########################
|
||||
## Internal slots
|
||||
########################
|
||||
|
||||
@SafeSlot(dict)
|
||||
def _on_experiment_selected(self, experiment_info: dict) -> None:
|
||||
"""Handle the experiment selected signal from the experiment selection widget"""
|
||||
experiment_info = ExperimentInfoMessage.model_validate(experiment_info)
|
||||
experiment_id = experiment_info.pgroup
|
||||
deployment_id = self._current_deployment_info.deployment_id
|
||||
self.set_experiment(experiment_id=experiment_id, deployment_id=deployment_id)
|
||||
|
||||
@SafeSlot(str, str, popup_error=True)
|
||||
def _on_login_requested(self, username: str, password: str):
|
||||
"""Handle login requested signal from the overview widget."""
|
||||
# Logout first to clear any existing session and cookies before attempting a new login
|
||||
if self._authenticated:
|
||||
logger.info("Existing session detected, logging out before attempting new login.")
|
||||
self.logout()
|
||||
# Now login with new credentials
|
||||
self.login(username, password)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def _update_deployment_info(self, msg: dict, _: dict) -> None:
|
||||
"""Fetch current deployment info from the server."""
|
||||
deployment = DeploymentInfoMessage.model_validate(msg)
|
||||
self._current_deployment_info = deployment
|
||||
self._current_session_info = deployment.active_session
|
||||
if self._current_session_info is not None:
|
||||
self._current_experiment_info = self._current_session_info.experiment
|
||||
self.overview_widget.set_experiment_info(self._current_experiment_info)
|
||||
|
||||
self._atlas_info_widget.set_info(
|
||||
realm_id=self._current_experiment_info.realm_id or "",
|
||||
bl_name=self._current_deployment_info.name or "",
|
||||
atlas_url=self._atlas_url,
|
||||
)
|
||||
self.atlas_http_service._set_current_deployment_info(deployment)
|
||||
|
||||
def _fetch_available_experiments(self):
|
||||
"""Fetch the list of available experiments for the authenticated user."""
|
||||
# What if this is None, should this be an optional user input in the UI?
|
||||
if self._current_experiment_info is None:
|
||||
logger.error(
|
||||
"No current experiment info available, cannot fetch available experiments."
|
||||
)
|
||||
return
|
||||
current_realm_id = self._current_experiment_info.realm_id
|
||||
if current_realm_id is None:
|
||||
logger.error(
|
||||
"Current experiment does not have a realm_id, cannot fetch available experiments."
|
||||
)
|
||||
return
|
||||
self.atlas_http_service.get_experiments_for_realm(current_realm_id)
|
||||
|
||||
########################
|
||||
## HTTP Service response handling
|
||||
########################
|
||||
|
||||
def _on_http_response_received(self, response: dict) -> None:
|
||||
"""Handle the HTTP response received from the BEC Atlas API."""
|
||||
response = HTTPResponse(**response)
|
||||
logger.debug(
|
||||
f"HTTP Response received: {response.request_url} with status {response.status}"
|
||||
)
|
||||
if AtlasEndpoints.REALMS_EXPERIMENTS in response.request_url:
|
||||
experiments = response.data if isinstance(response.data, list) else []
|
||||
self.experiment_selection.set_experiment_infos(experiments)
|
||||
elif AtlasEndpoints.SET_EXPERIMENT in response.request_url:
|
||||
self._on_overview_selected()
|
||||
|
||||
@SafeSlot(dict)
|
||||
def _on_authenticated(self, auth_info: dict) -> None:
|
||||
"""Handle authentication state change."""
|
||||
authenticated = False
|
||||
# Only if the user has owner access to the deployment, we consider them to be fully authenticated
|
||||
# This means that although they may authenticate against atlas, they won't be able to see any
|
||||
# extra information here
|
||||
if auth_info:
|
||||
info = AuthenticatedUserInfo.model_validate(auth_info)
|
||||
if (
|
||||
self._current_deployment_info
|
||||
and info.deployment_id == self._current_deployment_info.deployment_id
|
||||
):
|
||||
authenticated = True
|
||||
else:
|
||||
logger.warning(
|
||||
f"Authenticated user {info.email} does not have access to the current deployment {self._current_deployment_info.name if self._current_deployment_info else '<no deployment>'}."
|
||||
)
|
||||
self._authenticated = authenticated
|
||||
|
||||
if authenticated:
|
||||
self.toolbar.components.get_action("experiment_selection").action.setEnabled(True)
|
||||
self.toolbar.components.get_action("messaging_services").action.setEnabled(
|
||||
False
|
||||
) # TODO activate once messaging is added
|
||||
self.toolbar.components.get_action("logout").action.setEnabled(True)
|
||||
self._fetch_available_experiments() # Fetch experiments upon successful authentication
|
||||
self._atlas_info_widget.set_logged_in(info.email)
|
||||
self.toolbar.components.get_action("logout").set_authenticated(info)
|
||||
else:
|
||||
self.toolbar.components.get_action("experiment_selection").action.setEnabled(False)
|
||||
self.toolbar.components.get_action("messaging_services").action.setEnabled(False)
|
||||
self.toolbar.components.get_action("logout").action.setEnabled(False)
|
||||
# Delete data in experiment selection widget upon logout
|
||||
self.experiment_selection.set_experiment_infos([])
|
||||
self._on_overview_selected() # Switch back to overview on logout
|
||||
self._atlas_info_widget.clear_login() # Clear login status in atlas info widget on logout
|
||||
self.toolbar.components.get_action("logout").set_authenticated(None)
|
||||
self.authenticated.emit(authenticated)
|
||||
|
||||
################
|
||||
## API Methods
|
||||
################
|
||||
|
||||
@SafeSlot(str, str, popup_error=True)
|
||||
def set_experiment(self, experiment_id: str, deployment_id: str) -> None:
|
||||
"""Set the experiment information for the current experiment."""
|
||||
self.atlas_http_service.set_experiment(experiment_id, deployment_id)
|
||||
|
||||
@SafeSlot(str, str, popup_error=True)
|
||||
def login(self, username: str, password: str) -> None:
|
||||
"""Login to the BEC Atlas API with the provided username and password."""
|
||||
self.atlas_http_service.login(username=username, password=password)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def logout(self) -> None:
|
||||
"""Logout from the BEC Atlas API."""
|
||||
self.atlas_http_service.logout()
|
||||
|
||||
def get_user_info(self):
|
||||
"""Get the current user information from the BEC Atlas API."""
|
||||
self.atlas_http_service.get_user_info()
|
||||
|
||||
###############
|
||||
## Cleanup
|
||||
###############
|
||||
|
||||
def cleanup(self):
|
||||
self.atlas_http_service.cleanup()
|
||||
return super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
apply_theme("light")
|
||||
window = BECAtlasAdminView()
|
||||
|
||||
exp_info_dict = {
|
||||
"_id": "p22622",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22622"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "12345967",
|
||||
"title": "Test Experiment for Mat Card Widget",
|
||||
"firstname": "John",
|
||||
"lastname": "Doe",
|
||||
"email": "john.doe@psi.ch",
|
||||
"account": "doe_j",
|
||||
"pi_firstname": "Jane",
|
||||
"pi_lastname": "Smith",
|
||||
"pi_email": "jane.smith@psi.ch",
|
||||
"pi_account": "smith_j",
|
||||
"eaccount": "e22622",
|
||||
"pgroup": "p22622",
|
||||
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
|
||||
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
|
||||
"proposal_submitted": "15/12/2024",
|
||||
"proposal_expire": "31/12/2025",
|
||||
"proposal_status": "Scheduled",
|
||||
"delta_last_schedule": 30,
|
||||
"mainproposal": "",
|
||||
}
|
||||
from bec_lib.messages import DeploymentInfoMessage, ExperimentInfoMessage, SessionInfoMessage
|
||||
|
||||
# proposal_info = ExperimentInfoMessage(**exp_info_dict)
|
||||
# session_info = SessionInfoMessage(name="Test Session", experiment=proposal_info)
|
||||
# deployment_info = DeploymentInfoMessage(
|
||||
# deployment_id="test_deployment_001", active_session=session_info
|
||||
# )
|
||||
# window.set_experiment_info(proposal_info)
|
||||
window.resize(800, 600)
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1,386 @@
|
||||
"""
|
||||
HTTP service widget for interacting with the BEC Atlas API.
|
||||
|
||||
This module defines Qt-based classes that wrap ``QNetworkAccessManager`` to perform
|
||||
authenticated HTTP requests against the BEC Atlas backend, manage login and logout
|
||||
flows, and track authentication token expiry for the BEC Atlas Admin View.
|
||||
|
||||
It also provides Pydantic models that describe HTTP responses and authenticated user
|
||||
information, as well as an ``AuthenticatedTimer`` helper used to signal when
|
||||
authentication tokens expire.
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
import jwt
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import DeploymentInfoMessage
|
||||
from pydantic import BaseModel
|
||||
from qtpy.QtCore import QObject, QTimer, QUrl, QUrlQuery, Signal
|
||||
from qtpy.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
|
||||
from qtpy.QtWidgets import QMessageBox, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class AtlasEndpoints(StrEnum):
|
||||
"""Constants for BEC Atlas API endpoints."""
|
||||
|
||||
LOGIN = "/user/login"
|
||||
LOGOUT = "/user/logout"
|
||||
REALMS_EXPERIMENTS = "/realms/experiments"
|
||||
SET_EXPERIMENT = "/deployments/experiment"
|
||||
USER_INFO = "/user/me"
|
||||
DEPLOYMENT_INFO = "/deployments/id"
|
||||
|
||||
|
||||
class BECAtlasHTTPError(Exception):
|
||||
"""Custom exception for BEC Atlas HTTP errors."""
|
||||
|
||||
|
||||
class HTTPResponse(BaseModel):
|
||||
"""Model representing an HTTP response."""
|
||||
|
||||
request_url: str
|
||||
headers: dict
|
||||
status: int
|
||||
data: dict | list | str # Check with Klaus if str is deprecated
|
||||
|
||||
|
||||
class AuthenticatedUserInfo(BaseModel):
|
||||
"""Model representing authenticated user information."""
|
||||
|
||||
email: str
|
||||
exp: float
|
||||
groups: set[str]
|
||||
deployment_id: str
|
||||
|
||||
|
||||
class AuthenticatedTimer(QObject):
|
||||
"""Timer to track authentication expiration and emit a signal when the token expires."""
|
||||
|
||||
expired = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(self._on_expired)
|
||||
|
||||
def start(self, duration_seconds: float):
|
||||
"""Start the timer with the given duration in seconds."""
|
||||
self._timer.start(int(duration_seconds * 1000))
|
||||
|
||||
def stop(self):
|
||||
"""Stop the timer."""
|
||||
self._timer.stop()
|
||||
|
||||
@SafeSlot()
|
||||
def _on_expired(self):
|
||||
"""Handle the timer expiration by emitting the expired signal."""
|
||||
logger.info("Authentication token has expired.")
|
||||
self.expired.emit()
|
||||
|
||||
|
||||
class BECAtlasHTTPService(QWidget):
|
||||
"""HTTP service using the QNetworkAccessManager to interact with the BEC Atlas API."""
|
||||
|
||||
http_response = Signal(dict) # HTTPResponse.model_dump() dict
|
||||
authenticated = Signal(dict) # AuthenticatedUserInfo.model_dump() dict or {}
|
||||
authentication_expires = Signal(float)
|
||||
|
||||
def __init__(self, parent=None, base_url: str = "", headers: dict | None = None):
|
||||
super().__init__(parent)
|
||||
if headers is None:
|
||||
headers = {"accept": "application/json"}
|
||||
self._headers = headers
|
||||
self._base_url = base_url
|
||||
self.network_manager = QNetworkAccessManager(self)
|
||||
self.network_manager.finished.connect(self._handle_response)
|
||||
self._auth_user_info: AuthenticatedUserInfo | None = None
|
||||
self._auth_timer = self._create_auth_timer()
|
||||
self._current_deployment_info = None
|
||||
|
||||
def _create_auth_timer(self) -> AuthenticatedTimer:
|
||||
"""Create and connect the authenticated timer to handle token expiration."""
|
||||
timer = AuthenticatedTimer(self)
|
||||
timer.expired.connect(self.__clear_login_info)
|
||||
return timer
|
||||
|
||||
@property
|
||||
def auth_user_info(self) -> AuthenticatedUserInfo | None:
|
||||
"""Get the authenticated user information, including email and token expiration time."""
|
||||
return self._auth_user_info
|
||||
|
||||
def __set_auth_info(self, login_info: dict[Literal["email", "exp"], str | float]):
|
||||
"""Set the authenticated user information after a successful login."""
|
||||
login_info.update({"groups": []}) # Initialize groups as empty until we fetch user info
|
||||
login_info.update(
|
||||
{
|
||||
"deployment_id": (
|
||||
self._current_deployment_info.deployment_id
|
||||
if self._current_deployment_info
|
||||
else ""
|
||||
)
|
||||
}
|
||||
)
|
||||
self._auth_user_info = AuthenticatedUserInfo(**login_info)
|
||||
# Start timer to clear auth info once token expires
|
||||
exp_time = login_info.get("exp", 0)
|
||||
current_time = time.time() # TODO should we use server time to avoid clock skew issues?
|
||||
duration = max(0, exp_time - current_time)
|
||||
self._auth_timer.start(duration)
|
||||
|
||||
def __set_auth_groups(self, groups: list[str]):
|
||||
"""Set the authenticated user's groups after fetching user info."""
|
||||
if self._auth_user_info is not None:
|
||||
self._auth_user_info.groups = set(groups)
|
||||
|
||||
def __clear_login_info(self, skip_logout: bool = False):
|
||||
"""Clear the authenticated user information after logout."""
|
||||
self._auth_user_info = None
|
||||
if not skip_logout:
|
||||
self.logout() # Ensure we also logout on the server side and invalidate the session
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.cleanup()
|
||||
return super().closeEvent(event)
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup connection, destroy authenticate cookies."""
|
||||
logger.info("Cleaning up BECAtlasHTTPService: disconnecting signals and clearing cookies.")
|
||||
# Disconnect signals to avoid handling responses after cleanup
|
||||
self.network_manager.finished.disconnect(self._handle_response)
|
||||
|
||||
# Logout to invalidate session on server side
|
||||
self.logout()
|
||||
|
||||
# Stop the authentication timer
|
||||
self._auth_timer.stop()
|
||||
|
||||
# Delete all cookies related to the base URL
|
||||
for cookie in self.network_manager.cookieJar().cookiesForUrl(QUrl(self._base_url)):
|
||||
self.network_manager.cookieJar().deleteCookie(cookie)
|
||||
|
||||
@SafeSlot(QNetworkReply, popup_error=True)
|
||||
def _handle_response(self, reply: QNetworkReply):
|
||||
"""
|
||||
Handle the HTTP response from the server.
|
||||
|
||||
Args:
|
||||
reply (QNetworkReply): The network reply object containing the response.
|
||||
"""
|
||||
status = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
|
||||
raw_bytes = reply.readAll().data()
|
||||
request_url = reply.url().toString()
|
||||
headers = dict([(i.data().decode(), j.data().decode()) for i, j in reply.rawHeaderPairs()])
|
||||
reply.deleteLater()
|
||||
|
||||
# Any unsuccessful status code should raise here
|
||||
if status != 200:
|
||||
raise BECAtlasHTTPError(
|
||||
f"HTTP request for {request_url} failed with status code {status} and response: {raw_bytes.decode('utf-8')}"
|
||||
)
|
||||
|
||||
if len(raw_bytes) > 0:
|
||||
data = json.loads(raw_bytes.decode())
|
||||
else:
|
||||
data = {}
|
||||
|
||||
if data is None:
|
||||
data = {}
|
||||
logger.warning(f"Received empty response for {request_url} with status code {status}.")
|
||||
|
||||
if not isinstance(data, (dict, list, str)):
|
||||
raise BECAtlasHTTPError(
|
||||
f"Expected response data to be a dict, list, or str for {request_url}, but got {type(data)}. Response content: {data}"
|
||||
)
|
||||
|
||||
if AtlasEndpoints.LOGIN.value in request_url:
|
||||
# If it's a login response, don't forward the token
|
||||
# but extract the expiration time and emit it
|
||||
token = data.get("access_token")
|
||||
data = jwt.decode(token, options={"verify_signature": False})
|
||||
self.authentication_expires.emit(data.get("exp", 0))
|
||||
# Now we set the auth info, and then fetch the user info to get the groups
|
||||
self.__set_auth_info(data)
|
||||
self.get_user_info() # Fetch groups, then emit authenticated once groups are set on auth_user
|
||||
elif AtlasEndpoints.LOGOUT.value in request_url:
|
||||
self._auth_timer.stop() # Stop the timer if it was running
|
||||
self.__clear_login_info(skip_logout=True) # Skip calling logout again
|
||||
self.authenticated.emit({})
|
||||
elif AtlasEndpoints.USER_INFO.value in request_url:
|
||||
groups = data.get("groups", [])
|
||||
email = data.get("email", "")
|
||||
# Second step of authentication: We also have all groups now
|
||||
if self.auth_user_info is not None and self.auth_user_info.email == email:
|
||||
self.__set_auth_groups(groups)
|
||||
if self._current_deployment_info is not None:
|
||||
# Now we need to fetch the deployment info to get the owner groups and check access rights,
|
||||
# Then we can emit the authenticated signal with the full user info including groups if access is
|
||||
# granted. Otherwise, we emit nothing and show a warning that the user does not have the access
|
||||
# rights for the current deployment.
|
||||
self.get_deployment_info(
|
||||
deployment_id=self._current_deployment_info.deployment_id
|
||||
)
|
||||
elif AtlasEndpoints.DEPLOYMENT_INFO.value in request_url:
|
||||
owner_groups = data.get("owner_groups", [])
|
||||
if self.auth_user_info is not None and not self.auth_user_info.groups.isdisjoint(
|
||||
owner_groups
|
||||
):
|
||||
self.authenticated.emit(self.auth_user_info.model_dump())
|
||||
else:
|
||||
if self.auth_user_info is not None:
|
||||
warning_text = f"User {self.auth_user_info.email} does not have access to the active deployment {data.get('name', '<unknown>')}."
|
||||
else:
|
||||
warning_text = "Authenticated user information is missing. Cannot verify access to the active deployment."
|
||||
self._show_warning(warning_text)
|
||||
self.logout() # Logout to clear auth info and stop timer since user does not have access
|
||||
|
||||
response = HTTPResponse(request_url=request_url, headers=headers, status=status, data=data)
|
||||
self.http_response.emit(response.model_dump())
|
||||
|
||||
def _show_warning(self, text: str):
|
||||
"""Show a warning message to the user."""
|
||||
msg = QMessageBox(self)
|
||||
msg.setIcon(QMessageBox.Icon.Warning)
|
||||
msg.setText(text)
|
||||
msg.setWindowTitle("Authentication Warning")
|
||||
msg.exec_()
|
||||
|
||||
#######################
|
||||
# GET/POST Request Methods
|
||||
#######################
|
||||
|
||||
def _get_request(self, endpoint: str, query_parameters: dict | None = None):
|
||||
"""
|
||||
GET request to the API endpoint.
|
||||
|
||||
Args:
|
||||
endpoint (str): The API endpoint to send the GET request to.
|
||||
query_parameters (dict | None): Optional query parameters to include in the URL.
|
||||
"""
|
||||
url = QUrl(self._base_url + endpoint)
|
||||
if query_parameters:
|
||||
query = QUrlQuery()
|
||||
for key, value in query_parameters.items():
|
||||
query.addQueryItem(key, value)
|
||||
url.setQuery(query)
|
||||
request = QNetworkRequest(url)
|
||||
for key, value in self._headers.items():
|
||||
request.setRawHeader(key.encode(), value.encode())
|
||||
self.network_manager.get(request)
|
||||
|
||||
def _post_request(
|
||||
self, endpoint: str, payload: dict | None = None, query_parameters: dict | None = None
|
||||
):
|
||||
"""
|
||||
POST request to the API endpoint with a JSON payload.
|
||||
|
||||
Args:
|
||||
endpoint (str): The API endpoint to send the POST request to.
|
||||
payload (dict): The JSON payload to include in the POST request.
|
||||
query_parameters (dict | None): Optional query parameters to include in the URL.
|
||||
"""
|
||||
if payload is None:
|
||||
payload = {}
|
||||
url = QUrl(self._base_url + endpoint)
|
||||
if query_parameters:
|
||||
query = QUrlQuery()
|
||||
for key, value in query_parameters.items():
|
||||
query.addQueryItem(key, value)
|
||||
url.setQuery(query)
|
||||
request = QNetworkRequest(url)
|
||||
|
||||
# Headers
|
||||
for key, value in self._headers.items():
|
||||
request.setRawHeader(key.encode(), value.encode())
|
||||
request.setHeader(QNetworkRequest.KnownHeaders.ContentTypeHeader, "application/json")
|
||||
|
||||
payload_dump = json.dumps(payload).encode()
|
||||
self.network_manager.post(request, payload_dump)
|
||||
|
||||
def _set_current_deployment_info(self, deployment_info: dict | DeploymentInfoMessage):
|
||||
"""
|
||||
Set the current deployment information for the service.
|
||||
|
||||
Args:
|
||||
deployment_info (dict | DeploymentInfoMessage): The deployment information to set.
|
||||
"""
|
||||
if isinstance(deployment_info, dict):
|
||||
deployment_info = DeploymentInfoMessage.model_validate(deployment_info)
|
||||
self._current_deployment_info = deployment_info
|
||||
|
||||
################
|
||||
# API Methods
|
||||
################
|
||||
|
||||
@SafeSlot(str, str, popup_error=True)
|
||||
def login(self, username: str, password: str):
|
||||
"""
|
||||
Login to BEC Atlas with the provided username and password.
|
||||
|
||||
Args:
|
||||
username (str): The username for authentication.
|
||||
password (str): The password for authentication.
|
||||
"""
|
||||
self._post_request(
|
||||
endpoint=AtlasEndpoints.LOGIN.value,
|
||||
payload={"username": username, "password": password},
|
||||
)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def logout(self):
|
||||
"""Logout from BEC Atlas."""
|
||||
self._post_request(endpoint=AtlasEndpoints.LOGOUT.value)
|
||||
|
||||
@SafeSlot(str, popup_error=True)
|
||||
def get_experiments_for_realm(self, realm_id: str):
|
||||
"""
|
||||
Get the list of realms from BEC Atlas. Requires authentication.
|
||||
|
||||
Args:
|
||||
realm_id (str): The ID of the realm to retrieve experiments for.
|
||||
"""
|
||||
self._get_request(
|
||||
endpoint=AtlasEndpoints.REALMS_EXPERIMENTS.value,
|
||||
query_parameters={"realm_id": realm_id},
|
||||
)
|
||||
|
||||
@SafeSlot(str, str)
|
||||
def set_experiment(self, experiment_id: str, deployment_id: str) -> None:
|
||||
"""
|
||||
Set the current experiment information for the service.
|
||||
|
||||
Args:
|
||||
experiment_id (str): The ID of the experiment to set.
|
||||
deployment_id (str): The ID of the deployment associated with the experiment.
|
||||
"""
|
||||
self._post_request(
|
||||
endpoint=AtlasEndpoints.SET_EXPERIMENT.value,
|
||||
query_parameters={"experiment_id": experiment_id, "deployment_id": deployment_id},
|
||||
)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def get_user_info(self):
|
||||
"""Get the current user information from BEC Atlas. Requires authentication."""
|
||||
self._get_request(endpoint=AtlasEndpoints.USER_INFO.value)
|
||||
|
||||
@SafeSlot(str, popup_error=True)
|
||||
def get_deployment_info(self, deployment_id: str):
|
||||
"""
|
||||
Get the deployment information for a given deployment ID. Requires authentication.
|
||||
|
||||
Args:
|
||||
deployment_id (str): The ID of the deployment to retrieve information for.
|
||||
"""
|
||||
self._get_request(
|
||||
endpoint=AtlasEndpoints.DEPLOYMENT_INFO.value,
|
||||
query_parameters={"deployment_id": deployment_id},
|
||||
)
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Mat-card like widget to display experiment details. Optionally, a button on the bottom which the user can click to trigger the selection of the experiment."""
|
||||
|
||||
from bec_lib.messages import ExperimentInfoMessage
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QFrame,
|
||||
QGraphicsDropShadowEffect,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
from bec_widgets.utils.round_frame import RoundedFrame
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.utils import (
|
||||
format_name,
|
||||
format_schedule,
|
||||
)
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
|
||||
class BorderLessLabel(QLabel):
|
||||
"""A QLabel that does not show any border, even when stylesheets try to apply one."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setStyleSheet("border: none;")
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
|
||||
|
||||
|
||||
class ExperimentMatCard(BECWidget, QWidget):
|
||||
|
||||
RPC = False
|
||||
|
||||
experiment_selected = Signal(dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
show_activate_button: bool = True,
|
||||
button_text: str = "Activate",
|
||||
title: str = "Next Experiment",
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, theme_update=True, **kwargs)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(12, 8, 12, 8)
|
||||
self.experiment_info = {}
|
||||
self._abstract_text = ""
|
||||
|
||||
# Add card frame with shadow and custom styling
|
||||
self._card_frame = QFrame(parent=self)
|
||||
layout = QVBoxLayout(self._card_frame)
|
||||
self._card_frame.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
|
||||
palette = get_theme_palette()
|
||||
self._card_frame.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
border: 1px solid {palette.mid().color().name()};
|
||||
background: {palette.base().color().name()};
|
||||
}}
|
||||
""")
|
||||
shadow = QGraphicsDropShadowEffect(self._card_frame)
|
||||
shadow.setBlurRadius(18)
|
||||
shadow.setOffset(0, 4)
|
||||
shadow.setColor(palette.shadow().color())
|
||||
self._card_frame.setGraphicsEffect(shadow)
|
||||
|
||||
self._group_box = QGroupBox(self._card_frame)
|
||||
self._group_box.setStyleSheet(
|
||||
"QGroupBox { border: none; }; QLabel { border: none; padding: 0px; }"
|
||||
)
|
||||
self._fill_group_box(
|
||||
title=title, show_activate_button=show_activate_button, button_text=button_text
|
||||
)
|
||||
self.apply_theme("light")
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
palette = get_theme_palette()
|
||||
self._card_frame.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
border: 1px solid {palette.mid().color().name()};
|
||||
background: {palette.base().color().name()};
|
||||
}}
|
||||
""")
|
||||
shadow = self._card_frame.graphicsEffect()
|
||||
if isinstance(shadow, QGraphicsDropShadowEffect):
|
||||
shadow.setColor(palette.shadow().color())
|
||||
|
||||
def _fill_group_box(
|
||||
self, title: str, show_activate_button: bool, button_text: str = "Activate"
|
||||
):
|
||||
group_layout = QVBoxLayout(self._group_box)
|
||||
group_layout.setContentsMargins(16, 16, 16, 16)
|
||||
group_layout.setSpacing(12)
|
||||
|
||||
title_row = QHBoxLayout()
|
||||
self._card_title = BorderLessLabel(title, self._group_box)
|
||||
self._card_title.setStyleSheet("""
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
""")
|
||||
|
||||
# Add title row and info button to QH layout, then add it to QV layout
|
||||
title_row.addWidget(self._card_title)
|
||||
title_row.addStretch(1)
|
||||
group_layout.addLayout(title_row)
|
||||
|
||||
self._card_grid = QGridLayout()
|
||||
self._card_grid.setHorizontalSpacing(12)
|
||||
self._card_grid.setVerticalSpacing(8)
|
||||
self._card_grid.setColumnStretch(1, 1)
|
||||
|
||||
self._card_pgroup = BorderLessLabel("-", self._group_box)
|
||||
self._card_title_value = BorderLessLabel("-", self._group_box)
|
||||
self._card_title_value.setWordWrap(True)
|
||||
self._card_title_value.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
self._card_name = BorderLessLabel("-", self._group_box)
|
||||
self._card_start = BorderLessLabel("-", self._group_box)
|
||||
self._card_end = BorderLessLabel("-", self._group_box)
|
||||
|
||||
self._card_row_labels = []
|
||||
|
||||
def _row_label(text):
|
||||
label = BorderLessLabel(text, self._group_box)
|
||||
self._card_row_labels.append(label)
|
||||
return label
|
||||
|
||||
self._card_grid.addWidget(_row_label("Name"), 0, 0)
|
||||
self._card_grid.addWidget(self._card_name, 0, 1)
|
||||
self._card_grid.addWidget(_row_label("Title"), 1, 0)
|
||||
self._card_grid.addWidget(self._card_title_value, 1, 1)
|
||||
self._card_grid.addWidget(_row_label("P-group"), 2, 0)
|
||||
self._card_grid.addWidget(self._card_pgroup, 2, 1)
|
||||
self._card_grid.addWidget(_row_label("Schedule (start)"), 3, 0)
|
||||
self._card_grid.addWidget(self._card_start, 3, 1)
|
||||
self._card_grid.addWidget(_row_label("Schedule (end)"), 4, 0)
|
||||
self._card_grid.addWidget(self._card_end, 4, 1)
|
||||
|
||||
# Add to groupbox
|
||||
group_layout.addLayout(self._card_grid)
|
||||
|
||||
# Add abstract field at the bottom of the card.
|
||||
self._abstract_label = BorderLessLabel("", self._group_box)
|
||||
self._abstract_label.setWordWrap(True)
|
||||
self._abstract_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
group_layout.addWidget(self._abstract_label)
|
||||
|
||||
# Add activate button at the bottom
|
||||
self._activate_button = QPushButton(button_text, self._group_box)
|
||||
self._activate_button.clicked.connect(self._emit_next_experiment)
|
||||
self._activate_button.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
|
||||
)
|
||||
group_layout.addWidget(self._activate_button, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
self._activate_button.setVisible(show_activate_button)
|
||||
self._activate_button.setEnabled(False)
|
||||
|
||||
self._card_frame.layout().setContentsMargins(12, 12, 12, 12)
|
||||
self._card_frame.layout().addWidget(self._group_box)
|
||||
|
||||
card_row = QHBoxLayout()
|
||||
card_row.addStretch(0)
|
||||
card_row.addWidget(self._card_frame)
|
||||
card_row.addStretch(0)
|
||||
|
||||
layout = self.layout()
|
||||
layout.addStretch(0)
|
||||
layout.addLayout(card_row)
|
||||
layout.addStretch(0)
|
||||
|
||||
def _emit_next_experiment(self):
|
||||
self.experiment_selected.emit(self.experiment_info)
|
||||
|
||||
def clear_experiment_info(self):
|
||||
"""
|
||||
Clear the experiment information displayed on the card and disable the activate button.
|
||||
"""
|
||||
self._card_pgroup.setText("-")
|
||||
self._card_title_value.setText("-")
|
||||
self._card_name.setText("-")
|
||||
self._card_start.setText("-")
|
||||
self._card_end.setText("-")
|
||||
self._abstract_text = ""
|
||||
self._abstract_label.setText("")
|
||||
self.experiment_info = {}
|
||||
self._activate_button.setEnabled(False)
|
||||
|
||||
def set_experiment_info(self, info: ExperimentInfoMessage | dict):
|
||||
"""
|
||||
Set the experiment information to display on the card.
|
||||
|
||||
Args:
|
||||
info (ExperimentInfoMessage | dict): The experiment information to display. Can be either a
|
||||
dictionary or an ExperimentInfoMessage instance.
|
||||
"""
|
||||
if isinstance(info, dict):
|
||||
info = ExperimentInfoMessage(**info)
|
||||
|
||||
start, end = format_schedule(info.schedule)
|
||||
self._card_pgroup.setText(info.pgroup or "-")
|
||||
self._card_title_value.setText(info.title or "-")
|
||||
self._card_name.setText(format_name(info))
|
||||
self._card_start.setText(start or "-")
|
||||
self._card_end.setText(end or "-")
|
||||
self._abstract_text = (info.abstract or "").strip()
|
||||
self._abstract_label.setText(self._abstract_text if self._abstract_text else "")
|
||||
self.experiment_info = info.model_dump()
|
||||
self._activate_button.setEnabled(True)
|
||||
|
||||
def set_title(self, title: str):
|
||||
"""
|
||||
Set the title displayed at the top of the card.
|
||||
|
||||
Args:
|
||||
title (str): The title text to display.
|
||||
"""
|
||||
self._card_title.setText(title)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
exp_info = {
|
||||
"_id": "p22622",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22622"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "12345967",
|
||||
"title": "Test Experiment for Mat Card Widget",
|
||||
"firstname": "John",
|
||||
"lastname": "Doe",
|
||||
"email": "john.doe@psi.ch",
|
||||
"account": "doe_j",
|
||||
"pi_firstname": "Jane",
|
||||
"pi_lastname": "Smith",
|
||||
"pi_email": "jane.smith@psi.ch",
|
||||
"pi_account": "smith_j",
|
||||
"eaccount": "e22622",
|
||||
"pgroup": "p22622",
|
||||
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
|
||||
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
|
||||
"proposal_submitted": "15/12/2024",
|
||||
"proposal_expire": "31/12/2025",
|
||||
"proposal_status": "Scheduled",
|
||||
"delta_last_schedule": 30,
|
||||
"mainproposal": "",
|
||||
}
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
apply_theme("dark")
|
||||
w = QWidget()
|
||||
l = QVBoxLayout(w)
|
||||
button = DarkModeButton()
|
||||
widget = ExperimentMatCard()
|
||||
widget.set_experiment_info(exp_info)
|
||||
widget.set_title("Scheduled Experiment")
|
||||
l.addWidget(button)
|
||||
l.addWidget(widget)
|
||||
w.resize(w.sizeHint())
|
||||
w.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1,395 @@
|
||||
"""Experiment Selection View for BEC Atlas Admin Widget"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QSizePolicy,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from thefuzz import fuzz
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.fuzzy_search import is_match
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_mat_card import (
|
||||
ExperimentMatCard,
|
||||
)
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.utils import (
|
||||
format_name,
|
||||
format_schedule,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ExperimentSelection(QWidget):
|
||||
experiment_selected = Signal(dict)
|
||||
|
||||
def __init__(self, experiment_infos=None, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self._experiment_infos = experiment_infos or []
|
||||
self._next_experiment = self._select_next_experiment(self._experiment_infos)
|
||||
self._enable_fuzzy_search: bool = True
|
||||
self._hidden_rows: set[int] = set()
|
||||
self._headers: dict[str, str] = {
|
||||
"pgroup": "pgroup",
|
||||
"title": "Title",
|
||||
"name": "Name",
|
||||
"schedule_start": "Schedule (start)",
|
||||
"schedule_end": "Schedule (end)",
|
||||
}
|
||||
self._table_infos: list[dict[str, Any]] = []
|
||||
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
# main_layout.setSpacing(12)
|
||||
main_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.setAutoFillBackground(True)
|
||||
|
||||
self._tabs = QTabWidget(self)
|
||||
main_layout.addWidget(self._tabs, stretch=1)
|
||||
|
||||
self._card_tab = ExperimentMatCard(
|
||||
parent=self, show_activate_button=True, button_text="Activate"
|
||||
)
|
||||
self._card_tab.experiment_selected.connect(self._emit_selected_experiment)
|
||||
if self._next_experiment:
|
||||
self._card_tab.set_experiment_info(self._next_experiment)
|
||||
self._table_tab = QWidget(self)
|
||||
self._tabs.addTab(self._card_tab, "Next Experiment")
|
||||
self._tabs.addTab(self._table_tab, "Manual Selection")
|
||||
|
||||
self._build_table_tab()
|
||||
self._tabs.currentChanged.connect(self._on_tab_changed)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
main_layout.addLayout(button_layout)
|
||||
self._apply_table_filters()
|
||||
self.restore_default_view()
|
||||
|
||||
def restore_default_view(self):
|
||||
"""Reset the view to the default state, showing the next experiment card."""
|
||||
self._tabs.setCurrentWidget(self._card_tab)
|
||||
|
||||
def set_experiment_infos(self, experiment_infos: list[dict]):
|
||||
"""
|
||||
Update the experiment information displayed in the view. It will in addition determine
|
||||
the next experiment to be shown in the card view. If no next experiment can be determined,
|
||||
the card view will be cleared.
|
||||
|
||||
Args:
|
||||
experiment_infos (list[dict]): A list of experiment information dictionaries.
|
||||
"""
|
||||
self._experiment_infos = experiment_infos
|
||||
self._next_experiment = self._select_next_experiment(self._experiment_infos)
|
||||
if self._next_experiment:
|
||||
self._card_tab.set_experiment_info(self._next_experiment)
|
||||
else:
|
||||
self._card_tab.clear_experiment_info()
|
||||
self._apply_table_filters()
|
||||
|
||||
def _setup_search(self, layout: QVBoxLayout):
|
||||
"""
|
||||
Create components related to the search functionality
|
||||
|
||||
Args:
|
||||
layout (QVBoxLayout): The layout to which the search components will be added.
|
||||
"""
|
||||
|
||||
# Create search bar
|
||||
search_layout = QHBoxLayout()
|
||||
self.search_label = QLabel("Search:")
|
||||
self.search_input = QLineEdit()
|
||||
self.search_input.setPlaceholderText("Filter experiments...")
|
||||
self.search_input.setClearButtonEnabled(True)
|
||||
self.search_input.textChanged.connect(self._apply_row_filter)
|
||||
search_layout.addWidget(self.search_label)
|
||||
search_layout.addWidget(self.search_input)
|
||||
|
||||
# Add exact match toggle
|
||||
fuzzy_layout = QHBoxLayout()
|
||||
self.fuzzy_label = QLabel("Exact Match:")
|
||||
self.fuzzy_is_disabled = QCheckBox()
|
||||
|
||||
self.fuzzy_is_disabled.stateChanged.connect(self._state_change_fuzzy_search)
|
||||
self.fuzzy_is_disabled.setToolTip(
|
||||
"Enable approximate matching (OFF) and exact matching (ON)"
|
||||
)
|
||||
self.fuzzy_label.setToolTip("Enable approximate matching (OFF) and exact matching (ON)")
|
||||
fuzzy_layout.addWidget(self.fuzzy_label)
|
||||
fuzzy_layout.addWidget(self.fuzzy_is_disabled)
|
||||
fuzzy_layout.addStretch()
|
||||
|
||||
# Add both search components to the layout
|
||||
self.search_controls = QHBoxLayout()
|
||||
self.search_controls.addLayout(search_layout)
|
||||
self.search_controls.addSpacing(20) # Add some space between the search box and toggle
|
||||
self.search_controls.addLayout(fuzzy_layout)
|
||||
|
||||
# Add filter section for proposals
|
||||
|
||||
filter_layout = QHBoxLayout()
|
||||
filter_layout.setContentsMargins(12, 0, 12, 0)
|
||||
filter_layout.setSpacing(12)
|
||||
self._with_proposals = QCheckBox("Show experiments with proposals", self)
|
||||
self._without_proposals = QCheckBox("Show experiments without proposals", self)
|
||||
self._with_proposals.setChecked(True)
|
||||
self._without_proposals.setChecked(True)
|
||||
self._with_proposals.toggled.connect(self._apply_table_filters)
|
||||
self._without_proposals.toggled.connect(self._apply_table_filters)
|
||||
filter_layout.addWidget(self._with_proposals)
|
||||
filter_layout.addWidget(self._without_proposals)
|
||||
filter_layout.addStretch(1)
|
||||
self.search_controls.addLayout(filter_layout)
|
||||
|
||||
# Insert the search controls layout at the top of the table
|
||||
layout.addLayout(self.search_controls)
|
||||
|
||||
def _build_table_tab(self):
|
||||
layout = QVBoxLayout(self._table_tab)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self._setup_search(layout)
|
||||
|
||||
# Add table
|
||||
hor_layout = QHBoxLayout()
|
||||
self._table = QTableWidget(self._table_tab)
|
||||
self._table.setColumnCount(5)
|
||||
self._table.setHorizontalHeaderLabels(list(self._headers.values()))
|
||||
vh = self._table.verticalHeader()
|
||||
vh.setVisible(False)
|
||||
vh.setDefaultSectionSize(vh.minimumSectionSize())
|
||||
self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
self._table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
||||
self._table.setWordWrap(True)
|
||||
self._table.setStyleSheet("QTableWidget::item { padding: 4px; }")
|
||||
|
||||
header = self._table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
|
||||
|
||||
self._table.itemSelectionChanged.connect(self._update_selection_state)
|
||||
hor_layout.addWidget(self._table, stretch=5)
|
||||
hor_layout.addSpacing(12) # Add space between table and side card
|
||||
|
||||
# Add side card for experiment details
|
||||
self._side_card = ExperimentMatCard(
|
||||
parent=self, show_activate_button=True, button_text="Activate"
|
||||
)
|
||||
self._side_card.experiment_selected.connect(self._emit_selected_experiment)
|
||||
hor_layout.addWidget(self._side_card, stretch=2) # Ratio 5:2 between table and card
|
||||
layout.addLayout(hor_layout)
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(int)
|
||||
@SafeSlot(bool) # Overload for buttons
|
||||
def _apply_table_filters(self, *args, **kwargs):
|
||||
if self._tabs.currentWidget() is not self._table_tab:
|
||||
return
|
||||
|
||||
show_with = self._with_proposals.isChecked()
|
||||
show_without = self._without_proposals.isChecked()
|
||||
|
||||
self._table_infos = []
|
||||
for info in self._experiment_infos:
|
||||
has_proposal = bool(info.get("proposal"))
|
||||
if has_proposal and not show_with:
|
||||
continue
|
||||
if not has_proposal and not show_without:
|
||||
continue
|
||||
self._table_infos.append(info)
|
||||
|
||||
self._populate_table()
|
||||
self._update_selection_state()
|
||||
|
||||
def _populate_table(self):
|
||||
# Clear table before populating, this keeps headers intact
|
||||
self._table.setRowCount(0)
|
||||
# Refill table
|
||||
self._table.setRowCount(len(self._table_infos))
|
||||
for row, info in enumerate(self._table_infos):
|
||||
pgroup = info.get("pgroup", "")
|
||||
title = info.get("title", "")
|
||||
name = format_name(info)
|
||||
start, end = format_schedule(info.get("schedule"))
|
||||
|
||||
self._table.setItem(row, 0, QTableWidgetItem(pgroup))
|
||||
self._table.setItem(row, 1, QTableWidgetItem(title))
|
||||
self._table.setItem(row, 2, QTableWidgetItem(name))
|
||||
self._table.setItem(row, 3, QTableWidgetItem(start))
|
||||
self._table.setItem(row, 4, QTableWidgetItem(end))
|
||||
|
||||
width = self._table.viewport().width()
|
||||
self._table.resizeRowsToContents()
|
||||
self._table.resize(width, self._table.height())
|
||||
# self._table.resizeRowsToContents()
|
||||
|
||||
@SafeSlot()
|
||||
def _update_selection_state(self):
|
||||
if self._tabs.currentWidget() is not self._table_tab:
|
||||
return
|
||||
index = self._table.selectionModel().selectedRows()
|
||||
if len(index) > 0:
|
||||
index = index[0]
|
||||
self._side_card.set_experiment_info(self._table_infos[index.row()])
|
||||
|
||||
def _emit_selected_experiment(self):
|
||||
if self._tabs.currentWidget() is self._card_tab and self._next_experiment:
|
||||
self.experiment_selected.emit(self._next_experiment)
|
||||
return
|
||||
selected = self._table.selectionModel().selectedRows()
|
||||
if not selected:
|
||||
return
|
||||
row = selected[0].row()
|
||||
if 0 <= row < len(self._table_infos):
|
||||
self.experiment_selected.emit(self._table_infos[row])
|
||||
logger.info(f"Emitting next experiment signal with info: {self._table_infos[row]}")
|
||||
|
||||
def _select_next_experiment(self, experiment_infos: list[dict]) -> dict | None:
|
||||
candidates = []
|
||||
for info in experiment_infos:
|
||||
start, _ = format_schedule(info.get("schedule"), as_datetime=True)
|
||||
if start is None:
|
||||
continue
|
||||
candidates.append((start, info))
|
||||
|
||||
if not candidates:
|
||||
return experiment_infos[0] if experiment_infos else None
|
||||
|
||||
now = datetime.now()
|
||||
future = [entry for entry in candidates if entry[0] >= now]
|
||||
pool = future or candidates
|
||||
return min(pool, key=lambda entry: abs(entry[0] - now))[1]
|
||||
|
||||
def _on_tab_changed(self, index):
|
||||
if self._tabs.widget(index) is self._table_tab:
|
||||
self._table.resizeRowsToContents()
|
||||
if self._next_experiment:
|
||||
self._side_card.set_experiment_info(self._next_experiment)
|
||||
self._apply_table_filters()
|
||||
|
||||
def _get_column_data(self, row) -> dict[str, str]:
|
||||
output = {}
|
||||
for ii, header in enumerate(self._headers.values()):
|
||||
item = self._table.item(row, ii)
|
||||
if item is None:
|
||||
output[header] = ""
|
||||
continue
|
||||
output[header] = item.text()
|
||||
return output
|
||||
|
||||
@SafeSlot(str)
|
||||
def _apply_row_filter(self, text_input: str):
|
||||
"""Apply a filter to the table rows based on the filter text."""
|
||||
if not text_input:
|
||||
for row in self._hidden_rows:
|
||||
self._table.setRowHidden(row, False)
|
||||
self._hidden_rows.clear()
|
||||
return
|
||||
for row in range(self._table.rowCount()):
|
||||
experiment_data = self._get_column_data(row)
|
||||
if is_match(
|
||||
text_input, experiment_data, list(self._headers.values()), self._enable_fuzzy_search
|
||||
):
|
||||
self._table.setRowHidden(row, False)
|
||||
self._hidden_rows.discard(row)
|
||||
else:
|
||||
self._table.setRowHidden(row, True)
|
||||
self._hidden_rows.add(row)
|
||||
|
||||
@SafeSlot(int)
|
||||
def _state_change_fuzzy_search(self, enabled: int):
|
||||
"""Handle state changes for the fuzzy search toggle."""
|
||||
self._enable_fuzzy_search = not bool(enabled)
|
||||
# Re-apply filter with updated fuzzy search setting
|
||||
current_text = self.search_input.text()
|
||||
self._apply_row_filter(current_text)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
experiment_infos = [
|
||||
{
|
||||
"_id": "p22622",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22622"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "12345967",
|
||||
"title": "Test Experiment for Mat Card Widget",
|
||||
"firstname": "John",
|
||||
"lastname": "Doe",
|
||||
"email": "john.doe@psi.ch",
|
||||
"account": "doe_j",
|
||||
"pi_firstname": "Jane",
|
||||
"pi_lastname": "Smith",
|
||||
"pi_email": "jane.smith@psi.ch",
|
||||
"pi_account": "smith_j",
|
||||
"eaccount": "e22622",
|
||||
"pgroup": "p22622",
|
||||
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
|
||||
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
|
||||
"proposal_submitted": "15/12/2024",
|
||||
"proposal_expire": "31/12/2025",
|
||||
"proposal_status": "Scheduled",
|
||||
"delta_last_schedule": 30,
|
||||
"mainproposal": "",
|
||||
},
|
||||
{
|
||||
"_id": "p22623",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22623"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "",
|
||||
"title": "Experiment without Proposal",
|
||||
"firstname": "Alice",
|
||||
"lastname": "Johnson",
|
||||
"email": "alice.johnson@psi.ch",
|
||||
"account": "johnson_a",
|
||||
"pi_firstname": "Bob",
|
||||
"pi_lastname": "Brown",
|
||||
"pi_email": "bob.brown@psi.ch",
|
||||
"pi_account": "brown_b",
|
||||
"eaccount": "e22623",
|
||||
"pgroup": "p22623",
|
||||
"abstract": "",
|
||||
"schedule": [],
|
||||
"proposal_submitted": "",
|
||||
"proposal_expire": "",
|
||||
"proposal_status": "",
|
||||
"delta_last_schedule": None,
|
||||
"mainproposal": "",
|
||||
},
|
||||
]
|
||||
|
||||
app = QApplication([])
|
||||
from bec_qthemes import apply_theme
|
||||
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
apply_theme("light")
|
||||
w = QWidget()
|
||||
l = QVBoxLayout(w)
|
||||
dark_button = DarkModeButton()
|
||||
l.addWidget(dark_button)
|
||||
widget = ExperimentSelection(experiment_infos)
|
||||
l.addWidget(widget)
|
||||
w.resize(1280, 920)
|
||||
w.show()
|
||||
app.exec()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Utility functions for experiment selection."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from bec_lib.messages import ExperimentInfoMessage
|
||||
|
||||
|
||||
def format_name(info: dict | ExperimentInfoMessage) -> str:
|
||||
"""Format the name from the experiment info."""
|
||||
info = ExperimentInfoMessage.model_validate(info) if isinstance(info, dict) else info
|
||||
firstname = info.firstname
|
||||
lastname = info.lastname
|
||||
return " ".join(part for part in [firstname, lastname] if part)
|
||||
|
||||
|
||||
def format_schedule(
|
||||
schedule: list[dict[Literal["start", "end"], str]] | None, as_datetime: bool = False
|
||||
) -> tuple[str, str] | tuple[datetime | None, datetime | None]:
|
||||
"""Format the schedule information to display start and end times."""
|
||||
if not schedule:
|
||||
return (None, None) if as_datetime else ("", "")
|
||||
start, end = _pick_schedule_entry(schedule)
|
||||
if as_datetime:
|
||||
return start, end
|
||||
return format_datetime(start), format_datetime(end)
|
||||
|
||||
|
||||
def _pick_schedule_entry(
|
||||
schedule: list[dict[Literal["start", "end"], str]],
|
||||
) -> tuple[datetime | None, datetime | None]:
|
||||
"""Pick the most relevant schedule entry based on the current time."""
|
||||
now = datetime.now()
|
||||
candidates = []
|
||||
for item in schedule:
|
||||
if not item:
|
||||
continue
|
||||
start_raw = item.get("start")
|
||||
parsed = _parse_schedule_start(start_raw)
|
||||
if parsed is None:
|
||||
continue
|
||||
candidates.append((parsed, item))
|
||||
|
||||
if not candidates:
|
||||
return None, None
|
||||
|
||||
future = [entry for entry in candidates if entry[0] >= now]
|
||||
pool = future or candidates
|
||||
chosen_start, chosen_item = min(pool, key=lambda entry: abs(entry[0] - now))
|
||||
end_raw = chosen_item.get("end")
|
||||
return chosen_start, _parse_schedule_start(end_raw)
|
||||
|
||||
|
||||
def _parse_schedule_start(value) -> datetime | None:
|
||||
"""Parse a schedule start string into a datetime object."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(value, "%d/%m/%Y %H:%M:%S")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def format_datetime(value) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return value.strftime("%Y-%m-%d %H:%M")
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "3.2.0"
|
||||
version = "3.3.1"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
@@ -32,6 +32,7 @@ dependencies = [
|
||||
"copier~=9.7",
|
||||
"typer~=0.15",
|
||||
"markdown~=3.9",
|
||||
"PyJWT~=2.9",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -75,6 +75,13 @@ def test_dock_manipulations_e2e(qtbot, connected_client_gui_obj):
|
||||
w1 = dock_area.new("Waveform")
|
||||
w2 = dock_area.new("Waveform")
|
||||
|
||||
qtbot.waitUntil(
|
||||
lambda: all(
|
||||
gui_id in gui._server_registry for gui_id in [w0._gui_id, w1._gui_id, w2._gui_id]
|
||||
),
|
||||
timeout=5000,
|
||||
)
|
||||
|
||||
assert hasattr(gui.bec, "Waveform")
|
||||
assert hasattr(gui.bec, "Waveform_0")
|
||||
assert hasattr(gui.bec, "Waveform_1")
|
||||
@@ -126,6 +133,7 @@ def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
|
||||
|
||||
xw = gui.new("X")
|
||||
xw.delete_all()
|
||||
qtbot.waitUntil(lambda: len(gui.windows) == 2, timeout=3000)
|
||||
assert xw.__class__.__name__ == "RPCReference"
|
||||
assert gui._ipython_registry[xw._gui_id].__class__.__name__ == "BECDockArea"
|
||||
assert len(gui.windows) == 2
|
||||
@@ -145,14 +153,15 @@ def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
|
||||
|
||||
qtbot.waitUntil(wait_for_gui_started, timeout=3000)
|
||||
# gui.windows should have bec with gui_id 'bec'
|
||||
qtbot.waitUntil(lambda: len(gui.windows) == 1, timeout=3000)
|
||||
assert len(gui.windows) == 1
|
||||
|
||||
# communication should work, main dock area should have same id and be visible
|
||||
|
||||
yw = gui.new("Y")
|
||||
qtbot.waitUntil(lambda: len(gui.windows) == 2, timeout=3000)
|
||||
yw.delete_all()
|
||||
qtbot.waitUntil(lambda: len(gui.windows) == 2, timeout=3000)
|
||||
assert len(gui.windows) == 2
|
||||
yw.remove()
|
||||
qtbot.waitUntil(lambda: len(gui.windows) == 1, timeout=3000)
|
||||
assert len(gui.windows) == 1
|
||||
assert len(gui.windows) == 1 # only bec is left
|
||||
|
||||
@@ -89,8 +89,8 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
|
||||
# Skip private attributes
|
||||
if object_name.startswith("_"):
|
||||
continue
|
||||
# Skip VSCode widget as Code server is not available in the Docker image
|
||||
if object_name == "VSCodeEditor":
|
||||
# Skip BECShell as ttyd is not installed
|
||||
if object_name == "BECShell":
|
||||
continue
|
||||
|
||||
# Skip WebConsole as ttyd is not installed
|
||||
|
||||
@@ -128,167 +128,147 @@ def maybe_remove_dock_area(qtbot, gui: BECGuiClient, random_int_gen: random.Rand
|
||||
random_int = random_int_gen.randint(0, 100)
|
||||
if random_int >= 50:
|
||||
# Needed, reference gets deleted in the gui
|
||||
name = gui.dock_area.object_name
|
||||
gui_id = gui.dock_area._gui_id
|
||||
gui.dock_area.delete_all() # start fresh
|
||||
gui.delete("dock_area")
|
||||
wait_for_namespace_change(
|
||||
qtbot, gui=gui, parent_widget=gui, object_name=name, widget_gui_id=gui_id, exists=False
|
||||
)
|
||||
qtbot.waitUntil(lambda: hasattr(gui, "dock_area") is False, timeout=5000)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_bec_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the BECProgressBar widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.BECProgressBar)
|
||||
widget: client.BECProgressBar
|
||||
|
||||
# Check rpc calls
|
||||
assert widget.label_template == "$value / $maximum - $percentage %"
|
||||
widget.set_maximum(100)
|
||||
widget.set_minimum(50)
|
||||
widget.set_value(75)
|
||||
|
||||
assert widget._get_label() == "75 / 100 - 50 %"
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_bec_queue(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the BECQueue widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.BECQueue)
|
||||
widget: client.BECQueue
|
||||
|
||||
# No rpc calls to test so far
|
||||
# maybe we can add an rpc call to check the queue length
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_bec_status_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the BECStatusBox widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.BECStatusBox)
|
||||
|
||||
# Check rpc calls
|
||||
assert widget.get_server_state() in ["RUNNING", "IDLE", "BUSY", "ERROR"]
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_dap_combo_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the DAPComboBox widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.DapComboBox)
|
||||
widget: client.DAPComboBox
|
||||
|
||||
# Check rpc calls
|
||||
widget.select_fit_model("PseudoVoigtModel")
|
||||
widget.select_x_axis("samx")
|
||||
widget.select_y_axis("bpm4i")
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_device_browser(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the DeviceBrowser widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.DeviceBrowser)
|
||||
widget: client.DeviceBrowser
|
||||
|
||||
# No rpc calls yet to check
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the Image widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.Image)
|
||||
widget: client.Image
|
||||
|
||||
scans = bec.scans
|
||||
dev = bec.device_manager.devices
|
||||
# Test rpc calls
|
||||
img = widget.image(device=dev.eiger.name, signal="preview")
|
||||
assert img.get_data() is None
|
||||
# Run a scan and plot the image
|
||||
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
|
||||
s.wait()
|
||||
|
||||
def _wait_for_scan_in_history():
|
||||
# Get scan item from history
|
||||
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
return scan_item is not None
|
||||
|
||||
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
|
||||
# Check that last image is equivalent to data in Redis
|
||||
last_img = bec.connector.get_last(MessageEndpoints.device_preview("eiger", "preview"))[
|
||||
"data"
|
||||
].data
|
||||
assert np.allclose(img.get_data(), last_img)
|
||||
|
||||
# Now add a device with a preview signal
|
||||
img = widget.image(device="eiger", signal="preview")
|
||||
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
|
||||
s.wait()
|
||||
|
||||
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# TODO re-enable when issue is resolved #560
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_bec_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the BECProgressBar widget."""
|
||||
# def test_widgets_e2e_log_panel(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the LogPanel widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.BECProgressBar)
|
||||
# widget: client.BECProgressBar
|
||||
|
||||
# # Check rpc calls
|
||||
# assert widget.label_template == "$value / $maximum - $percentage %"
|
||||
# widget.set_maximum(100)
|
||||
# widget.set_minimum(50)
|
||||
# widget.set_value(75)
|
||||
|
||||
# assert widget._get_label() == "75 / 100 - 50 %"
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_bec_queue(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the BECQueue widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.BECQueue)
|
||||
# widget: client.BECQueue
|
||||
|
||||
# # No rpc calls to test so far
|
||||
# # maybe we can add an rpc call to check the queue length
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_bec_status_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the BECStatusBox widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.BECStatusBox)
|
||||
|
||||
# # Check rpc calls
|
||||
# assert widget.get_server_state() in ["RUNNING", "IDLE", "BUSY", "ERROR"]
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_dap_combo_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the DAPComboBox widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.DapComboBox)
|
||||
# widget: client.DAPComboBox
|
||||
|
||||
# # Check rpc calls
|
||||
# widget.select_fit_model("PseudoVoigtModel")
|
||||
# widget.select_x_axis("samx")
|
||||
# widget.select_y_axis("bpm4i")
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_device_browser(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the DeviceBrowser widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.DeviceBrowser)
|
||||
# widget: client.DeviceBrowser
|
||||
|
||||
# # No rpc calls yet to check
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the Image widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.Image)
|
||||
# widget: client.Image
|
||||
|
||||
# scans = bec.scans
|
||||
# dev = bec.device_manager.devices
|
||||
# # Test rpc calls
|
||||
# img = widget.image(device=dev.eiger.name, signal="preview")
|
||||
# assert img.get_data() is None
|
||||
# # Run a scan and plot the image
|
||||
# s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
|
||||
# s.wait()
|
||||
|
||||
# def _wait_for_scan_in_history():
|
||||
# # Get scan item from history
|
||||
# scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
# return scan_item is not None
|
||||
|
||||
# qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
|
||||
# # Check that last image is equivalent to data in Redis
|
||||
# last_img = bec.connector.get_last(MessageEndpoints.device_preview("eiger", "preview"))[
|
||||
# "data"
|
||||
# ].data
|
||||
# assert np.allclose(img.get_data(), last_img)
|
||||
|
||||
# # Now add a device with a preview signal
|
||||
# img = widget.image(device="eiger", signal="preview")
|
||||
# s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
|
||||
# s.wait()
|
||||
|
||||
# qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# # TODO re-enable when issue is resolved #560
|
||||
# # @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# # def test_widgets_e2e_log_panel(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# # """Test the LogPanel widget."""
|
||||
# # gui = connected_client_gui_obj
|
||||
# # bec = gui._client
|
||||
# # # Create dock_area and widget
|
||||
# # widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel)
|
||||
# # widget: client.LogPanel
|
||||
|
||||
# # # No rpc calls to check so far
|
||||
|
||||
# # # Test removing the widget, or leaving it open for the next test
|
||||
# # maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the MineSweeper widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.Minesweeper)
|
||||
# widget: client.MineSweeper
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel)
|
||||
# widget: client.LogPanel
|
||||
|
||||
# # No rpc calls to check so far
|
||||
|
||||
@@ -296,160 +276,175 @@ def maybe_remove_dock_area(qtbot, gui: BECGuiClient, random_int_gen: random.Rand
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_motor_map(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the MotorMap widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.MotorMap)
|
||||
# widget: client.MotorMap
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the MineSweeper widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.Minesweeper)
|
||||
widget: client.MineSweeper
|
||||
|
||||
# # Test RPC calls
|
||||
# dev = bec.device_manager.devices
|
||||
# scans = bec.scans
|
||||
# # Set motor map to names
|
||||
# widget.map(dev.samx, dev.samy)
|
||||
# # Move motor samx to pos
|
||||
# pos = dev.samx.limits[1] - 1 # -1 from higher limit
|
||||
# scans.mv(dev.samx, pos, relative=False).wait()
|
||||
# # Check that data is up to date
|
||||
# assert np.isclose(widget.get_data()["x"][-1], pos, dev.samx.precision)
|
||||
# # Move motor samy to pos
|
||||
# pos = dev.samy.limits[0] + 1 # +1 from lower limit
|
||||
# scans.mv(dev.samy, pos, relative=False).wait()
|
||||
# # Check that data is up to date
|
||||
# assert np.isclose(widget.get_data()["y"][-1], pos, dev.samy.precision)
|
||||
# No rpc calls to check so far
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_multi_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test MultiWaveform widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.MultiWaveform)
|
||||
# widget: client.MultiWaveform
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_motor_map(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the MotorMap widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.MotorMap)
|
||||
widget: client.MotorMap
|
||||
|
||||
# # Test RPC calls
|
||||
# dev = bec.device_manager.devices
|
||||
# scans = bec.scans
|
||||
# # test plotting
|
||||
# cm = "cividis"
|
||||
# widget.plot(dev.waveform, color_palette=cm)
|
||||
# assert widget.monitor == dev.waveform.name
|
||||
# assert widget.color_palette == cm
|
||||
# Test RPC calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
# Set motor map to names
|
||||
widget.map(dev.samx, dev.samy)
|
||||
# Move motor samx to pos
|
||||
pos = dev.samx.limits[1] - 1 # -1 from higher limit
|
||||
scans.mv(dev.samx, pos, relative=False).wait()
|
||||
# Check that data is up to date
|
||||
assert np.isclose(widget.get_data()["x"][-1], pos, dev.samx.precision)
|
||||
# Move motor samy to pos
|
||||
pos = dev.samy.limits[0] + 1 # +1 from lower limit
|
||||
scans.mv(dev.samy, pos, relative=False).wait()
|
||||
# Check that data is up to date
|
||||
assert np.isclose(widget.get_data()["y"][-1], pos, dev.samy.precision)
|
||||
|
||||
# # Scan with BEC
|
||||
# s = scans.line_scan(dev.samx, -3, 3, steps=5, exp_time=0.01, relative=False)
|
||||
# s.wait()
|
||||
|
||||
# def _wait_for_scan_in_history():
|
||||
# # Get scan item from history
|
||||
# scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
# return scan_item is not None
|
||||
|
||||
# qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
# # Wait for data in history (should be plotted?)
|
||||
|
||||
# # TODO how can we check that the data was plotted, implement get_data()
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_positioner_indicator(
|
||||
# qtbot, connected_client_gui_obj, random_generator_from_seed
|
||||
# ):
|
||||
# """Test the PositionIndicator widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.PositionIndicator)
|
||||
# widget: client.PositionIndicator
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_multi_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test MultiWaveform widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.MultiWaveform)
|
||||
widget: client.MultiWaveform
|
||||
|
||||
# # TODO check what these rpc calls are supposed to do! Issue created #461
|
||||
# widget.set_value(5)
|
||||
# Test RPC calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
# test plotting
|
||||
cm = "cividis"
|
||||
widget.plot(dev.waveform, color_palette=cm)
|
||||
assert widget.monitor == dev.waveform.name
|
||||
assert widget.color_palette == cm
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# Scan with BEC
|
||||
s = scans.line_scan(dev.samx, -3, 3, steps=5, exp_time=0.01, relative=False)
|
||||
s.wait()
|
||||
|
||||
def _wait_for_scan_in_history():
|
||||
# Get scan item from history
|
||||
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
return scan_item is not None
|
||||
|
||||
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
# Wait for data in history (should be plotted?)
|
||||
|
||||
# TODO how can we check that the data was plotted, implement get_data()
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_positioner_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the PositionerBox widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox)
|
||||
# widget: client.PositionerBox
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_positioner_indicator(
|
||||
qtbot, connected_client_gui_obj, random_generator_from_seed
|
||||
):
|
||||
"""Test the PositionIndicator widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.PositionIndicator)
|
||||
widget: client.PositionIndicator
|
||||
|
||||
# # Test rpc calls
|
||||
# dev = bec.device_manager.devices
|
||||
# scans = bec.scans
|
||||
# # No rpc calls to check so far
|
||||
# widget.set_positioner(dev.samx)
|
||||
# widget.set_positioner(dev.samy.name)
|
||||
# TODO check what these rpc calls are supposed to do! Issue created #461
|
||||
widget.set_value(5)
|
||||
|
||||
# scans.mv(dev.samy, -3, relative=False).wait()
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_positioner_box_2d(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the PositionerBox2D widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox2D)
|
||||
# widget: client.PositionerBox2D
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_positioner_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the PositionerBox widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox)
|
||||
widget: client.PositionerBox
|
||||
|
||||
# # Test rpc calls
|
||||
# dev = bec.device_manager.devices
|
||||
# scans = bec.scans
|
||||
# # No rpc calls to check so far
|
||||
# widget.set_positioner_hor(dev.samx)
|
||||
# widget.set_positioner_ver(dev.samy)
|
||||
# Test rpc calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
# No rpc calls to check so far
|
||||
widget.set_positioner(dev.samx)
|
||||
widget.set_positioner(dev.samy.name)
|
||||
|
||||
# # Try moving the motors
|
||||
# scans.mv(dev.samx, 3, relative=False).wait()
|
||||
# scans.mv(dev.samy, -3, relative=False).wait()
|
||||
scans.mv(dev.samy, -3, relative=False).wait()
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_positioner_control_line(
|
||||
# qtbot, connected_client_gui_obj, random_generator_from_seed
|
||||
# ):
|
||||
# """Test the positioner control line widget"""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.PositionerControlLine)
|
||||
# widget: client.PositionerControlLine
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_positioner_box_2d(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the PositionerBox2D widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox2D)
|
||||
widget: client.PositionerBox2D
|
||||
|
||||
# # Test rpc calls
|
||||
# dev = bec.device_manager.devices
|
||||
# scans = bec.scans
|
||||
# # Set positioner
|
||||
# widget.set_positioner(dev.samx)
|
||||
# scans.mv(dev.samx, 3, relative=False).wait()
|
||||
# widget.set_positioner(dev.samy.name)
|
||||
# scans.mv(dev.samy, -3, relative=False).wait()
|
||||
# Test rpc calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
# No rpc calls to check so far
|
||||
widget.set_positioner_hor(dev.samx)
|
||||
widget.set_positioner_ver(dev.samy)
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# Try moving the motors
|
||||
scans.mv(dev.samx, 3, relative=False).wait()
|
||||
scans.mv(dev.samy, -3, relative=False).wait()
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.repeat(20)
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_positioner_control_line(
|
||||
qtbot, connected_client_gui_obj, random_generator_from_seed
|
||||
):
|
||||
"""Test the positioner control line widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.PositionerControlLine)
|
||||
widget: client.PositionerControlLine
|
||||
|
||||
# Test rpc calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
# Set positioner
|
||||
widget.set_positioner(dev.samx)
|
||||
scans.mv(dev.samx, 3, relative=False).wait()
|
||||
widget.set_positioner(dev.samy.name)
|
||||
scans.mv(dev.samy, -3, relative=False).wait()
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# TODO passes locally, fails on CI for some reason... -> issue #1003
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the RingProgressBar widget"""
|
||||
@@ -478,86 +473,86 @@ def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_g
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_scan_control(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the ScanControl widget"""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.ScanControl)
|
||||
# widget: client.ScanControl
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_scan_control(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the ScanControl widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.ScanControl)
|
||||
widget: client.ScanControl
|
||||
|
||||
# # No rpc calls to check so far
|
||||
# No rpc calls to check so far
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_scatter_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the ScatterWaveform widget"""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.ScatterWaveform)
|
||||
# widget: client.ScatterWaveform
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_scatter_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the ScatterWaveform widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.ScatterWaveform)
|
||||
widget: client.ScatterWaveform
|
||||
|
||||
# # Test rpc calls
|
||||
# dev = bec.device_manager.devices
|
||||
# scans = bec.scans
|
||||
# widget.plot(dev.samx, dev.samy, dev.bpm4i)
|
||||
# scans.grid_scan(dev.samx, -5, 5, 5, dev.samy, -5, 5, 5, exp_time=0.01, relative=False).wait()
|
||||
# Test rpc calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
widget.plot(dev.samx, dev.samy, dev.bpm4i)
|
||||
scans.grid_scan(dev.samx, -5, 5, 5, dev.samy, -5, 5, 5, exp_time=0.01, relative=False).wait()
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_text_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the TextBox widget"""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.TextBox)
|
||||
# widget: client.TextBox
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_text_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the TextBox widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.TextBox)
|
||||
widget: client.TextBox
|
||||
|
||||
# # RPC calls
|
||||
# widget.set_plain_text("Hello World")
|
||||
# widget.set_html_text("<b> Hello World HTML </b>")
|
||||
# RPC calls
|
||||
widget.set_plain_text("Hello World")
|
||||
widget.set_html_text("<b> Hello World HTML </b>")
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the Waveform widget"""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.Waveform)
|
||||
# widget: client.Waveform
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the Waveform widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.Waveform)
|
||||
widget: client.Waveform
|
||||
|
||||
# # Test rpc calls
|
||||
# dev = bec.device_manager.devices
|
||||
# scans = bec.scans
|
||||
# widget.plot(dev.bpm4i)
|
||||
# s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
|
||||
# s.wait()
|
||||
# Test rpc calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
widget.plot(dev.bpm4i)
|
||||
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
|
||||
s.wait()
|
||||
|
||||
# def _wait_for_scan_in_history():
|
||||
# # Get scan item from history
|
||||
# scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
# return scan_item is not None
|
||||
def _wait_for_scan_in_history():
|
||||
# Get scan item from history
|
||||
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
return scan_item is not None
|
||||
|
||||
# qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
|
||||
# scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
# samx_data = scan_item.devices.samx.samx.read()["value"]
|
||||
# bpm4i_data = scan_item.devices.bpm4i.bpm4i.read()["value"]
|
||||
# curve = widget.curves[0]
|
||||
# assert np.allclose(curve.get_data()[0], samx_data)
|
||||
# assert np.allclose(curve.get_data()[1], bpm4i_data)
|
||||
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
samx_data = scan_item.devices.samx.samx.read()["value"]
|
||||
bpm4i_data = scan_item.devices.bpm4i.bpm4i.read()["value"]
|
||||
curve = widget.curves[0]
|
||||
assert np.allclose(curve.get_data()[0], samx_data)
|
||||
assert np.allclose(curve.get_data()[1], bpm4i_data)
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
@@ -1,47 +1,18 @@
|
||||
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
|
||||
from math import inf
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
import fakeredis
|
||||
import pytest
|
||||
from bec_lib.bec_service import messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.redis_connector import RedisConnector
|
||||
from bec_lib.scan_history import ScanHistory
|
||||
|
||||
from bec_widgets.tests.utils import DEVICES, DMMock, FakePositioner, Positioner
|
||||
|
||||
|
||||
def fake_redis_server(host, port, **kwargs):
|
||||
redis = fakeredis.FakeRedis()
|
||||
return redis
|
||||
from bec_widgets.tests.utils import FakePositioner, Positioner
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mocked_client(bec_dispatcher):
|
||||
connector = RedisConnector("localhost:1", redis_cls=fake_redis_server)
|
||||
# Create a MagicMock object
|
||||
client = MagicMock() # TODO change to real BECClient
|
||||
|
||||
# Shutdown the original client
|
||||
bec_dispatcher.client.shutdown()
|
||||
# Mock the connector attribute
|
||||
bec_dispatcher.client = client
|
||||
|
||||
# Mock the device_manager.devices attribute
|
||||
client.connector = connector
|
||||
client.device_manager = DMMock()
|
||||
client.device_manager.add_devices(DEVICES)
|
||||
|
||||
def mock_mv(*args, relative=False):
|
||||
# Extracting motor and value pairs
|
||||
for i in range(0, len(args), 2):
|
||||
motor = args[i]
|
||||
value = args[i + 1]
|
||||
motor.move(value, relative=relative)
|
||||
return MagicMock(wait=MagicMock())
|
||||
|
||||
client.scans = MagicMock(mv=mock_mv)
|
||||
|
||||
# Ensure isinstance check for Positioner passes
|
||||
original_isinstance = isinstance
|
||||
@@ -52,8 +23,8 @@ def mocked_client(bec_dispatcher):
|
||||
return original_isinstance(obj, class_info)
|
||||
|
||||
with patch("builtins.isinstance", new=isinstance_mock):
|
||||
yield client
|
||||
connector.shutdown() # TODO change to real BECClient
|
||||
yield bec_dispatcher.client
|
||||
bec_dispatcher.client.connector.shutdown()
|
||||
|
||||
|
||||
##################################################
|
||||
@@ -190,17 +161,16 @@ def mocked_client_with_dap(mocked_client, dap_plugin_message):
|
||||
name="LmfitService1D", status=1, info={}
|
||||
),
|
||||
}
|
||||
client = mocked_client
|
||||
client.service_status = dap_services
|
||||
client.connector.set(
|
||||
type(mocked_client).service_status = PropertyMock(return_value=dap_services)
|
||||
mocked_client.connector.set(
|
||||
topic=MessageEndpoints.dap_available_plugins("dap"), msg=dap_plugin_message
|
||||
)
|
||||
|
||||
# Patch the client's DAP attribute so that the available models include "GaussianModel"
|
||||
patched_models = {"GaussianModel": {}, "LorentzModel": {}, "SineModel": {}}
|
||||
client.dap._available_dap_plugins = patched_models
|
||||
mocked_client.dap._available_dap_plugins = patched_models
|
||||
|
||||
yield client
|
||||
yield mocked_client
|
||||
|
||||
|
||||
class DummyData:
|
||||
@@ -233,7 +203,6 @@ def create_dummy_scan_item():
|
||||
"readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]},
|
||||
}
|
||||
}
|
||||
dummy_scan.status_message = MagicMock()
|
||||
dummy_scan.status_message.info = {
|
||||
"readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]},
|
||||
"scan_report_devices": ["samx"],
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
import json
|
||||
import time
|
||||
from unittest import mock
|
||||
from unittest.mock import patch
|
||||
|
||||
import fakeredis
|
||||
import h5py
|
||||
import numpy as np
|
||||
import pytest
|
||||
from bec_lib import messages
|
||||
from bec_lib import messages, service_config
|
||||
from bec_lib.bec_service import messages
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.messages import _StoredDataInfo
|
||||
from bec_qthemes import apply_theme
|
||||
from bec_qthemes._theme import Theme
|
||||
from ophyd._pyepics_shim import _dispatcher
|
||||
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
|
||||
from qtpy.QtCore import QEvent, QEventLoop
|
||||
from qtpy.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.tests.utils import DEVICES, DMMock
|
||||
from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module
|
||||
from bec_widgets.utils import error_popups
|
||||
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
|
||||
|
||||
# Patch to set default RAISE_ERROR_DEFAULT to True for tests
|
||||
# This means that by default, error popups will raise exceptions during tests
|
||||
@@ -38,15 +47,20 @@ def process_all_deferred_deletes(qapp):
|
||||
def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unused-argument
|
||||
qapp = QApplication.instance()
|
||||
process_all_deferred_deletes(qapp)
|
||||
apply_theme("light")
|
||||
qapp.processEvents()
|
||||
|
||||
if (
|
||||
not hasattr(qapp, "theme")
|
||||
or not isinstance(qapp.theme, Theme)
|
||||
or qapp.theme.theme != "light"
|
||||
):
|
||||
apply_theme("light")
|
||||
qapp.processEvents()
|
||||
|
||||
yield
|
||||
|
||||
# if the test failed, we don't want to check for open widgets as
|
||||
# it simply pollutes the output
|
||||
# stop pyepics dispatcher for leaking tests
|
||||
from ophyd._pyepics_shim import _dispatcher
|
||||
|
||||
_dispatcher.stop()
|
||||
if request.node.stash._storage.get("failed"):
|
||||
@@ -71,9 +85,37 @@ def rpc_register():
|
||||
RPCRegister.reset_singleton()
|
||||
|
||||
|
||||
_REDIS_CONN: QtRedisConnector | None = None
|
||||
|
||||
|
||||
def global_mock_qt_redis_connector(*_, **__):
|
||||
global _REDIS_CONN
|
||||
if _REDIS_CONN is None:
|
||||
_REDIS_CONN = QtRedisConnector(bootstrap="localhost:1", redis_cls=fakeredis.FakeRedis)
|
||||
return _REDIS_CONN
|
||||
|
||||
|
||||
def mock_client(*_, **__):
|
||||
with (
|
||||
patch("bec_lib.client.DeviceManagerBase", DMMock),
|
||||
patch("bec_lib.client.DAPPlugins"),
|
||||
patch("bec_lib.client.Scans"),
|
||||
patch("bec_lib.client.ScanManager"),
|
||||
patch("bec_lib.bec_service.BECAccess"),
|
||||
):
|
||||
client = BECClient(
|
||||
config=service_config.ServiceConfig(config={"redis": {"host": "localhost", "port": 1}}),
|
||||
connector_cls=global_mock_qt_redis_connector,
|
||||
)
|
||||
client.start()
|
||||
client.device_manager.add_devices(DEVICES)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def bec_dispatcher(threads_check): # pylint: disable=unused-argument
|
||||
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
|
||||
with mock.patch.object(bec_dispatcher_module, "BECClient", mock_client):
|
||||
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
|
||||
yield bec_dispatcher
|
||||
bec_dispatcher.disconnect_all()
|
||||
# clean BEC client
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
|
||||
@@ -10,6 +12,7 @@ from .client_mocks import mocked_client
|
||||
@pytest.fixture
|
||||
def abort_button(qtbot, mocked_client):
|
||||
widget = AbortButton(client=mocked_client)
|
||||
widget.queue = MagicMock()
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
720
tests/unit_tests/test_atlas_admin_view.py
Normal file
720
tests/unit_tests/test_atlas_admin_view.py
Normal file
@@ -0,0 +1,720 @@
|
||||
import datetime
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from bec_lib.messages import (
|
||||
DeploymentInfoMessage,
|
||||
ExperimentInfoMessage,
|
||||
MessagingConfig,
|
||||
MessagingServiceScopeConfig,
|
||||
SessionInfoMessage,
|
||||
)
|
||||
from qtpy.QtCore import QByteArray, QUrl
|
||||
from qtpy.QtNetwork import QNetworkRequest
|
||||
|
||||
from bec_widgets.utils.fuzzy_search import is_match
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view import BECAtlasAdminView
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_http_service import (
|
||||
AtlasEndpoints,
|
||||
AuthenticatedUserInfo,
|
||||
BECAtlasHTTPError,
|
||||
BECAtlasHTTPService,
|
||||
HTTPResponse,
|
||||
)
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_mat_card import (
|
||||
ExperimentMatCard,
|
||||
)
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_selection import (
|
||||
ExperimentSelection,
|
||||
)
|
||||
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.utils import (
|
||||
format_datetime,
|
||||
format_name,
|
||||
format_schedule,
|
||||
)
|
||||
|
||||
|
||||
class _FakeQByteArray:
|
||||
def __init__(self, payload: bytes):
|
||||
self._payload = payload
|
||||
|
||||
def data(self) -> bytes:
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeReply:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
request_url: str,
|
||||
status: int = 200,
|
||||
payload: bytes = b"{}",
|
||||
headers: list[tuple[bytes, bytes]] | None = None,
|
||||
):
|
||||
self._request_url = request_url
|
||||
self._status = status
|
||||
self._payload = payload
|
||||
self._headers = (
|
||||
headers
|
||||
if headers is not None
|
||||
else [(QByteArray(b"content-type"), QByteArray(b"application/json"))]
|
||||
)
|
||||
self.deleted = False
|
||||
|
||||
def attribute(self, attr):
|
||||
assert attr == QNetworkRequest.Attribute.HttpStatusCodeAttribute
|
||||
return self._status
|
||||
|
||||
def readAll(self):
|
||||
return _FakeQByteArray(self._payload)
|
||||
|
||||
def url(self):
|
||||
return QUrl(self._request_url)
|
||||
|
||||
def rawHeaderPairs(self):
|
||||
return self._headers
|
||||
|
||||
def deleteLater(self):
|
||||
self.deleted = True
|
||||
|
||||
|
||||
class TestBECAtlasHTTPService:
|
||||
|
||||
@pytest.fixture
|
||||
def http_service(self, qtbot):
|
||||
"""Fixture to create a BECAtlasHTTPService instance."""
|
||||
service = BECAtlasHTTPService(base_url="http://localhost:8000")
|
||||
qtbot.addWidget(service)
|
||||
qtbot.waitExposed(service)
|
||||
return service
|
||||
|
||||
def test_initialization(self, http_service):
|
||||
"""Test that the BECAtlasHTTPService initializes correctly."""
|
||||
assert http_service._base_url == "http://localhost:8000"
|
||||
assert http_service._auth_timer._timer.isActive() == False
|
||||
assert http_service._headers == {"accept": "application/json"}
|
||||
|
||||
def test_get_request_uses_network_manager_get(self, http_service):
|
||||
"""Test that _get_request uses the network manager's get method with correct parameters."""
|
||||
|
||||
with mock.patch.object(http_service.network_manager, "get") as mock_get:
|
||||
http_service._get_request(
|
||||
endpoint=AtlasEndpoints.REALMS_EXPERIMENTS.value,
|
||||
query_parameters={"realm_id": "realm-1"},
|
||||
)
|
||||
|
||||
mock_get.assert_called_once()
|
||||
request = mock_get.call_args.args[0]
|
||||
assert request.url().toString() == (
|
||||
"http://localhost:8000/realms/experiments?realm_id=realm-1"
|
||||
)
|
||||
assert request.rawHeader("accept") == QByteArray(b"application/json")
|
||||
|
||||
def test_post_request_uses_network_manager_post(self, http_service):
|
||||
"""Test that _post_request uses the network manager's post method with correct parameters."""
|
||||
|
||||
with mock.patch.object(http_service.network_manager, "post") as mock_post:
|
||||
http_service._post_request(
|
||||
endpoint=AtlasEndpoints.LOGIN.value, payload={"username": "alice", "password": "pw"}
|
||||
)
|
||||
|
||||
mock_post.assert_called_once()
|
||||
request, payload = mock_post.call_args.args
|
||||
assert request.url().toString() == "http://localhost:8000/user/login"
|
||||
assert request.rawHeader("accept") == QByteArray(b"application/json")
|
||||
assert payload == b'{"username": "alice", "password": "pw"}'
|
||||
|
||||
def test_public_api(self, http_service):
|
||||
"""Test BEC ATLAS public API methods from the http service."""
|
||||
with mock.patch.object(http_service, "_get_request") as mock_get:
|
||||
# User info
|
||||
http_service.get_user_info()
|
||||
mock_get.assert_called_once_with(endpoint=AtlasEndpoints.USER_INFO.value)
|
||||
|
||||
mock_get.reset_mock()
|
||||
# Deployment info
|
||||
http_service.get_deployment_info("dep-1")
|
||||
mock_get.assert_called_once_with(
|
||||
endpoint=AtlasEndpoints.DEPLOYMENT_INFO.value,
|
||||
query_parameters={"deployment_id": "dep-1"},
|
||||
)
|
||||
|
||||
mock_get.reset_mock()
|
||||
# Realms experiments
|
||||
http_service.get_experiments_for_realm("realm-1")
|
||||
mock_get.assert_called_once_with(
|
||||
endpoint=AtlasEndpoints.REALMS_EXPERIMENTS.value,
|
||||
query_parameters={"realm_id": "realm-1"},
|
||||
)
|
||||
|
||||
with mock.patch.object(http_service, "_post_request") as mock_post:
|
||||
# Logout
|
||||
http_service.logout()
|
||||
mock_post.assert_called_once_with(endpoint=AtlasEndpoints.LOGOUT.value)
|
||||
|
||||
mock_post.reset_mock()
|
||||
# Login
|
||||
http_service.login("alice", "pw")
|
||||
mock_post.assert_called_once_with(
|
||||
endpoint=AtlasEndpoints.LOGIN.value, payload={"username": "alice", "password": "pw"}
|
||||
)
|
||||
|
||||
mock_post.reset_mock()
|
||||
# Set experiment
|
||||
http_service.set_experiment("exp-1", "dep-1")
|
||||
mock_post.assert_called_once_with(
|
||||
endpoint=AtlasEndpoints.SET_EXPERIMENT.value,
|
||||
query_parameters={"experiment_id": "exp-1", "deployment_id": "dep-1"},
|
||||
)
|
||||
|
||||
def test_handle_response_login(self, http_service, qtbot):
|
||||
"""Test that handling a login response correctly decodes the token and starts the auth timer."""
|
||||
exp = time.time() + 300
|
||||
token = jwt.encode({"email": "alice@example.org", "exp": exp}, "secret", algorithm="HS256")
|
||||
payload = ("{" f'"access_token": "{token}"' "}").encode()
|
||||
reply = _FakeReply(
|
||||
request_url="http://localhost:8000/user/login", status=200, payload=payload
|
||||
)
|
||||
|
||||
with mock.patch.object(http_service, "get_user_info") as mock_get_user_info:
|
||||
|
||||
with qtbot.waitSignal(http_service.authentication_expires, timeout=1000) as blocker:
|
||||
http_service._handle_response(reply)
|
||||
|
||||
assert blocker.args[0] == pytest.approx(exp)
|
||||
assert http_service.auth_user_info is not None
|
||||
assert http_service.auth_user_info.email == "alice@example.org"
|
||||
assert http_service.auth_user_info.groups == set()
|
||||
http_service.get_user_info.assert_called_once()
|
||||
|
||||
def test_handle_response_logout(self, http_service, qtbot):
|
||||
"""Test handle response for logout."""
|
||||
http_service._auth_user_info = AuthenticatedUserInfo(
|
||||
email="alice@example.org", exp=time.time() + 60, groups={"staff"}, deployment_id="dep-1"
|
||||
)
|
||||
reply = _FakeReply(
|
||||
request_url="http://localhost:8000/user/logout", status=200, payload=b"{}"
|
||||
)
|
||||
|
||||
with qtbot.waitSignal(http_service.authenticated, timeout=1000) as blocker:
|
||||
http_service._handle_response(reply)
|
||||
|
||||
assert blocker.args[0] == {}
|
||||
assert http_service.auth_user_info is None
|
||||
|
||||
def test_handle_response_user_info(self, http_service):
|
||||
"""Test handle response for user info endpoint correctly updates auth user info."""
|
||||
http_service._auth_user_info = AuthenticatedUserInfo(
|
||||
email="alice@example.org", exp=time.time() + 60, groups=set(), deployment_id="dep-1"
|
||||
)
|
||||
http_service._current_deployment_info = SimpleNamespace(deployment_id="dep-1")
|
||||
reply = _FakeReply(
|
||||
request_url="http://localhost:8000/user/me",
|
||||
status=200,
|
||||
payload=b'{"email": "alice@example.org", "groups": ["operators", "staff"]}',
|
||||
)
|
||||
|
||||
with mock.patch.object(http_service, "get_deployment_info") as mock_get_deployment_info:
|
||||
http_service._handle_response(reply)
|
||||
|
||||
assert http_service.auth_user_info is not None
|
||||
assert http_service.auth_user_info.groups == {"operators", "staff"}
|
||||
mock_get_deployment_info.assert_called_once_with(deployment_id="dep-1")
|
||||
|
||||
def test_handle_response_deployment_info(self, http_service, qtbot):
|
||||
"""Test handling deployment info response"""
|
||||
|
||||
# Groups match: should emit authenticated signal with user info
|
||||
http_service._auth_user_info = AuthenticatedUserInfo(
|
||||
email="alice@example.org",
|
||||
exp=time.time() + 60,
|
||||
groups={"operators"},
|
||||
deployment_id="dep-1",
|
||||
)
|
||||
reply = _FakeReply(
|
||||
request_url="http://localhost:8000/deployments/id?deployment_id=dep-1",
|
||||
status=200,
|
||||
payload=b'{"owner_groups": ["operators"], "name": "Beamline Deployment"}',
|
||||
)
|
||||
|
||||
with qtbot.waitSignal(http_service.authenticated, timeout=1000) as blocker:
|
||||
http_service._handle_response(reply)
|
||||
|
||||
assert blocker.args[0]["email"] == "alice@example.org"
|
||||
assert set(blocker.args[0]["groups"]) == {"operators"}
|
||||
assert blocker.args[0]["deployment_id"] == "dep-1"
|
||||
|
||||
# Groups do not match: should show warning and logout
|
||||
http_service._auth_user_info = AuthenticatedUserInfo(
|
||||
email="alice@example.org",
|
||||
exp=time.time() + 60,
|
||||
groups={"operators"},
|
||||
deployment_id="dep-1",
|
||||
)
|
||||
reply = _FakeReply(
|
||||
request_url="http://localhost:8000/deployments/id?deployment_id=dep-1",
|
||||
status=200,
|
||||
payload=b'{"owner_groups": ["no-operators"], "name": "Beamline Deployment"}',
|
||||
)
|
||||
with (
|
||||
mock.patch.object(http_service, "_show_warning") as mock_show_warning,
|
||||
mock.patch.object(http_service, "logout") as mock_logout,
|
||||
):
|
||||
http_service._handle_response(reply)
|
||||
|
||||
mock_show_warning.assert_called_once()
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
def test_handle_response_emits_http_response(self, http_service, qtbot):
|
||||
"""Test that _handle_response emits the http_response signal with correct parameters for a generic response."""
|
||||
reply = _FakeReply(
|
||||
request_url="http://localhost:8000/realms/experiments?realm_id=realm-1",
|
||||
status=200,
|
||||
payload=b'{"items": []}',
|
||||
)
|
||||
|
||||
with qtbot.waitSignal(http_service.http_response, timeout=1000) as blocker:
|
||||
http_service._handle_response(reply)
|
||||
|
||||
assert blocker.args[0]["request_url"] == (
|
||||
"http://localhost:8000/realms/experiments?realm_id=realm-1"
|
||||
)
|
||||
assert blocker.args[0]["status"] == 200
|
||||
assert blocker.args[0]["headers"] == {"content-type": "application/json"}
|
||||
assert blocker.args[0]["data"] == {"items": []}
|
||||
|
||||
def test_handle_response_raises_for_invalid_status(self, http_service):
|
||||
reply = _FakeReply(
|
||||
request_url="http://localhost:8000/user/me",
|
||||
status=401,
|
||||
payload=b'{"detail": "Unauthorized"}',
|
||||
)
|
||||
|
||||
with pytest.raises(BECAtlasHTTPError):
|
||||
http_service._handle_response(reply, _override_slot_params={"raise_error": True})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def experiment_info_message() -> ExperimentInfoMessage:
|
||||
data = {
|
||||
"_id": "p22622",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22622"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "12345967",
|
||||
"title": "Test Experiment for Mat Card Widget",
|
||||
"firstname": "John",
|
||||
"lastname": "Doe",
|
||||
"email": "john.doe@psi.ch",
|
||||
"account": "doe_j",
|
||||
"pi_firstname": "Jane",
|
||||
"pi_lastname": "Smith",
|
||||
"pi_email": "jane.smith@psi.ch",
|
||||
"pi_account": "smith_j",
|
||||
"eaccount": "e22622",
|
||||
"pgroup": "p22622",
|
||||
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
|
||||
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
|
||||
"proposal_submitted": "15/12/2024",
|
||||
"proposal_expire": "31/12/2025",
|
||||
"proposal_status": "Scheduled",
|
||||
"delta_last_schedule": 30,
|
||||
"mainproposal": "",
|
||||
}
|
||||
return ExperimentInfoMessage.model_validate(data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def experiment_info_list(experiment_info_message: ExperimentInfoMessage) -> list[dict]:
|
||||
"""Fixture to provide a list of experiment info dictionaries."""
|
||||
another_experiment_info = {
|
||||
"_id": "p22623",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22623"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "",
|
||||
"title": "Experiment without Proposal",
|
||||
"firstname": "Alice",
|
||||
"lastname": "Johnson",
|
||||
"email": "alice.johnson@psi.ch",
|
||||
"account": "johnson_a",
|
||||
"pi_firstname": "Bob",
|
||||
"pi_lastname": "Brown",
|
||||
"pi_email": "bob.brown@psi.ch",
|
||||
"pi_account": "brown_b",
|
||||
"eaccount": "e22623",
|
||||
"pgroup": "p22623",
|
||||
"abstract": "",
|
||||
"schedule": [],
|
||||
"proposal_submitted": "",
|
||||
"proposal_expire": "",
|
||||
"proposal_status": "",
|
||||
"delta_last_schedule": None,
|
||||
"mainproposal": "",
|
||||
}
|
||||
return [
|
||||
experiment_info_message.model_dump(),
|
||||
ExperimentInfoMessage.model_validate(another_experiment_info).model_dump(),
|
||||
]
|
||||
|
||||
|
||||
class TestBECAtlasExperimentSelection:
|
||||
|
||||
def test_format_name(self, experiment_info_message: ExperimentInfoMessage):
|
||||
"""Test utils format name"""
|
||||
assert format_name(experiment_info_message) == "John Doe"
|
||||
|
||||
def test_format_schedule(self, experiment_info_message: ExperimentInfoMessage):
|
||||
"""Test utils format schedule"""
|
||||
assert format_schedule(experiment_info_message.schedule) == (
|
||||
"2025-01-01 08:00",
|
||||
"2025-01-03 18:00",
|
||||
)
|
||||
assert format_schedule(experiment_info_message.schedule, as_datetime=True) == (
|
||||
datetime.datetime.strptime(
|
||||
experiment_info_message.schedule[0]["start"], "%d/%m/%Y %H:%M:%S"
|
||||
),
|
||||
datetime.datetime.strptime(
|
||||
experiment_info_message.schedule[0]["end"], "%d/%m/%Y %H:%M:%S"
|
||||
),
|
||||
)
|
||||
assert format_schedule([]) == ("", "")
|
||||
|
||||
def test_format_datetime(self):
|
||||
"""Test utils format datetime"""
|
||||
dt = datetime.datetime(2025, 1, 1, 8, 0)
|
||||
assert format_datetime(dt) == "2025-01-01 08:00"
|
||||
assert format_datetime(None) == ""
|
||||
|
||||
@pytest.fixture
|
||||
def mat_card(self, qtbot):
|
||||
"""Fixture to create an ExperimentMatCard instance."""
|
||||
card = ExperimentMatCard()
|
||||
qtbot.addWidget(card)
|
||||
qtbot.waitExposed(card)
|
||||
return card
|
||||
|
||||
def test_set_experiment_info(
|
||||
self, mat_card: ExperimentMatCard, experiment_info_message: ExperimentInfoMessage, qtbot
|
||||
):
|
||||
"""Test that set_experiment_info correctly updates the card's display based on the provided experiment info, whether it's a dictionary or an ExperimentInfoMessage instance."""
|
||||
# Test with ExperimentInfoMessage instance
|
||||
mat_card.set_experiment_info(experiment_info_message)
|
||||
assert mat_card._card_pgroup.text() == "p22622"
|
||||
assert mat_card._card_title.text() == "Next Experiment"
|
||||
assert mat_card._abstract_label.text() == experiment_info_message.abstract.strip()
|
||||
assert mat_card.experiment_info == experiment_info_message.model_dump()
|
||||
assert mat_card._activate_button.isEnabled()
|
||||
assert mat_card._activate_button.text() == "Activate"
|
||||
|
||||
# Test with dictionary input
|
||||
mat_card.set_experiment_info(experiment_info_message.model_dump())
|
||||
mat_card.set_title("Experiment Details")
|
||||
assert mat_card._card_pgroup.text() == "p22622"
|
||||
assert mat_card._card_title.text() == "Experiment Details"
|
||||
assert mat_card._abstract_label.text() == experiment_info_message.abstract.strip()
|
||||
assert mat_card.experiment_info == experiment_info_message.model_dump()
|
||||
assert mat_card._activate_button.isEnabled()
|
||||
assert mat_card._activate_button.text() == "Activate"
|
||||
|
||||
with qtbot.waitSignal(mat_card.experiment_selected, timeout=1000) as blocker:
|
||||
mat_card._activate_button.click()
|
||||
assert blocker.args[0] == experiment_info_message.model_dump()
|
||||
|
||||
def test_is_match(self):
|
||||
"""Test is_match utility function for search functionality."""
|
||||
data = {"name": "Test Experiment", "description": "This is a test."}
|
||||
relevant_keys = ["name", "description"]
|
||||
|
||||
# Test exact match
|
||||
assert is_match("Test Experiment", data, relevant_keys, enable_fuzzy=False)
|
||||
assert not is_match("Nonexistent", data, relevant_keys, enable_fuzzy=False)
|
||||
|
||||
# Test fuzzy match
|
||||
assert not is_match("Nonexistent", data, relevant_keys, enable_fuzzy=True)
|
||||
assert is_match("Test Experimnt", data, relevant_keys, enable_fuzzy=True)
|
||||
# Typo should still match with fuzzy enabled
|
||||
assert is_match("Test Experiement", data, relevant_keys, enable_fuzzy=True)
|
||||
|
||||
@pytest.fixture
|
||||
def experiment_selection(self, qtbot):
|
||||
"""Fixture to create an ExperimentSelection instance with sample experiment info."""
|
||||
selection = ExperimentSelection()
|
||||
qtbot.addWidget(selection)
|
||||
qtbot.waitExposed(selection)
|
||||
return selection
|
||||
|
||||
def test_set_experiments(
|
||||
self, experiment_selection: ExperimentSelection, experiment_info_list: list[dict]
|
||||
):
|
||||
"""Test that set_experiment_infos correctly populates the experiment selection with provided experiment info."""
|
||||
with mock.patch.object(
|
||||
experiment_selection._card_tab, "set_experiment_info"
|
||||
) as mock_set_experiment_info:
|
||||
experiment_selection.set_experiment_infos(experiment_info_list)
|
||||
assert len(experiment_selection._experiment_infos) == 2
|
||||
|
||||
# Next experiment should be the first one as the second one has no schedule
|
||||
mock_set_experiment_info.assert_called_once_with(experiment_info_list[0])
|
||||
|
||||
# Should be on card tab
|
||||
assert experiment_selection._tabs.currentWidget() == experiment_selection._card_tab
|
||||
|
||||
def test_filter_functionality(
|
||||
self, experiment_selection: ExperimentSelection, experiment_info_list: list[dict], qtbot
|
||||
):
|
||||
"""Test that the search functionality correctly filters experiments based on the search query."""
|
||||
wid = experiment_selection
|
||||
wid.set_experiment_infos(experiment_info_list)
|
||||
|
||||
# First move to the table tab
|
||||
wid._tabs.setCurrentWidget(wid._table_tab)
|
||||
assert wid._side_card.experiment_info == wid._next_experiment
|
||||
|
||||
# Initially, both experiments should be in the table
|
||||
assert wid._table.rowCount() == 2
|
||||
with qtbot.waitSignal(wid._with_proposals.toggled, timeout=1000):
|
||||
wid._with_proposals.setChecked(False) # Should hide one experiment
|
||||
assert wid._table.rowCount() == 1
|
||||
with qtbot.waitSignal(wid._without_proposals.toggled, timeout=1000):
|
||||
wid._without_proposals.setChecked(False) # Should hide the other experiment
|
||||
assert wid._table.rowCount() == 0
|
||||
with qtbot.waitSignals(
|
||||
[wid._without_proposals.toggled, wid._with_proposals.toggled], timeout=1000
|
||||
):
|
||||
wid._without_proposals.setChecked(True)
|
||||
wid._with_proposals.setChecked(True) # Should show both experiments again
|
||||
assert wid._table.rowCount() == 2
|
||||
|
||||
# Click on first experiment and check if side card updates
|
||||
with qtbot.waitSignal(wid._table.itemSelectionChanged, timeout=1000):
|
||||
wid._table.selectRow(0) # Select the first experiment
|
||||
pgroup = wid._table.item(0, 0).text() # pgroup
|
||||
exp = [exp for exp in experiment_info_list if exp["pgroup"] == pgroup][0]
|
||||
assert wid._side_card.experiment_info == exp
|
||||
|
||||
# Click on second experiment and check if side card updates
|
||||
with qtbot.waitSignal(wid._table.itemSelectionChanged, timeout=1000):
|
||||
wid._table.selectRow(1) # Select the second experiment
|
||||
|
||||
pgroup = wid._table.item(1, 0).text() # pgroup
|
||||
exp = [exp for exp in experiment_info_list if exp["pgroup"] == pgroup][0]
|
||||
assert wid._side_card.experiment_info == exp
|
||||
|
||||
wid.search_input.setText("Experiment without Proposal")
|
||||
with mock.patch.object(wid, "_apply_row_filter") as mock_apply_row_filter:
|
||||
with qtbot.waitSignal(wid.fuzzy_is_disabled.stateChanged, timeout=1000):
|
||||
wid.fuzzy_is_disabled.setChecked(True) # Disable fuzzy search
|
||||
mock_apply_row_filter.assert_called_once_with("Experiment without Proposal")
|
||||
|
||||
assert wid._enable_fuzzy_search is False
|
||||
|
||||
def test_emit_selected_experiment(
|
||||
self, experiment_selection: ExperimentSelection, experiment_info_list: list[dict], qtbot
|
||||
):
|
||||
"""Test that clicking the activate button on the side card emits the experiment_selected signal with the correct experiment info."""
|
||||
wid = experiment_selection
|
||||
wid.set_experiment_infos(experiment_info_list)
|
||||
|
||||
wid._tabs.setCurrentWidget(wid._table_tab)
|
||||
with qtbot.waitSignal(wid._table.itemSelectionChanged, timeout=1000):
|
||||
wid._table.selectRow(1) # Select the second experiment
|
||||
pgroup = wid._table.item(1, 0).text() # pgroup
|
||||
exp = [exp for exp in experiment_info_list if exp["pgroup"] == pgroup][0]
|
||||
|
||||
with qtbot.waitSignal(wid.experiment_selected, timeout=1000) as blocker:
|
||||
wid._side_card._activate_button.click()
|
||||
assert blocker.args == [exp]
|
||||
|
||||
|
||||
class TestBECAtlasAdminView:
|
||||
|
||||
@pytest.fixture
|
||||
def admin_view(self, qtbot):
|
||||
"""Fixture to create a BECAtlasAdminView instance."""
|
||||
with mock.patch(
|
||||
"bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view.BECAtlasAdminView._connect_dispatcher"
|
||||
):
|
||||
view = BECAtlasAdminView()
|
||||
qtbot.addWidget(view)
|
||||
qtbot.waitExposed(view)
|
||||
return view
|
||||
|
||||
def test_init_and_login(self, admin_view: BECAtlasAdminView, qtbot):
|
||||
"""Test that the BECAtlasAdminView initializes correctly."""
|
||||
# Check that the atlas URL is set correctly
|
||||
assert admin_view._atlas_url == "https://bec-atlas-dev.psi.ch/api/v1"
|
||||
|
||||
# Test that clicking the login button emits the credentials_entered signal with the correct username and password
|
||||
with mock.patch.object(admin_view.atlas_http_service, "login") as mock_login:
|
||||
with qtbot.waitSignal(admin_view.overview_widget.login_requested, timeout=1000):
|
||||
admin_view.overview_widget._login.username.setText("alice")
|
||||
admin_view.overview_widget._login.password.setText("password")
|
||||
admin_view.overview_widget._login._emit_credentials()
|
||||
|
||||
mock_login.assert_called_once_with(username="alice", password="password")
|
||||
mock_login.reset_mock()
|
||||
admin_view._authenticated = True
|
||||
with mock.patch.object(admin_view, "logout") as mock_logout:
|
||||
with qtbot.waitSignal(admin_view.overview_widget.login_requested, timeout=1000):
|
||||
admin_view.overview_widget._login.password.setText("password")
|
||||
admin_view.overview_widget._login._emit_credentials()
|
||||
mock_logout.assert_called_once()
|
||||
mock_login.assert_called_once_with(username="alice", password="password")
|
||||
|
||||
def test_on_experiment_selected(
|
||||
self, admin_view: BECAtlasAdminView, deployment_info: DeploymentInfoMessage, qtbot
|
||||
):
|
||||
"""Test that selecting an experiment in the overview widget correctly calls the HTTP service to set the experiment and updates the current experiment view."""
|
||||
# First we need to simulate that we are authenticated and have deployment info
|
||||
admin_view._update_deployment_info(deployment_info.model_dump(), {})
|
||||
with mock.patch.object(
|
||||
admin_view.atlas_http_service, "set_experiment"
|
||||
) as mock_set_experiment:
|
||||
with qtbot.waitSignal(
|
||||
admin_view.experiment_selection.experiment_selected, timeout=1000
|
||||
):
|
||||
admin_view.experiment_selection.experiment_selected.emit(
|
||||
deployment_info.active_session.experiment.model_dump()
|
||||
)
|
||||
mock_set_experiment.assert_called_once_with(
|
||||
deployment_info.active_session.experiment.pgroup, deployment_info.deployment_id
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def deployment_info(
|
||||
self, experiment_info_message: ExperimentInfoMessage
|
||||
) -> DeploymentInfoMessage:
|
||||
"""Fixture to provide a DeploymentInfoMessage instance."""
|
||||
return DeploymentInfoMessage(
|
||||
deployment_id="dep-1",
|
||||
name="Test Deployment",
|
||||
messaging_config=MessagingConfig(
|
||||
signal=MessagingServiceScopeConfig(enabled=False),
|
||||
teams=MessagingServiceScopeConfig(enabled=False),
|
||||
scilog=MessagingServiceScopeConfig(enabled=False),
|
||||
),
|
||||
active_session=SessionInfoMessage(
|
||||
experiment=experiment_info_message, name="Test Session"
|
||||
),
|
||||
)
|
||||
|
||||
def test_on_authenticated(
|
||||
self, admin_view: BECAtlasAdminView, deployment_info: DeploymentInfoMessage, qtbot
|
||||
):
|
||||
"""Test that the on_authenticated method correctly updates the UI based on authentication state."""
|
||||
# Simulate successful authentication
|
||||
auth_info = AuthenticatedUserInfo(
|
||||
email="alice@example.com",
|
||||
exp=time.time() + 300,
|
||||
groups={"operators"},
|
||||
deployment_id="dep-1",
|
||||
)
|
||||
|
||||
# First check that deployment info updates all fields correctly
|
||||
admin_view._update_deployment_info(deployment_info.model_dump(), {})
|
||||
|
||||
assert admin_view.atlas_http_service._current_deployment_info == deployment_info
|
||||
assert (
|
||||
admin_view._atlas_info_widget._bl_info_label.text()
|
||||
== f"{deployment_info.active_session.experiment.realm_id} @ {deployment_info.name}"
|
||||
)
|
||||
assert admin_view._atlas_info_widget._atlas_url_label.text() == admin_view._atlas_url
|
||||
|
||||
# Now run on_authenticated, this enables all toolbar buttons
|
||||
# and calls fetch experiments. It also switches the overview widget
|
||||
# to the current experiment view.
|
||||
# Default should be on the overview widget
|
||||
assert admin_view.stacked_layout.currentWidget() == admin_view.overview_widget
|
||||
with mock.patch.object(
|
||||
admin_view, "_fetch_available_experiments"
|
||||
) as mock_fetch_experiments:
|
||||
with qtbot.waitSignal(admin_view.authenticated, timeout=1000) as blocker:
|
||||
admin_view._on_authenticated(auth_info.model_dump())
|
||||
# Fetch experiments should be called
|
||||
mock_fetch_experiments.assert_called_once()
|
||||
assert blocker.args[0] is True
|
||||
assert (
|
||||
admin_view._atlas_info_widget._atlas_url_label.text()
|
||||
== f"{admin_view._atlas_info_widget._atlas_url_text} | {auth_info.email}"
|
||||
)
|
||||
assert (
|
||||
admin_view.toolbar.components.get_action("messaging_services").action.isEnabled()
|
||||
is False
|
||||
)
|
||||
|
||||
# Logout timer is running
|
||||
logout_action = admin_view.toolbar.components.get_action("logout")
|
||||
assert logout_action.action.isEnabled() is True
|
||||
assert logout_action._tick_timer.isActive() is True
|
||||
|
||||
# Current Experiment widget should be visible in the overview
|
||||
assert (
|
||||
admin_view.overview_widget.stacked_layout.currentWidget()
|
||||
== admin_view.overview_widget._experiment_overview_widget
|
||||
)
|
||||
|
||||
# Click toolbar to switch to experiment selection
|
||||
exp_select = admin_view.toolbar.components.get_action("experiment_selection")
|
||||
assert exp_select.action.isEnabled() is True
|
||||
with qtbot.waitSignal(exp_select.action.triggered, timeout=1000):
|
||||
exp_select.action.trigger()
|
||||
|
||||
assert admin_view.stacked_layout.currentWidget() == admin_view.experiment_selection
|
||||
|
||||
# Now we simulate that the authentication expires
|
||||
# This deactivates buttons, resets the overview widget
|
||||
# and emits authenticated signal with False
|
||||
with qtbot.waitSignal(admin_view.authenticated, timeout=1000) as blocker:
|
||||
admin_view._on_authenticated({}) # Simulate not authenticated anymore
|
||||
assert blocker.args[0] is False
|
||||
assert logout_action._tick_timer.isActive() is False
|
||||
assert admin_view._atlas_info_widget._atlas_url_label.text() == admin_view._atlas_url
|
||||
assert (
|
||||
admin_view.overview_widget.stacked_layout.currentWidget()
|
||||
== admin_view.overview_widget._login_widget
|
||||
)
|
||||
# View should switch back to overview
|
||||
assert admin_view.stacked_layout.currentWidget() == admin_view.overview_widget
|
||||
|
||||
def test_fetch_experiments(
|
||||
self, admin_view: BECAtlasAdminView, deployment_info: DeploymentInfoMessage, qtbot
|
||||
):
|
||||
"""Test that _fetch_available_experiments correctly calls the HTTP service and updates the experiment selection widget."""
|
||||
admin_view._update_deployment_info(deployment_info.model_dump(), {})
|
||||
with mock.patch.object(
|
||||
admin_view.atlas_http_service, "get_experiments_for_realm"
|
||||
) as mock_get_experiments:
|
||||
admin_view._fetch_available_experiments()
|
||||
mock_get_experiments.assert_called_once_with(
|
||||
deployment_info.active_session.experiment.realm_id
|
||||
)
|
||||
|
||||
def test_on_http_response_received(
|
||||
self, experiment_info_list: list[dict], admin_view: BECAtlasAdminView, qtbot
|
||||
):
|
||||
"""Test that _on_http_response_received correctly handles HTTP responses and updates the UI accordingly."""
|
||||
realms = HTTPResponse(
|
||||
request_url=f"{admin_view._atlas_url}/{AtlasEndpoints.REALMS_EXPERIMENTS}/experiments?realm_id=TestBeamline",
|
||||
status=200,
|
||||
headers={"content-type": "application/json"},
|
||||
data=experiment_info_list,
|
||||
)
|
||||
with mock.patch.object(
|
||||
admin_view.experiment_selection, "set_experiment_infos"
|
||||
) as mock_set_experiment_infos:
|
||||
admin_view._on_http_response_received(realms.model_dump())
|
||||
mock_set_experiment_infos.assert_called_once_with(experiment_info_list)
|
||||
|
||||
set_experiment = HTTPResponse(
|
||||
request_url=f"{admin_view._atlas_url}/{AtlasEndpoints.SET_EXPERIMENT}",
|
||||
status=200,
|
||||
headers={"content-type": "application/json"},
|
||||
data={},
|
||||
)
|
||||
with mock.patch.object(admin_view, "_on_overview_selected") as mock_on_overview_selected:
|
||||
admin_view._on_http_response_received(set_experiment.model_dump())
|
||||
mock_on_overview_selected.assert_called_once()
|
||||
@@ -4,10 +4,45 @@ import time
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from bec_lib import service_config
|
||||
from bec_lib.messages import ScanMessage
|
||||
from bec_lib.serialization import MsgpackSerialization
|
||||
|
||||
from bec_widgets.utils.bec_dispatcher import QtRedisConnector, QtThreadSafeCallback
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher, QtRedisConnector, QtThreadSafeCallback
|
||||
|
||||
|
||||
def test_init_handles_client_and_config_arg():
|
||||
# Client passed
|
||||
self_mock = mock.MagicMock(_initialized=False)
|
||||
with mock.patch.object(BECDispatcher, "start_cli_server"):
|
||||
BECDispatcher.__init__(self_mock, client=mock.MagicMock(name="test_client"))
|
||||
assert "test_client" in repr(self_mock.client)
|
||||
|
||||
# No client, service config object
|
||||
self_mock.reset_mock()
|
||||
self_mock._initialized = False
|
||||
with (
|
||||
mock.patch.object(BECDispatcher, "start_cli_server"),
|
||||
mock.patch("bec_widgets.utils.bec_dispatcher.BECClient") as client_cls,
|
||||
):
|
||||
config = service_config.ServiceConfig()
|
||||
BECDispatcher.__init__(self_mock, client=None, config=config)
|
||||
client_cls.assert_called_with(
|
||||
config=config, connector_cls=QtRedisConnector, name="BECWidgets"
|
||||
)
|
||||
|
||||
# No client, service config string
|
||||
self_mock.reset_mock()
|
||||
self_mock._initialized = False
|
||||
with (
|
||||
mock.patch.object(BECDispatcher, "start_cli_server"),
|
||||
mock.patch("bec_widgets.utils.bec_dispatcher.BECClient"),
|
||||
mock.patch("bec_widgets.utils.bec_dispatcher.ServiceConfig") as svc_cfg,
|
||||
mock.patch("bec_widgets.utils.bec_dispatcher.isinstance", return_value=False),
|
||||
):
|
||||
config = service_config.ServiceConfig()
|
||||
BECDispatcher.__init__(self_mock, client=None, config="test_str")
|
||||
svc_cfg.assert_called_with("test_str")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -17,6 +17,8 @@ def dap_combobox(qtbot, mocked_client):
|
||||
|
||||
def test_dap_combobox_init(dap_combobox):
|
||||
"""Test DapComboBox init."""
|
||||
assert dap_combobox.fit_model_combobox is dap_combobox
|
||||
assert dap_combobox.isEditable() is True
|
||||
assert dap_combobox.fit_model_combobox.currentText() == "GaussianModel"
|
||||
assert dap_combobox.available_models == ["GaussianModel", "LorentzModel", "SineModel"]
|
||||
assert dap_combobox._validate_dap_model("GaussianModel") is True
|
||||
@@ -71,3 +73,25 @@ def test_dap_combobox_currentTextchanged(dap_combobox):
|
||||
dap_combobox.fit_model_updated.connect(my_callback)
|
||||
dap_combobox.fit_model_combobox.setCurrentText("SineModel")
|
||||
assert container[0] == "SineModel"
|
||||
|
||||
|
||||
def test_dap_combobox_init_without_available_models(qtbot, mocked_client):
|
||||
mocked_client.dap._available_dap_plugins = {}
|
||||
|
||||
widget = create_widget(qtbot, DapComboBox, client=mocked_client)
|
||||
|
||||
assert widget.available_models == []
|
||||
assert widget.fit_model_combobox.count() == 0
|
||||
assert widget.fit_model_combobox.currentText() == ""
|
||||
|
||||
|
||||
def test_dap_combobox_invalid_manual_entry_highlighted(dap_combobox):
|
||||
dap_combobox.setCurrentText("not-a-model")
|
||||
|
||||
assert dap_combobox.is_valid_input is False
|
||||
assert "red" in dap_combobox.styleSheet()
|
||||
|
||||
dap_combobox.setCurrentText("GaussianModel")
|
||||
|
||||
assert dap_combobox.is_valid_input is True
|
||||
assert "transparent" in dap_combobox.styleSheet()
|
||||
|
||||
@@ -160,7 +160,7 @@ def test_signal_display(mocked_client, qtbot):
|
||||
|
||||
def test_signal_display_no_device(mocked_client, qtbot):
|
||||
device_mock = mock.MagicMock()
|
||||
mocked_client.client.device_manager.devices = {"test_device_1": device_mock}
|
||||
mocked_client.device_manager.devices = {"test_device_1": device_mock}
|
||||
signal_display = SignalDisplay(client=mocked_client, device="test_device_2")
|
||||
qtbot.addWidget(signal_display)
|
||||
assert (
|
||||
|
||||
@@ -146,12 +146,12 @@ def test_signal_lineedit(device_signal_line_edit):
|
||||
|
||||
|
||||
def test_device_signal_input_base_cleanup(qtbot, mocked_client):
|
||||
with mock.patch.object(mocked_client.callbacks, "remove"):
|
||||
widget = DeviceInputWidget(client=mocked_client)
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
|
||||
widget = DeviceInputWidget(client=mocked_client)
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
|
||||
mocked_client.callbacks.remove.assert_called_once_with(widget._device_update_register)
|
||||
mocked_client.callbacks.remove.assert_called_once_with(widget._device_update_register)
|
||||
|
||||
|
||||
def test_signal_combobox_get_signal_name_with_item_data(qtbot, device_signal_combobox):
|
||||
|
||||
@@ -197,6 +197,163 @@ def test_image_setup_preview_signal_2d(qtbot, mocked_client):
|
||||
np.testing.assert_array_equal(view.main_image.image, test_data)
|
||||
|
||||
|
||||
def test_switching_device_disconnects_previous_preview_endpoint(qtbot, mocked_client, monkeypatch):
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2)
|
||||
_set_signal_config(mocked_client, "waveform1d", "img", signal_class="PreviewSignal", ndim=2)
|
||||
|
||||
connected = []
|
||||
disconnected = []
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"connect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"disconnect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
|
||||
)
|
||||
|
||||
view.image(device="eiger", signal="img")
|
||||
connected.clear()
|
||||
disconnected.clear()
|
||||
|
||||
view.device = "waveform1d"
|
||||
|
||||
assert MessageEndpoints.device_preview("eiger", "img") in disconnected
|
||||
assert MessageEndpoints.device_preview("waveform1d", "img") in connected
|
||||
|
||||
|
||||
def test_switching_device_disconnects_previous_async_endpoint(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Verify that switching device while async_update=True disconnects device_async_signal
|
||||
endpoints for both scan_id and old_scan_id on the old device before reconnecting to
|
||||
the new device.
|
||||
"""
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj"
|
||||
)
|
||||
_set_signal_config(
|
||||
mocked_client, "waveform1d", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj"
|
||||
)
|
||||
|
||||
connected = []
|
||||
disconnected = []
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"connect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"disconnect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
|
||||
)
|
||||
|
||||
view.image(device="eiger", signal="img")
|
||||
assert view.async_update is True
|
||||
assert view.subscriptions["main"].async_signal_name == "async_obj"
|
||||
|
||||
view.scan_id = "scan_current"
|
||||
view.old_scan_id = "scan_previous"
|
||||
connected.clear()
|
||||
disconnected.clear()
|
||||
|
||||
view.device = "waveform1d"
|
||||
|
||||
# Both scan_id and old_scan_id endpoints for the old device must be disconnected
|
||||
assert (
|
||||
MessageEndpoints.device_async_signal("scan_current", "eiger", "async_obj") in disconnected
|
||||
)
|
||||
assert (
|
||||
MessageEndpoints.device_async_signal("scan_previous", "eiger", "async_obj") in disconnected
|
||||
)
|
||||
# The new device's async endpoint for the current scan must be connected
|
||||
assert (
|
||||
MessageEndpoints.device_async_signal("scan_current", "waveform1d", "async_obj") in connected
|
||||
)
|
||||
|
||||
|
||||
def test_switching_signal_disconnects_previous_preview_endpoint(qtbot, mocked_client, monkeypatch):
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(mocked_client, "eiger", "img_a", signal_class="PreviewSignal", ndim=2)
|
||||
_set_signal_config(mocked_client, "eiger", "img_b", signal_class="PreviewSignal", ndim=2)
|
||||
|
||||
connected = []
|
||||
disconnected = []
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"connect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"disconnect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
|
||||
)
|
||||
|
||||
view.image(device="eiger", signal="img_a")
|
||||
connected.clear()
|
||||
disconnected.clear()
|
||||
|
||||
view.signal = "img_b"
|
||||
|
||||
assert MessageEndpoints.device_preview("eiger", "img_a") in disconnected
|
||||
assert MessageEndpoints.device_preview("eiger", "img_b") in connected
|
||||
|
||||
|
||||
def test_switching_signal_disconnects_previous_async_endpoint(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
When the current monitor is an async signal, switching to a different signal must
|
||||
disconnect the previous async endpoint (based on scan_id/async_signal_name) before
|
||||
reconnecting with the new signal's async endpoint.
|
||||
"""
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "img_a", signal_class="AsyncSignal", ndim=2, obj_name="async_obj_a"
|
||||
)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "img_b", signal_class="AsyncSignal", ndim=2, obj_name="async_obj_b"
|
||||
)
|
||||
|
||||
connected = []
|
||||
disconnected = []
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"connect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"disconnect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
|
||||
)
|
||||
|
||||
# Connect to img_a as an async signal; scan_id is None so no actual subscription is made
|
||||
view.image(device="eiger", signal="img_a")
|
||||
assert view.async_update is True
|
||||
assert view.subscriptions["main"].async_signal_name == "async_obj_a"
|
||||
assert view.subscriptions["main"].source == "device_monitor_2d"
|
||||
|
||||
# Simulate an active scan so that the async endpoint is real
|
||||
view.scan_id = "scan_123"
|
||||
connected.clear()
|
||||
disconnected.clear()
|
||||
|
||||
# Switch to a different signal
|
||||
view.signal = "img_b"
|
||||
|
||||
# The previous async endpoint for img_a must have been disconnected
|
||||
expected_disconnect = MessageEndpoints.device_async_signal("scan_123", "eiger", "async_obj_a")
|
||||
assert expected_disconnect in disconnected
|
||||
|
||||
# The new async endpoint for img_b must have been connected
|
||||
expected_connect = MessageEndpoints.device_async_signal("scan_123", "eiger", "async_obj_b")
|
||||
assert expected_connect in connected
|
||||
|
||||
|
||||
def test_preview_signals_skip_0d_entries(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Preview/async combobox should omit 0‑D signals.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -53,14 +53,16 @@ def test_scatter_waveform_update_with_scan_history(qtbot, mocked_client, monkeyp
|
||||
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
|
||||
|
||||
dummy_scan = create_dummy_scan_item()
|
||||
mocked_client.history = MagicMock()
|
||||
# .get_by_scan_id() typically returns historical data, but we abuse it here
|
||||
# to return mock live data
|
||||
mocked_client.history.get_by_scan_id.return_value = dummy_scan
|
||||
mocked_client.history.__getitem__.return_value = dummy_scan
|
||||
|
||||
swf.plot("samx", "samy", "bpm4i", label="test_curve")
|
||||
swf.update_with_scan_history(scan_id="dummy")
|
||||
qtbot.wait(500)
|
||||
|
||||
assert swf.scan_item == dummy_scan
|
||||
qtbot.waitUntil(lambda: swf.scan_item == dummy_scan, timeout=500)
|
||||
qtbot.wait(200)
|
||||
|
||||
x_data, y_data = swf.main_curve.getData()
|
||||
np.testing.assert_array_equal(x_data, [10, 20, 30])
|
||||
|
||||
@@ -8,7 +8,7 @@ from .client_mocks import mocked_client
|
||||
|
||||
@pytest.fixture
|
||||
def plot_widget_with_arrow_item(qtbot, mocked_client):
|
||||
widget = Waveform(client=mocked_client())
|
||||
widget = Waveform(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
|
||||
@@ -17,7 +17,7 @@ def plot_widget_with_arrow_item(qtbot, mocked_client):
|
||||
|
||||
@pytest.fixture
|
||||
def plot_widget_with_tick_item(qtbot, mocked_client):
|
||||
widget = Waveform(client=mocked_client())
|
||||
widget = Waveform(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
|
||||
|
||||
@@ -189,10 +189,10 @@ def test_bec_shell_startup_contains_gui_id(bec_shell_widget):
|
||||
assert bec_shell._is_bec_shell
|
||||
assert bec_shell._unique_id == "bec_shell"
|
||||
|
||||
assert bec_shell.startup_cmd == "bec --nogui"
|
||||
with mock.patch.object(bec_shell.bec_dispatcher, "cli_server", None):
|
||||
assert bec_shell.startup_cmd == "bec --nogui"
|
||||
|
||||
with mock.patch.object(bec_shell.bec_dispatcher, "cli_server") as mock_cli_server:
|
||||
mock_cli_server.gui_id = "test_gui_id"
|
||||
with mock.patch.object(bec_shell.bec_dispatcher.cli_server, "gui_id", "test_gui_id"):
|
||||
assert bec_shell.startup_cmd == "bec --gui-id test_gui_id"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user