1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-04 16:02:51 +01:00

fix(dock_area): profile management with empty profile, applied across the whole repo

This commit is contained in:
2026-02-28 18:41:06 +01:00
committed by Jan Wyzula
parent 5cccac4dba
commit 5b13e2c9d5
12 changed files with 456 additions and 157 deletions

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Literal
from bec_lib import bec_logger
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
@@ -9,37 +11,32 @@ logger = bec_logger.logger
def dock_area(
object_name: str | None = None, profile: str | None = None, start_empty: bool = False
object_name: str | None = None, startup_profile: str | Literal["restore", "skip"] | None = None
) -> BECDockArea:
"""
Create an advanced dock area using Qt Advanced Docking System.
Args:
object_name(str): The name of the advanced dock area.
profile(str|None): Optional profile to load; if None the "general" profile is used.
start_empty(bool): If True, start with an empty dock area when loading specified profile.
startup_profile(str | Literal["restore", "skip"] | None): Startup mode for
the workspace:
- None: start empty
- "restore": restore last used profile
- "skip": do not initialize profile state
- "<name>": load specific profile
Returns:
BECDockArea: The created advanced dock area.
Note:
The "general" profile is mandatory and will always exist. If manually deleted,
it will be automatically recreated.
"""
# Default to "general" profile when called from CLI without specifying a profile
effective_profile = profile if profile is not None else "general"
widget = BECDockArea(
object_name=object_name,
restore_initial_profile=True,
root_widget=True,
profile_namespace="bec",
init_profile=effective_profile,
start_empty=start_empty,
)
logger.info(
f"Created advanced dock area with profile: {effective_profile}, start_empty: {start_empty}"
startup_profile=startup_profile,
)
logger.info(f"Created advanced dock area with startup_profile: {startup_profile}")
return widget

View File

@@ -43,6 +43,7 @@ if TYPE_CHECKING: # pragma: no cover
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
START_EMPTY_PROFILE_OPTION = "Start Empty (No Profile)"
class LaunchTile(RoundedFrame):
@@ -354,7 +355,7 @@ class LaunchWindow(BECMainWindow):
def _refresh_dock_area_profiles(self, preserve_selection: bool = True) -> None:
"""
Refresh the dock-area profile selector, optionally preserving the selection.
Sets the combobox to the last used profile or "general" if no selection preserved.
Defaults to Start Empty when no valid selection can be preserved.
Args:
preserve_selection(bool): Whether to preserve the current selection or not.
@@ -369,9 +370,10 @@ class LaunchWindow(BECMainWindow):
)
profiles = list_profiles("bec")
selector_items = [START_EMPTY_PROFILE_OPTION, *profiles]
selector.blockSignals(True)
selector.clear()
for profile in profiles:
for profile in selector_items:
selector.addItem(profile)
if selected_text:
@@ -380,21 +382,31 @@ class LaunchWindow(BECMainWindow):
if idx >= 0:
selector.setCurrentIndex(idx)
else:
# Selection no longer exists, fall back to last profile or "general"
# Selection no longer exists, fall back to default startup selection.
self._set_selector_to_default_profile(selector, profiles)
else:
# No selection to preserve, use last profile or "general"
# No selection to preserve, use default startup selection.
self._set_selector_to_default_profile(selector, profiles)
selector.blockSignals(False)
def _set_selector_to_default_profile(self, selector: QComboBox, profiles: list[str]) -> None:
"""
Set the selector to the last used profile or "general" as fallback.
Set the selector default.
Preference order:
1) Start Empty option (if available)
2) Last used profile
3) First available profile
Args:
selector(QComboBox): The combobox to set.
profiles(list[str]): List of available profiles.
"""
start_empty_idx = selector.findText(START_EMPTY_PROFILE_OPTION, Qt.MatchFlag.MatchExactly)
if start_empty_idx >= 0:
selector.setCurrentIndex(start_empty_idx)
return
# Try to get last used profile
last_profile = get_last_profile(namespace="bec")
if last_profile and last_profile in profiles:
@@ -403,13 +415,6 @@ class LaunchWindow(BECMainWindow):
selector.setCurrentIndex(idx)
return
# Fall back to "general" profile
if "general" in profiles:
idx = selector.findText("general", Qt.MatchFlag.MatchExactly)
if idx >= 0:
selector.setCurrentIndex(idx)
return
# If nothing else, select first item
if selector.count() > 0:
selector.setCurrentIndex(0)
@@ -588,11 +593,14 @@ class LaunchWindow(BECMainWindow):
"""
tile = self.tiles.get("dock_area")
if tile is None or tile.selector is None:
profile = None
startup_profile = None
else:
selection = tile.selector.currentText().strip()
profile = selection if selection else None
return self.launch("dock_area", profile=profile)
if selection == START_EMPTY_PROFILE_OPTION:
startup_profile = None
else:
startup_profile = selection if selection else None
return self.launch("dock_area", startup_profile=startup_profile)
def _open_widget(self):
"""

View File

@@ -350,7 +350,7 @@ class BECDockArea(RPCBase):
@rpc_timeout(None)
@rpc_call
def load_profile(self, name: "str | None" = None, start_empty: "bool" = False):
def load_profile(self, name: "str | None" = None):
"""
Load a workspace profile.
@@ -359,7 +359,6 @@ class BECDockArea(RPCBase):
Args:
name (str | None): The name of the profile to load. If None, prompts the user.
start_empty (bool): If True, load a profile without any widgets. Danger of overwriting the dynamic state of that profile.
"""
@rpc_call

View File

@@ -322,8 +322,7 @@ class BECGuiClient(RPCBase):
wait: bool = True,
geometry: tuple[int, int, int, int] | None = None,
launch_script: str = "dock_area",
profile: str | None = None,
start_empty: bool = False,
startup_profile: str | Literal["restore", "skip"] | None = None,
**kwargs,
) -> client.AdvancedDockArea:
"""Create a new top-level dock area.
@@ -333,24 +332,27 @@ class BECGuiClient(RPCBase):
wait(bool, optional): Whether to wait for the server to start. Defaults to True.
geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h).
launch_script(str): The launch script to use. Defaults to "dock_area".
profile(str | None): The profile name to load. If None, loads the "general" profile.
Use a profile name to load a specific saved profile.
start_empty(bool): If True, start with an empty dock area when loading specified profile.
startup_profile(str | Literal["restore", "skip"] | None): Startup mode for
the dock area:
- None: start in transient empty workspace
- "restore": restore last-used profile
- "skip": skip profile initialization
- "<name>": load the named profile
**kwargs: Additional keyword arguments passed to the dock area.
Returns:
client.AdvancedDockArea: The new dock area.
Note:
The "general" profile is mandatory and will always exist. If manually deleted,
it will be automatically recreated.
Examples:
>>> gui.new() # Start with the "general" profile
>>> gui.new(profile="my_profile") # Load specific profile, if profile does not exist, the new profile is created empty with specified name
>>> gui.new(start_empty=True) # Start with "general" profile but empty dock area
>>> gui.new(profile="my_profile", start_empty=True) # Start with "my_profile" profile but empty dock area
>>> gui.new() # Start with an empty unsaved workspace
>>> gui.new(startup_profile="restore") # Restore last profile
>>> gui.new(startup_profile="my_profile") # Load explicit profile
"""
if "profile" in kwargs or "start_empty" in kwargs:
raise TypeError(
"gui.new() no longer accepts 'profile' or 'start_empty'. Use 'startup_profile' instead."
)
if not self._check_if_server_is_alive():
self.start(wait=True)
if wait:
@@ -359,16 +361,14 @@ class BECGuiClient(RPCBase):
name=name,
geometry=geometry,
launch_script=launch_script,
profile=profile,
start_empty=start_empty,
startup_profile=startup_profile,
**kwargs,
)
return self._new_impl(
name=name,
geometry=geometry,
launch_script=launch_script,
profile=profile,
start_empty=start_empty,
startup_profile=startup_profile,
**kwargs,
)
@@ -378,8 +378,7 @@ class BECGuiClient(RPCBase):
name: str | None,
geometry: tuple[int, int, int, int] | None,
launch_script: str,
profile: str | None,
start_empty: bool,
startup_profile: str | Literal["restore", "skip"] | None,
**kwargs,
):
if launch_script == "dock_area":
@@ -388,8 +387,7 @@ class BECGuiClient(RPCBase):
"system.launch_dock_area",
name=name,
geometry=geometry,
profile=profile,
start_empty=start_empty,
startup_profile=startup_profile,
**kwargs,
)
except ValueError as exc:
@@ -406,8 +404,7 @@ class BECGuiClient(RPCBase):
launch_script=launch_script,
name=name,
geometry=geometry,
profile=profile,
start_empty=start_empty,
startup_profile=startup_profile,
**kwargs,
) # pylint: disable=protected-access

View File

@@ -4,7 +4,7 @@ import functools
import traceback
import types
from contextlib import contextmanager
from typing import TYPE_CHECKING, Callable, TypeVar
from typing import TYPE_CHECKING, Callable, Literal, TypeVar
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
@@ -204,8 +204,7 @@ class RPCServer:
def _launch_dock_area(
name: str | None = None,
geometry: tuple[int, int, int, int] | None = None,
profile: str | None = None,
start_empty: bool = False,
startup_profile: str | Literal["restore", "skip"] | None = None,
) -> QWidget | None:
from bec_widgets.applications import bw_launch
@@ -218,9 +217,7 @@ class RPCServer:
else:
name = WidgetContainerUtils.generate_unique_name("dock_area", existing_dock_areas)
result_widget = bw_launch.dock_area(
object_name=name, profile=profile, start_empty=start_empty
)
result_widget = bw_launch.dock_area(object_name=name, startup_profile=startup_profile)
result_widget.window().setWindowTitle(f"BEC - {name}")
if isinstance(result_widget, BECMainWindow):

View File

@@ -41,7 +41,7 @@ class AutoUpdates(BECMainWindow):
parent=self,
object_name="dock_area",
enable_profile_management=False,
restore_initial_profile=False,
startup_profile="skip",
)
self.setCentralWidget(self.dock_area)
self._auto_update_selected_device: str | None = None

View File

@@ -87,6 +87,7 @@ logger = bec_logger.logger
_PROFILE_NAMESPACE_UNSET = object()
PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_state")}
StartupProfile = Literal["restore", "skip"] | str | None
class BECDockArea(DockAreaWidget):
@@ -124,9 +125,7 @@ class BECDockArea(DockAreaWidget):
instance_id: str | None = None,
auto_save_upon_exit: bool = True,
enable_profile_management: bool = True,
restore_initial_profile: bool = True,
init_profile: str | None = None,
start_empty: bool = False,
startup_profile: StartupProfile = "restore",
**kwargs,
):
self._profile_namespace_hint = profile_namespace
@@ -135,9 +134,7 @@ class BECDockArea(DockAreaWidget):
self._instance_id = slugify.slugify(instance_id, separator="_") if instance_id else None
self._auto_save_upon_exit = auto_save_upon_exit
self._profile_management_enabled = enable_profile_management
self._restore_initial_profile = restore_initial_profile
self._init_profile = init_profile
self._start_empty = start_empty
self._startup_profile = self._normalize_startup_profile(startup_profile)
super().__init__(
parent,
default_add_direction=default_add_direction,
@@ -162,10 +159,12 @@ class BECDockArea(DockAreaWidget):
self._root_layout.insertWidget(0, self.toolbar)
# Populate and hook the workspace combo
self._refresh_workspace_list()
self._current_profile_name = None
self._empty_profile_active = False
self._empty_profile_consumed = False
self._pending_autosave_skip: tuple[str, str] | None = None
self._exit_snapshot_written = False
self._refresh_workspace_list()
# State manager
self.state_manager = WidgetStateManager(
@@ -177,84 +176,85 @@ class BECDockArea(DockAreaWidget):
# Initialize default editable state based on current lock
self._set_editable(True) # default to editable; will sync toolbar toggle below
if self._ensure_initial_profile():
self._refresh_workspace_list()
# Apply the requested mode after everything is set up
self.mode = mode
if self._restore_initial_profile:
self._fetch_initial_profile()
self._fetch_initial_profile()
def _ensure_initial_profile(self) -> bool:
@staticmethod
def _normalize_startup_profile(startup_profile: StartupProfile) -> StartupProfile:
"""
Ensure the "general" workspace profile always exists for the current namespace.
The "general" profile is mandatory and will be recreated if deleted.
If list_profile fails due to file permission or corrupted profiles, no action taken.
Returns:
bool: True if a profile was created, False otherwise.
Normalize startup profile values.
"""
namespace = self.profile_namespace
try:
existing_profiles = list_profiles(namespace)
except Exception as exc: # pragma: no cover - defensive guard
logger.warning(f"Unable to enumerate profiles for namespace '{namespace}': {exc}")
return False
if startup_profile == "":
return None
return startup_profile
# Always ensure "general" profile exists
name = "general"
if name in existing_profiles:
return False
logger.info(
f"Profile '{name}' not found in namespace '{namespace}'. Creating mandatory '{name}' workspace."
)
self._write_profile_settings(name, namespace, save_preview=False)
set_quick_select(name, True, namespace=namespace)
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
return True
def _fetch_initial_profile(self):
# Restore last-used profile if available; otherwise fall back to combo selection
def _resolve_restore_startup_profile(self) -> str | None:
"""
Resolve the profile name when startup profile is set to "restore".
"""
combo = self.toolbar.components.get_action("workspace_combo").widget
namespace = self.profile_namespace
init_profile = None
# First priority: use init_profile if explicitly provided
if self._init_profile:
init_profile = self._init_profile
else:
# Try to restore from last used profile
instance_id = self._last_profile_instance_id()
if instance_id:
inst_profile = get_last_profile(
namespace=namespace, instance=instance_id, allow_namespace_fallback=False
)
if inst_profile and self._profile_exists(inst_profile, namespace):
init_profile = inst_profile
if not init_profile:
last = get_last_profile(namespace=namespace)
if last and self._profile_exists(last, namespace):
init_profile = last
else:
text = combo.currentText()
init_profile = text if text else None
if not init_profile:
# Fall back to "general" profile which is guaranteed to exist
if self._profile_exists("general", namespace):
init_profile = "general"
if init_profile:
self._load_initial_profile(init_profile)
instance_id = self._last_profile_instance_id()
if instance_id:
inst_profile = get_last_profile(
namespace=namespace, instance=instance_id, allow_namespace_fallback=False
)
if inst_profile and self._profile_exists(inst_profile, namespace):
return inst_profile
last = get_last_profile(namespace=namespace)
if last and self._profile_exists(last, namespace):
return last
combo_text = combo.currentText().strip()
if combo_text and self._profile_exists(combo_text, namespace):
return combo_text
return None
def _fetch_initial_profile(self):
startup_profile = self._startup_profile
if startup_profile == "skip":
logger.debug("Skipping startup profile initialization.")
return
if startup_profile == "restore":
restored = self._resolve_restore_startup_profile()
if restored:
self._load_initial_profile(restored)
return
self._start_empty_workspace()
return
if startup_profile is None:
self._start_empty_workspace()
return
self._load_initial_profile(startup_profile)
def _load_initial_profile(self, name: str) -> None:
"""Load the initial profile."""
self.load_profile(name, start_empty=self._start_empty)
self.load_profile(name)
combo = self.toolbar.components.get_action("workspace_combo").widget
combo.blockSignals(True)
combo.setCurrentText(name)
if not self._empty_profile_active:
combo.setCurrentText(name)
combo.blockSignals(False)
def _start_empty_workspace(self) -> None:
"""
Initialize the dock area in transient empty-profile mode.
"""
if (
getattr(self, "_current_profile_name", None) is None
and not self._empty_profile_consumed
):
self.delete_all()
self._enter_empty_profile_state()
def _customize_dock(self, dock: CDockWidget, widget: QWidget) -> None:
prefs = getattr(dock, "_dock_preferences", {}) or {}
if prefs.get("show_settings_action") is None:
@@ -601,13 +601,6 @@ class BECDockArea(DockAreaWidget):
"""Namespace used to scope user/default profile files for this dock area."""
return self._resolve_profile_namespace()
def _active_profile_name_or_default(self) -> str:
name = getattr(self, "_current_profile_name", None)
if not name:
name = "general"
self._current_profile_name = name
return name
def _profile_exists(self, name: str, namespace: str | None) -> bool:
return any(
os.path.exists(path) for path in user_profile_candidates(name, namespace)
@@ -675,12 +668,26 @@ class BECDockArea(DockAreaWidget):
name: The profile name.
namespace: The profile namespace.
"""
self._empty_profile_active = False
self._empty_profile_consumed = True
self._current_profile_name = name
self.profile_changed.emit(name)
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
combo = self.toolbar.components.get_action("workspace_combo").widget
combo.refresh_profiles(active_profile=name)
def _enter_empty_profile_state(self) -> None:
"""
Switch to the transient empty workspace state.
In this mode there is no active profile name, the toolbar shows an
explicit blank profile entry, and no autosave on shutdown is performed.
"""
self._empty_profile_active = True
self._current_profile_name = None
self._pending_autosave_skip = None
self._refresh_workspace_list()
@SafeSlot()
def list_profiles(self) -> list[str]:
"""
@@ -814,10 +821,10 @@ class BECDockArea(DockAreaWidget):
"""
self.save_profile(name, show_dialog=True)
@SafeSlot()
@SafeSlot(str)
@SafeSlot(str, bool)
@rpc_timeout(None)
def load_profile(self, name: str | None = None, start_empty: bool = False):
def load_profile(self, name: str | None = None):
"""
Load a workspace profile.
@@ -826,8 +833,10 @@ class BECDockArea(DockAreaWidget):
Args:
name (str | None): The name of the profile to load. If None, prompts the user.
start_empty (bool): If True, load a profile without any widgets. Danger of overwriting the dynamic state of that profile.
"""
if name == "":
return
if not name: # Gui fallback if the name is not provided
name, ok = QInputDialog.getText(
self, "Load Workspace", "Enter the name of the workspace profile to load:"
@@ -859,10 +868,6 @@ class BECDockArea(DockAreaWidget):
# Clear existing docks and remove all widgets
self.delete_all()
if start_empty:
self._finalize_profile_change(name, namespace)
return
# Rebuild widgets and restore states
for item in read_manifest(settings):
obj_name = item["object_name"]
@@ -1008,25 +1013,36 @@ class BECDockArea(DockAreaWidget):
"""
combo = self.toolbar.components.get_action("workspace_combo").widget
active_profile = getattr(self, "_current_profile_name", None)
empty_profile_active = bool(getattr(self, "_empty_profile_active", False))
namespace = self.profile_namespace
if hasattr(combo, "set_quick_profile_provider"):
combo.set_quick_profile_provider(lambda ns=namespace: list_quick_profiles(namespace=ns))
if hasattr(combo, "refresh_profiles"):
combo.refresh_profiles(active_profile)
if empty_profile_active:
combo.refresh_profiles(active_profile, show_empty_profile=True)
else:
combo.refresh_profiles(active_profile)
else:
# Fallback for regular QComboBox
combo.blockSignals(True)
combo.clear()
quick_profiles = list_quick_profiles(namespace=namespace)
items = list(quick_profiles)
items = [""] if empty_profile_active else []
items.extend(quick_profiles)
if active_profile and active_profile not in items:
items.insert(0, active_profile)
combo.addItems(items)
if active_profile:
if empty_profile_active:
idx = combo.findText("")
if idx >= 0:
combo.setCurrentIndex(idx)
elif active_profile:
idx = combo.findText(active_profile)
if idx >= 0:
combo.setCurrentIndex(idx)
if active_profile and active_profile not in quick_profiles:
if empty_profile_active:
combo.setToolTip("Unsaved empty workspace")
elif active_profile and active_profile not in quick_profiles:
combo.setToolTip("Active profile is not in quick select")
else:
combo.setToolTip("")
@@ -1131,7 +1147,16 @@ class BECDockArea(DockAreaWidget):
logger.info("ADS prepare_for_shutdown: skipping (already handled or destroyed)")
return
name = self._active_profile_name_or_default()
if getattr(self, "_empty_profile_active", False):
logger.info("ADS prepare_for_shutdown: skipping autosave for unsaved empty workspace")
self._exit_snapshot_written = True
return
name = getattr(self, "_current_profile_name", None)
if not name:
logger.info("ADS prepare_for_shutdown: skipping autosave (no active profile)")
self._exit_snapshot_written = True
return
namespace = self.profile_namespace
settings = open_user_settings(name, namespace=namespace)

View File

@@ -24,12 +24,15 @@ class ProfileComboBox(QComboBox):
def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None:
self._quick_provider = provider
def refresh_profiles(self, active_profile: str | None = None):
def refresh_profiles(
self, active_profile: str | None = None, show_empty_profile: bool = False
) -> None:
"""
Refresh the profile list and ensure the active profile is visible.
Args:
active_profile(str | None): The currently active profile name.
show_empty_profile(bool): If True, show an explicit empty unsaved workspace entry.
"""
current_text = active_profile or self.currentText()
@@ -39,9 +42,22 @@ class ProfileComboBox(QComboBox):
quick_profiles = self._quick_provider()
quick_set = set(quick_profiles)
items = list(quick_profiles)
items: list[str] = []
if show_empty_profile:
items.append("")
if active_profile and active_profile not in quick_set:
items.insert(0, active_profile)
items.append(active_profile)
for profile in quick_profiles:
if profile not in items:
items.append(profile)
if active_profile and active_profile not in quick_set:
# keep active profile at the top when not in quick list
items.remove(active_profile)
insert_pos = 1 if show_empty_profile else 0
items.insert(insert_pos, active_profile)
for profile in items:
self.addItem(profile)
@@ -52,6 +68,15 @@ class ProfileComboBox(QComboBox):
self.setItemData(idx, None, Qt.ItemDataRole.ToolTipRole)
self.setItemData(idx, None, Qt.ItemDataRole.ForegroundRole)
if profile == "":
self.setItemData(idx, "Unsaved empty workspace", Qt.ItemDataRole.ToolTipRole)
if active_profile is None:
font = QFont(self.font())
font.setItalic(True)
self.setItemData(idx, font, Qt.ItemDataRole.FontRole)
self.setCurrentIndex(idx)
continue
if active_profile and profile == active_profile:
tooltip = "Active workspace profile"
if profile not in quick_set:
@@ -69,16 +94,23 @@ class ProfileComboBox(QComboBox):
self.setItemData(idx, "Not in quick select", Qt.ItemDataRole.ToolTipRole)
# Restore selection if possible
index = self.findText(current_text)
if index >= 0:
self.setCurrentIndex(index)
if show_empty_profile and active_profile is None:
empty_idx = self.findText("")
if empty_idx >= 0:
self.setCurrentIndex(empty_idx)
else:
index = self.findText(current_text)
if index >= 0:
self.setCurrentIndex(index)
self.blockSignals(False)
if active_profile and self.currentText() != active_profile:
idx = self.findText(active_profile)
if idx >= 0:
self.setCurrentIndex(idx)
if active_profile and active_profile not in quick_set:
if show_empty_profile and self.currentText() == "":
self.setToolTip("Unsaved empty workspace")
elif active_profile and active_profile not in quick_set:
self.setToolTip("Active profile is not in quick select")
else:
self.setToolTip("")

View File

@@ -127,3 +127,133 @@ def test_client_utils_apply_theme_toggles_when_none(current_theme, expected_them
mock.call("fetch_theme"),
mock.call("change_theme", theme=expected_theme),
]
def test_client_utils_new_passes_startup_profile():
gui = BECGuiClient()
launcher = mock.MagicMock()
with mock.patch.object(
BECGuiClient, "launcher", new_callable=mock.PropertyMock
) as launcher_prop:
launcher_prop.return_value = launcher
with mock.patch("bec_widgets.cli.client_utils.wait_for_server", _no_wait_for_server):
with mock.patch.object(gui, "_check_if_server_is_alive", return_value=True):
gui.new(startup_profile="saved_profile")
launcher._run_rpc.assert_called_once_with(
"system.launch_dock_area", name=None, geometry=None, startup_profile="saved_profile"
)
def test_client_utils_new_defaults_to_empty_startup_profile():
gui = BECGuiClient()
launcher = mock.MagicMock()
with mock.patch.object(
BECGuiClient, "launcher", new_callable=mock.PropertyMock
) as launcher_prop:
launcher_prop.return_value = launcher
with mock.patch("bec_widgets.cli.client_utils.wait_for_server", _no_wait_for_server):
with mock.patch.object(gui, "_check_if_server_is_alive", return_value=True):
gui.new()
launcher._run_rpc.assert_called_once_with(
"system.launch_dock_area", name=None, geometry=None, startup_profile=None
)
def test_client_utils_new_rejects_legacy_profile_kwargs():
gui = BECGuiClient()
with pytest.raises(TypeError, match="startup_profile"):
gui.new(profile="saved_profile")
def test_client_utils_new_falls_back_when_system_rpc_not_supported():
gui = BECGuiClient()
launcher = mock.MagicMock()
launcher._run_rpc.side_effect = [
ValueError("Unknown system RPC method: system.launch_dock_area"),
"fallback_widget",
]
with mock.patch.object(
BECGuiClient, "launcher", new_callable=mock.PropertyMock
) as launcher_prop:
launcher_prop.return_value = launcher
with mock.patch("bec_widgets.cli.client_utils.wait_for_server", _no_wait_for_server):
with mock.patch.object(gui, "_check_if_server_is_alive", return_value=True):
result = gui.new(startup_profile="restore")
assert result == "fallback_widget"
assert launcher._run_rpc.call_args_list == [
mock.call("system.launch_dock_area", name=None, geometry=None, startup_profile="restore"),
mock.call(
"launch", launch_script="dock_area", name=None, geometry=None, startup_profile="restore"
),
]
def test_client_utils_new_reraises_unexpected_system_rpc_error():
gui = BECGuiClient()
launcher = mock.MagicMock()
launcher._run_rpc.side_effect = ValueError("Some other RPC error")
with mock.patch.object(
BECGuiClient, "launcher", new_callable=mock.PropertyMock
) as launcher_prop:
launcher_prop.return_value = launcher
with mock.patch("bec_widgets.cli.client_utils.wait_for_server", _no_wait_for_server):
with mock.patch.object(gui, "_check_if_server_is_alive", return_value=True):
with pytest.raises(ValueError, match="Some other RPC error"):
gui.new(startup_profile="restore")
def test_client_utils_new_starts_server_when_not_alive():
gui = BECGuiClient()
launcher = mock.MagicMock()
with mock.patch.object(
BECGuiClient, "launcher", new_callable=mock.PropertyMock
) as launcher_prop:
launcher_prop.return_value = launcher
with mock.patch("bec_widgets.cli.client_utils.wait_for_server", _no_wait_for_server):
with (
mock.patch.object(gui, "_check_if_server_is_alive", return_value=False),
mock.patch.object(gui, "start") as mock_start,
):
gui.new(wait=False, startup_profile=None)
mock_start.assert_called_once_with(wait=True)
def test_client_utils_delete_uses_container_proxy():
gui = BECGuiClient()
widget = mock.MagicMock()
widget._gui_id = "widget-id"
with (
mock.patch.object(BECGuiClient, "windows", new_callable=mock.PropertyMock) as windows_prop,
mock.patch.dict(
gui._server_registry, {"widget-id": {"container_proxy": "container-id"}}, clear=True
),
):
windows_prop.return_value = {"dock": widget}
gui.delete("dock")
widget._run_rpc.assert_called_once_with("close", gui_id="container-id")
def test_client_utils_delete_falls_back_to_direct_close():
gui = BECGuiClient()
widget = mock.MagicMock()
widget._gui_id = "widget-id"
with (
mock.patch.object(BECGuiClient, "windows", new_callable=mock.PropertyMock) as windows_prop,
mock.patch.dict(gui._server_registry, {"widget-id": {"container_proxy": None}}, clear=True),
):
windows_prop.return_value = {"dock": widget}
gui.delete("dock")
widget._run_rpc.assert_called_once_with("close")

View File

@@ -1518,6 +1518,8 @@ class TestAdvancedDockAreaRestoreAndDialogs:
profile_name = "refresh_profile"
helper = profile_helper(advanced_dock_area)
helper.open_user(profile_name).sync()
# Simulate a normal named-profile state (not transient empty startup mode).
advanced_dock_area._empty_profile_active = False
advanced_dock_area._current_profile_name = profile_name
combo = advanced_dock_area.toolbar.components.get_action("workspace_combo").widget
combo.refresh_profiles = MagicMock()
@@ -1526,6 +1528,16 @@ class TestAdvancedDockAreaRestoreAndDialogs:
combo.refresh_profiles.assert_called_once_with(profile_name)
def test_refresh_workspace_list_with_empty_workspace_state(self, advanced_dock_area):
combo = advanced_dock_area.toolbar.components.get_action("workspace_combo").widget
combo.refresh_profiles = MagicMock()
advanced_dock_area._current_profile_name = None
advanced_dock_area._empty_profile_active = True
advanced_dock_area._refresh_workspace_list()
combo.refresh_profiles.assert_called_once_with(None, show_empty_profile=True)
def test_refresh_workspace_list_fallback(self, advanced_dock_area):
class ComboStub:
def __init__(self):
@@ -1573,6 +1585,8 @@ class TestAdvancedDockAreaRestoreAndDialogs:
with patch.object(
advanced_dock_area.toolbar.components, "get_action", return_value=StubAction(combo_stub)
):
# Simulate a normal named-profile state (not transient empty startup mode).
advanced_dock_area._empty_profile_active = False
advanced_dock_area._current_profile_name = active
advanced_dock_area._refresh_workspace_list()
@@ -1728,6 +1742,29 @@ class TestProfileManagement:
class TestWorkspaceProfileOperations:
"""Test workspace profile save/load/delete operations."""
def test_empty_startup_profile_creates_transient_unsaved_workspace(self, qtbot, mocked_client):
widget = BECDockArea(client=mocked_client, startup_profile=None)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
helper = profile_helper(widget)
assert widget._empty_profile_active is True
assert widget._empty_profile_consumed is False
assert widget._current_profile_name is None
combo = widget.toolbar.components.get_action("workspace_combo").widget
assert combo.currentText() == ""
with patch.object(widget, "_write_snapshot_to_settings") as mock_write:
widget.prepare_for_shutdown()
mock_write.assert_not_called()
helper.open_user("real_profile").sync()
widget.load_profile("real_profile")
assert widget._empty_profile_active is False
assert widget._empty_profile_consumed is True
assert widget._current_profile_name == "real_profile"
assert combo.currentText() == "real_profile"
def test_save_profile_readonly_conflict(
self, advanced_dock_area, temp_profile_dir, module_profile_factory
):

View File

@@ -7,7 +7,7 @@ import pytest
from qtpy.QtGui import QFontMetrics
import bec_widgets
from bec_widgets.applications.launch_window import LaunchWindow
from bec_widgets.applications.launch_window import START_EMPTY_PROFILE_OPTION, LaunchWindow
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
@@ -84,6 +84,29 @@ def test_launch_window_launch_plugin_auto_update(bec_launch_window):
res.deleteLater()
def test_launch_window_dock_area_selector_has_start_empty_option(bec_launch_window):
selector = bec_launch_window.tiles["dock_area"].selector
assert selector is not None
assert selector.findText(START_EMPTY_PROFILE_OPTION) >= 0
def test_launch_window_dock_area_selector_defaults_to_start_empty(bec_launch_window):
selector = bec_launch_window.tiles["dock_area"].selector
assert selector is not None
assert selector.currentText() == START_EMPTY_PROFILE_OPTION
def test_open_dock_area_with_start_empty_option_calls_launch(bec_launch_window):
selector = bec_launch_window.tiles["dock_area"].selector
assert selector is not None
selector.setCurrentText(START_EMPTY_PROFILE_OPTION)
with mock.patch.object(bec_launch_window, "launch") as mock_launch:
bec_launch_window._open_dock_area()
mock_launch.assert_called_once_with("dock_area", startup_profile=None)
@pytest.mark.parametrize(
"connection_names, hide",
[

View File

@@ -1,4 +1,6 @@
import webbrowser
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from qtpy.QtCore import QEvent, QPoint, QPointF
@@ -55,6 +57,58 @@ def test_status_bar_has_separator(bec_main_window):
assert separators, "Expected at least one QFrame separator in the status bar."
def test_display_app_id_not_connected(bec_main_window):
with patch.object(bec_main_window.bec_dispatcher, "cli_server", None):
bec_main_window.display_app_id()
assert bec_main_window._app_id_label.text() == "Not connected"
def test_display_app_id_connected(bec_main_window):
with patch.object(bec_main_window.bec_dispatcher, "cli_server", MagicMock(gui_id="gui_123")):
bec_main_window.display_app_id()
assert bec_main_window._app_id_label.text() == "App ID: gui_123"
def test_event_consumes_status_tip(bec_main_window):
status_tip_event = QEvent(QEvent.Type.StatusTip)
assert bec_main_window.event(status_tip_event) is True
def test_get_launcher_from_qapp_returns_none_when_absent(bec_main_window):
with patch.object(
QApplication, "instance", return_value=SimpleNamespace(topLevelWidgets=lambda: [])
):
assert bec_main_window._get_launcher_from_qapp() is None
def test_show_launcher_warns_when_cli_server_missing(bec_main_window):
with (
patch.object(bec_main_window.bec_dispatcher, "cli_server", None),
patch.object(bec_main_window, "_get_launcher_from_qapp", return_value=None),
patch("bec_widgets.widgets.containers.main_window.main_window.logger.warning") as mock_warn,
):
bec_main_window._show_launcher()
mock_warn.assert_called_once()
def test_show_launcher_creates_launcher_when_missing(bec_main_window):
launcher = MagicMock()
with (
patch.object(bec_main_window.bec_dispatcher, "cli_server", MagicMock(gui_id="server_id")),
patch.object(bec_main_window, "_get_launcher_from_qapp", return_value=None),
patch("bec_widgets.applications.launch_window.LaunchWindow", return_value=launcher) as cls,
):
bec_main_window._show_launcher()
cls.assert_called_once_with(gui_id="server_id:launcher")
launcher.setAttribute.assert_called_once()
launcher.show.assert_called_once()
launcher.activateWindow.assert_called_once()
launcher.raise_.assert_called_once()
assert bec_main_window._launcher_window is launcher
#################################################################
# Tests for BECMainWindow Addons
#################################################################