From e8d503740601db9c316b3a87b8b29941b1e750ec Mon Sep 17 00:00:00 2001 From: PN Tech Date: Tue, 17 Mar 2026 13:33:29 +0300 Subject: [PATCH 1/2] fix(stability-core): harden sessions/settings/theme/notifications/update flows --- README.md | 8 ++-- README_RU.md | 8 ++-- app.py | 2 +- core/updater.py | 9 ++++ modules/auth/mvc/auth_model.py | 14 ++++-- .../mvc/document_editor_controller.py | 8 ++-- .../mvc/document_editor_model.py | 45 ++++++++++++++++--- tests/test_auth.py | 11 +++++ tests/test_document_files.py | 28 +++++++++++- tests/test_notifications.py | 25 ++++++++++- tests/test_settings_manager.py | 11 +++++ tests/test_theme_manager.py | 12 +++++ tests/test_updater.py | 24 ++++++++++ utils/notifications/notification_service.py | 10 +++++ utils/settings_manager.py | 10 ++++- utils/theme_manager.py | 20 ++++++--- utils/whats_new_modal.py | 10 ++--- 17 files changed, 219 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 45e0ecf..9d486e4 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,11 @@ Allows authorized users to create, edit, and manage document pages. Support for --- -## 🛠 What's New (v0.2.16) +## 🛠 What's New (v0.2.17) -- Improved toolbar behavior in document edit/create windows. -- `Duplicate` and `Delete` now only mark a document as changed when pages were actually modified. -- Reduced accidental “unsaved changes” state when pressing toolbar actions with no selected pages. +- Improved overall stability for sessions, settings, notifications, and updates. +- Added safer handling for duplicate file drops and incomplete session data edge cases. +- Reduced risk of UI interruptions in high-frequency actions (toasts, theme switches, update install flow). --- diff --git a/README_RU.md b/README_RU.md index e7e5d44..3b560b7 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,11 +35,11 @@ --- -## 🛠 Что нового (v0.2.16) +## 🛠 Что нового (v0.2.17) -- Улучшено поведение панели инструментов в окнах создания и редактирования документа. -- `Дублировать` и `Удалить` теперь помечают документ как изменённый только при реальном изменении страниц. -- Снижен риск ложного состояния “есть несохранённые изменения” при нажатии действий без выбранных страниц. +- Повышена общая стабильность сессий, настроек, уведомлений и обновлений. +- Добавлена более безопасная обработка дубликатов файлов и неполных данных сессии в edge-case сценариях. +- Снижен риск сбоев интерфейса при частых действиях (уведомления, переключение темы, запуск установщика обновления). --- diff --git a/app.py b/app.py index d0addf1..facf8bb 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.2.16" +APP_VERSION = "0.2.17" class Application: """ diff --git a/core/updater.py b/core/updater.py index bfd6fb9..8e7c0f0 100644 --- a/core/updater.py +++ b/core/updater.py @@ -4,6 +4,7 @@ import tempfile import subprocess import logging +from pathlib import Path from packaging import version from PyQt5.QtCore import QObject, pyqtSignal, QThread, Qt, QTimer from PyQt5.QtWidgets import QDialog, QMessageBox @@ -247,6 +248,14 @@ def _show_install_confirmation(self, file_path): self._install_update(file_path) def _install_update(self, file_path): + installer_path = Path(file_path) + if not installer_path.exists() or installer_path.stat().st_size <= 0: + NotificationService().show_toast("error", "Ошибка", "Файл обновления поврежден или не найден.") + return + if sys.platform == "win32" and installer_path.suffix.lower() != ".exe": + NotificationService().show_toast("error", "Ошибка", "Некорректный формат установщика обновления.") + return + try: if sys.platform == "win32": os.startfile(file_path) diff --git a/modules/auth/mvc/auth_model.py b/modules/auth/mvc/auth_model.py index ab4c7cf..9636658 100644 --- a/modules/auth/mvc/auth_model.py +++ b/modules/auth/mvc/auth_model.py @@ -260,7 +260,10 @@ def logout(self) -> None: """ def delete_file(file_path: Path) -> None: if file_path.exists(): - file_path.unlink() + try: + file_path.unlink() + except OSError as e: + logging.warning(f"Failed to delete file {file_path}: {e}") def disable_auto_login(user_id: int) -> None: profile_path = self.APP_DIR / "Profiles" / f"user_data_{user_id}.json" @@ -269,8 +272,11 @@ def disable_auto_login(user_id: int) -> None: return profile_data["auto_login"] = False - with open(profile_path, "w", encoding="utf-8") as f: - json.dump(profile_data, f, indent=4, ensure_ascii=False) + try: + with open(profile_path, "w", encoding="utf-8") as f: + json.dump(profile_data, f, indent=4, ensure_ascii=False) + except OSError as e: + logging.warning(f"Failed to disable auto-login in {profile_path}: {e}") # Get last user id last_logged_data = read_json(self.LOCAL_DIR_LAST_LOGGED) @@ -288,6 +294,8 @@ def disable_auto_login(user_id: int) -> None: logging.info(f"Tokens for user_id {user_id} deleted from keyring.") except keyring_errors.PasswordDeleteError: logging.info(f"Tokens for user_id {user_id} not found in keyring, skipping deletion.") + except Exception as e: + logging.warning(f"Failed to delete tokens for user_id {user_id}: {e}") disable_auto_login(user_id) diff --git a/modules/document_editor/mvc/document_editor_controller.py b/modules/document_editor/mvc/document_editor_controller.py index e0271f3..a9bf7bd 100644 --- a/modules/document_editor/mvc/document_editor_controller.py +++ b/modules/document_editor/mvc/document_editor_controller.py @@ -304,11 +304,13 @@ def _on_files_dropped(self, files: list) -> None: msg += f"\n...и еще {len(oversized_files) - 5}" NotificationService().show_toast("error", "Ошибка загрузки", msg) + added_any = False for file_path in valid_files: - self.model.add_pending_file(file_path) - self.view.add_file_widget(file_path) + if self.model.add_pending_file(file_path): + self.view.add_file_widget(file_path) + added_any = True - if valid_files: + if added_any: self._on_document_data_changed() diff --git a/modules/document_editor/mvc/document_editor_model.py b/modules/document_editor/mvc/document_editor_model.py index db6fead..090de47 100644 --- a/modules/document_editor/mvc/document_editor_model.py +++ b/modules/document_editor/mvc/document_editor_model.py @@ -2,6 +2,7 @@ import logging import keyring import requests +import os from pathlib import Path @@ -22,6 +23,7 @@ def __init__( self.document_data = document_data if document_data is not None else {} self.pages = pages if pages is not None else [] self.pending_files = [] + self._pending_file_keys = set() self.config_data = load_config() @@ -137,24 +139,55 @@ def upload_file(self, file_path: str) -> dict: return self._make_authorized_request(self.api.upload_file, document_id=doc_id, file_path=file_path) - def add_pending_file(self, file_path: str) -> None: - """Adds a file to the pending upload list.""" - if file_path not in self.pending_files: - self.pending_files.append(file_path) + @staticmethod + def _pending_file_key(file_path: str) -> str: + """Normalizes file path for duplicate detection.""" + try: + normalized = str(Path(file_path).expanduser().resolve(strict=False)) + except Exception: + normalized = str(file_path) + return normalized.lower() if os.name == "nt" else normalized + + def _rebuild_pending_file_keys(self) -> None: + self._pending_file_keys = {self._pending_file_key(path) for path in self.pending_files} + + def add_pending_file(self, file_path: str) -> bool: + """Adds a file to the pending upload list. + + Returns: + bool: True if file added, False if duplicate. + """ + self._rebuild_pending_file_keys() + key = self._pending_file_key(file_path) + if key in self._pending_file_keys: + return False + self.pending_files.append(file_path) + self._pending_file_keys.add(key) + return True def remove_pending_file(self, file_path: str) -> None: """Removes a file from the pending upload list.""" - if file_path in self.pending_files: - self.pending_files.remove(file_path) + self._rebuild_pending_file_keys() + remove_key = self._pending_file_key(file_path) + to_remove = None + for path in self.pending_files: + if self._pending_file_key(path) == remove_key: + to_remove = path + break + if to_remove is not None: + self.pending_files.remove(to_remove) + self._pending_file_keys.discard(remove_key) def upload_pending_files(self) -> None: """Uploads all pending files.""" + self._rebuild_pending_file_keys() errors = [] for file_path in list(self.pending_files): try: self.upload_file(file_path) self.pending_files.remove(file_path) + self._pending_file_keys.discard(self._pending_file_key(file_path)) except Exception as e: errors.append(f"Не удалось загрузить {Path(file_path).name}: {e}") diff --git a/tests/test_auth.py b/tests/test_auth.py index efd3fae..5eaadcf 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -97,6 +97,17 @@ def test_logout(self, model): mock_json_dump.assert_called() model.LOCAL_DIR_LAST_LOGGED.unlink.assert_called_once() + def test_logout_handles_file_delete_errors_gracefully(self, model): + model.LOCAL_DIR_LAST_LOGGED.exists.return_value = True + model.LOCAL_DIR_LAST_LOGGED.unlink.side_effect = OSError("locked") + + with patch("modules.auth.mvc.auth_model.read_json", side_effect=[{"user_id": 1}, {"auto_login": True}]), \ + patch("modules.auth.mvc.auth_model.keyring.delete_password"), \ + patch("builtins.open", new_callable=MagicMock), \ + patch("json.dump"): + # Should not raise even if unlink fails. + model.logout() + def test_verify_token(self, model): """Test token verification logic.""" diff --git a/tests/test_document_files.py b/tests/test_document_files.py index 42e3352..d470aa3 100644 --- a/tests/test_document_files.py +++ b/tests/test_document_files.py @@ -56,6 +56,27 @@ def path_side_effect(arg): # Since model is a Mock, we check if the attribute was set assert controller.model.is_document_edited is True + def test_files_dropped_duplicate_paths_are_ignored(self, controller): + files = ["/tmp/doc1.pdf", "/tmp/doc1.pdf"] + + with patch("modules.document_editor.mvc.document_editor_controller.Path") as mock_path: + def path_side_effect(arg): + p = MagicMock() + p.name = str(arg).split("/")[-1] + p.suffix = "." + str(arg).split(".")[-1] + p.stat.return_value.st_size = 1024 + return p + + mock_path.side_effect = path_side_effect + controller.model.add_pending_file.side_effect = [True, False] + controller.model.is_document_edited = False + + controller._on_files_dropped(files) + + # Only first unique file should be shown in UI. + assert controller.view.add_file_widget.call_count == 1 + assert controller.model.is_document_edited is True + def test_files_dropped_blocked_extension(self, controller): """Test dropping files with blocked extensions.""" @@ -202,6 +223,11 @@ def test_upload_file(self, model): model.upload_file(file_path) model.api.upload_file.assert_called_once() + def test_add_pending_file_deduplicates_paths(self, model): + assert model.add_pending_file("C:/Temp/test.pdf") is True + assert model.add_pending_file("C:/Temp/test.pdf") is False + assert len(model.pending_files) == 1 + def test_upload_pending_files_success(self, model): """Test uploading multiple pending files.""" @@ -229,4 +255,4 @@ def test_upload_pending_files_failure(self, model): assert "Upload failed" in str(excinfo.value) # File should remain in pending if failed - assert "f1.pdf" in model.pending_files \ No newline at end of file + assert "f1.pdf" in model.pending_files diff --git a/tests/test_notifications.py b/tests/test_notifications.py index e5ea942..fd23a22 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -44,7 +44,8 @@ def test_show_toast(self, mock_toast_class, mock_tm): mock_toast_class.return_value = mock_toast_instance # Call method - service.show_toast("success", "Title", "Message") + with patch("utils.notifications.notification_service.time.monotonic", return_value=100.0): + service.show_toast("success", "Title", "Message") # Verify that ToastNotification was created with correct parameters mock_toast_class.assert_called_once_with( @@ -59,6 +60,28 @@ def test_show_toast(self, mock_toast_class, mock_tm): mock_toast_instance.destroyed.connect.assert_called_once() mock_toast_instance.show_animated.assert_called_once() + @patch("utils.notifications.notification_service.ThemeManagerInstance") + @patch("utils.notifications.notification_service.ToastNotification") + def test_show_toast_deduplicates_burst_identical_messages(self, mock_toast_class, mock_tm): + service = NotificationService() + mock_window = Mock(spec=QWidget) + mock_window.height.return_value = 600 + mock_window.width.return_value = 800 + service.set_main_window(mock_window) + + toast_instance = Mock() + toast_instance.height.return_value = 80 + toast_instance.width.return_value = 220 + toast_instance.destroyed = Mock() + toast_instance.destroyed.connect = Mock() + mock_toast_class.return_value = toast_instance + + with patch("utils.notifications.notification_service.time.monotonic", side_effect=[100.0, 100.2]): + service.show_toast("info", "Sync", "Done") + service.show_toast("info", "Sync", "Done") + + mock_toast_class.assert_called_once() + @patch("utils.notifications.notification_service.ThemeManagerInstance") def test_destroyed_toast_is_removed_from_stack(self, mock_tm): service = NotificationService() diff --git a/tests/test_settings_manager.py b/tests/test_settings_manager.py index befe329..fea5baf 100644 --- a/tests/test_settings_manager.py +++ b/tests/test_settings_manager.py @@ -1,4 +1,5 @@ import json +from pathlib import Path from unittest.mock import MagicMock, patch @@ -76,3 +77,13 @@ def test_load_settings_keeps_current_schema(self, tmp_path): "search_by_code": False, "exact_match": True, } + + def test_set_setting_persists_json_atomically(self, tmp_path): + with patch("utils.settings_manager.get_app_data_dir", return_value=tmp_path): + manager = SettingsManager(user_id=1) + manager.set_setting("theme", 0) + + saved = json.loads(manager.settings_file.read_text(encoding="utf-8")) + assert saved["theme"] == 0 + temp_file = Path(str(manager.settings_file) + ".tmp") + assert not temp_file.exists() diff --git a/tests/test_theme_manager.py b/tests/test_theme_manager.py index 824688a..926dcd5 100644 --- a/tests/test_theme_manager.py +++ b/tests/test_theme_manager.py @@ -170,3 +170,15 @@ def test_compile_whats_new_styles(self, mock_configs): light_qss = themes_dir / "light.qss" assert light_qss.exists() assert "#whatsNewContainer" in light_qss.read_text(encoding="utf-8") + + def test_switch_theme_does_not_commit_state_when_apply_fails(self, mock_configs): + with patch("utils.theme_manager.get_app_root", return_value=mock_configs): + tm = ThemeManager() + tm.current_theme_id = "0" + tm.settings_manager = Mock() + + with patch.object(tm, "_apply_theme", return_value=False): + tm.switch_theme("1") + + assert tm.current_theme_id == "0" + tm.settings_manager.set_setting.assert_not_called() diff --git a/tests/test_updater.py b/tests/test_updater.py index 09c38b1..4d648c1 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -224,3 +224,27 @@ def test_on_download_canceled_cleans_state(self): manager.progress_dialog.close.assert_called_once() assert manager._downloader is None service_instance.show_toast.assert_called_once() + + def test_install_update_rejects_missing_file(self): + manager = UpdateManager(current_version="0.2.0", repo_name="owner/repo") + + with patch("core.updater.NotificationService") as mock_service, \ + patch("core.updater.sys.platform", "win32"), \ + patch("core.updater.os.startfile") as mock_start: + manager._install_update("Z:/not-found/setup.exe") + + mock_start.assert_not_called() + mock_service.return_value.show_toast.assert_called_once() + + def test_install_update_rejects_invalid_windows_extension(self, tmp_path): + manager = UpdateManager(current_version="0.2.0", repo_name="owner/repo") + fake_installer = tmp_path / "setup.bin" + fake_installer.write_bytes(b"abc") + + with patch("core.updater.NotificationService") as mock_service, \ + patch("core.updater.sys.platform", "win32"), \ + patch("core.updater.os.startfile") as mock_start: + manager._install_update(str(fake_installer)) + + mock_start.assert_not_called() + mock_service.return_value.show_toast.assert_called_once() diff --git a/utils/notifications/notification_service.py b/utils/notifications/notification_service.py index 3132349..79a7e3c 100644 --- a/utils/notifications/notification_service.py +++ b/utils/notifications/notification_service.py @@ -1,5 +1,6 @@ from PyQt5.QtCore import QTimer, QObject, QEvent from PyQt5.QtWidgets import QWidget +import time from .toast_notification import ToastNotification from .modal_notification import ModalNotification @@ -49,6 +50,8 @@ def __init__(self): self.main_window: QWidget | None = None self.active_toasts = [] self.resize_filter = WindowResizeFilter(self) + self._last_toast_ts_by_key: dict[tuple[str, str, str], float] = {} + self.TOAST_DEDUPE_WINDOW_SEC = 0.8 def set_main_window(self, main_window: QWidget): """Sets the main window instance for positioning notifications. @@ -94,6 +97,13 @@ def show_toast( print("ERROR: Main window not set for NotificationService.") return + toast_key = (notification_type, title, message) + now = time.monotonic() + last_ts = self._last_toast_ts_by_key.get(toast_key) + if last_ts is not None and (now - last_ts) < self.TOAST_DEDUPE_WINDOW_SEC: + return + self._last_toast_ts_by_key[toast_key] = now + toast = ToastNotification( title=title, message=message, diff --git a/utils/settings_manager.py b/utils/settings_manager.py index 5cec1fc..77cf60f 100644 --- a/utils/settings_manager.py +++ b/utils/settings_manager.py @@ -1,5 +1,6 @@ import json import logging +import os from pathlib import Path from typing import Any, Dict @@ -104,11 +105,18 @@ def load_settings(self) -> Dict[str, Any]: def save_settings(self) -> None: """Saves the current settings to the user's settings file.""" + temp_path = self.settings_file.with_suffix(self.settings_file.suffix + ".tmp") try: - with open(self.settings_file, "w", encoding="utf-8") as f: + with open(temp_path, "w", encoding="utf-8") as f: json.dump(self.settings, f, indent=4, ensure_ascii=False) + os.replace(temp_path, self.settings_file) except IOError as e: self.logger.error(f"Failed to save settings to {self.settings_file}: {e}") + try: + if temp_path.exists(): + temp_path.unlink() + except OSError: + pass def get_setting(self, key: str, default: Any = None) -> Any: """ diff --git a/utils/theme_manager.py b/utils/theme_manager.py index 7ef135f..f3e23bf 100644 --- a/utils/theme_manager.py +++ b/utils/theme_manager.py @@ -78,17 +78,21 @@ def switch_theme(self, theme: int | str | None = None) -> None: If None, toggles between the primary themes. """ try: + prev_theme_id = self.current_theme_id if theme is None: - self.current_theme_id = "1" if self.current_theme_id == "0" else "0" + target_theme_id = "1" if self.current_theme_id == "0" else "0" else: theme_str = str(theme) if theme_str in self.themes: - self.current_theme_id = theme_str + target_theme_id = theme_str else: logger.error(f"Theme {theme} not found in config.") return - self._apply_theme() + self.current_theme_id = target_theme_id + if not self._apply_theme(): + self.current_theme_id = prev_theme_id + return # Save the theme setting if a settings manager is available if self.settings_manager: @@ -143,7 +147,7 @@ def _load_ui_config(self) -> dict: # THEME APPLY # --------------------------- - def _apply_theme(self) -> None: + def _apply_theme(self) -> bool: """Applies the current theme's QSS stylesheet to the QApplication. Ensures the theme is availabel and its QSS file exists, then reads the @@ -151,7 +155,7 @@ def _apply_theme(self) -> None: """ if not self._validate_theme_availabel(): - return + return False theme_name = self.themes[self.current_theme_id] themes_path = get_app_root() / self.ui_config["paths"]["themes_path"] @@ -164,13 +168,15 @@ def _apply_theme(self) -> None: app = QApplication.instance() if not app: logger.error("QApplication is not running. Stylesheet not applied.") - return + return False app.setStyleSheet(style) logger.info(f"Theme '{theme_name}' applied successfully.") + return True except Exception as e: logger.error(f"Error applying theme: {e}") + return False # --------------------------- # VALIDATION @@ -269,4 +275,4 @@ def _compile_all_themes(self) -> bool: return False -ThemeManagerInstance = ThemeManager() \ No newline at end of file +ThemeManagerInstance = ThemeManager() diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index a0f6daa..8f9e3ad 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,13 +17,13 @@ RELEASE_NOTES = { - "0.2.16": [ + "0.2.17": [ { - "title": "Улучшения панели инструментов", + "title": "Общее повышение стабильности", "items": [ - "Улучшено поведение панели инструментов в окнах создания и редактирования документа.", - "`Дублировать` и `Удалить` теперь помечают документ как изменённый только при реальном изменении страниц.", - "Снижен риск ложного состояния `есть несохранённые изменения` при нажатии действий без выбранных страниц.", + "Повышена общая стабильность сессий, настроек, уведомлений и обновлений.", + "Добавлена более безопасная обработка дубликатов файлов и неполных данных сессии в edge-case сценариях.", + "Снижен риск сбоев интерфейса при частых действиях (уведомления, переключение темы, запуск установщика обновления).", ], }, ], From f11393beef88163645df2d3a298849dd92abdd1a Mon Sep 17 00:00:00 2001 From: PN Tech Date: Tue, 17 Mar 2026 14:24:34 +0300 Subject: [PATCH 2/2] fix(sidebar) preserve selected department/category on reload --- README.md | 7 +- README_RU.md | 7 +- app.py | 2 +- tests/test_custom_widgets.py | 91 ++++++++++++++++++- ui/custom_widgets/treeview.py | 160 +++++++++++++++++++++++++++------- utils/whats_new_modal.py | 9 +- 6 files changed, 230 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 9d486e4..8e863ad 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,10 @@ Allows authorized users to create, edit, and manage document pages. Support for --- -## 🛠 What's New (v0.2.17) +## 🛠 What's New (v0.2.18) -- Improved overall stability for sessions, settings, notifications, and updates. -- Added safer handling for duplicate file drops and incomplete session data edge cases. -- Reduced risk of UI interruptions in high-frequency actions (toasts, theme switches, update install flow). +- Fixed an issue where selected Department or Category could occasionally lose visual highlight after collapsing and expanding sidebar groups. +- Improved sidebar behavior so the previously selected item is restored more reliably after expanding a group. --- diff --git a/README_RU.md b/README_RU.md index 3b560b7..fba1239 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,11 +35,10 @@ --- -## 🛠 Что нового (v0.2.17) +## 🛠 Что нового (v0.2.18) -- Повышена общая стабильность сессий, настроек, уведомлений и обновлений. -- Добавлена более безопасная обработка дубликатов файлов и неполных данных сессии в edge-case сценариях. -- Снижен риск сбоев интерфейса при частых действиях (уведомления, переключение темы, запуск установщика обновления). +- Исправлена ошибка, из-за которой при сворачивании и разворачивании групп в боковой панели иногда визуально пропадала подсветка выбранного Отдела или Категории. +- Улучшено поведение боковой панели: после разворачивания группы выбранный пункт теперь восстанавливается стабильнее. --- diff --git a/app.py b/app.py index facf8bb..ecc2b0c 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.2.17" +APP_VERSION = "0.2.18" class Application: """ diff --git a/tests/test_custom_widgets.py b/tests/test_custom_widgets.py index ede61a6..5fdc222 100644 --- a/tests/test_custom_widgets.py +++ b/tests/test_custom_widgets.py @@ -1,6 +1,6 @@ import pytest from unittest.mock import Mock, patch -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QItemSelectionModel from PyQt5.QtGui import QIcon from ui.custom_widgets.lineedits import TagsLineEdit @@ -138,6 +138,95 @@ def test_selection(self, mock_tm, qapp): sidebar.set_selected("B") assert sidebar.get_selected_id() == "B" + @patch("ui.custom_widgets.treeview.ThemeManagerInstance") + def test_expand_restores_programmatic_selection_after_transient_clear(self, mock_tm, qapp): + """Expanded group should restore last active item even if selection was transiently cleared.""" + sidebar = SidebarBlock() + items = [ + SidebarItem(id="A", title="A"), + SidebarItem(id="B", title="B") + ] + sidebar.set_items(items, group_title="Departments") + + model = sidebar.model() + group_index = model.index(0, 0) + b_index = model.index(1, 0, group_index) + selection_model = sidebar.selectionModel() + + selection_model.setCurrentIndex( + b_index, + QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows + ) + assert sidebar.get_selected_id() == "B" + + sidebar.collapse(group_index) + qapp.processEvents() + + selection_model.clearSelection() + selection_model.setCurrentIndex(group_index, QItemSelectionModel.NoUpdate) + + sidebar.expand(group_index) + qapp.processEvents() + + assert sidebar.get_selected_id() == "B" + + @patch("ui.custom_widgets.treeview.ThemeManagerInstance") + def test_expand_restores_selection_for_numeric_ids(self, mock_tm, qapp): + """Expanded group should restore selected item when ids are numeric.""" + sidebar = SidebarBlock() + items = [ + SidebarItem(id=1, title="Dept 1"), + SidebarItem(id=2, title="Dept 2") + ] + sidebar.set_items(items, group_title="Departments") + + model = sidebar.model() + group_index = model.index(0, 0) + dept2_index = model.index(1, 0, group_index) + selection_model = sidebar.selectionModel() + + selection_model.setCurrentIndex( + dept2_index, + QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows + ) + assert sidebar.get_selected_id() == 2 + + sidebar.collapse(group_index) + qapp.processEvents() + + selection_model.clearSelection() + selection_model.setCurrentIndex(group_index, QItemSelectionModel.NoUpdate) + + sidebar.expand(group_index) + qapp.processEvents() + + assert sidebar.get_selected_id() == 2 + + @patch("ui.custom_widgets.treeview.ThemeManagerInstance") + def test_set_items_preserves_selected_item_on_reload(self, mock_tm, qapp): + """Reloading items should keep previously selected item when it still exists.""" + sidebar = SidebarBlock() + initial_items = [ + SidebarItem(id="A", title="Dept A", count=1), + SidebarItem(id="B", title="Dept B", count=2), + SidebarItem(id="C", title="Dept C", count=3), + ] + sidebar.set_items(initial_items, group_title="Departments") + sidebar.set_selected("B") + assert sidebar.get_selected_id() == "B" + + reloaded_items = [ + SidebarItem(id="A", title="Dept A", count=10), + SidebarItem(id="B", title="Dept B", count=20), + SidebarItem(id="C", title="Dept C", count=30), + ] + sidebar.set_items(reloaded_items, group_title="Departments") + + assert sidebar.get_selected_id() == "B" + selected_rows = sidebar.selectionModel().selectedRows(0) + assert len(selected_rows) == 1 + assert selected_rows[0].data(ROLE_ID) == "B" + class TestThemeSwitch: diff --git a/ui/custom_widgets/treeview.py b/ui/custom_widgets/treeview.py index 07a5bb9..3c81944 100644 --- a/ui/custom_widgets/treeview.py +++ b/ui/custom_widgets/treeview.py @@ -8,7 +8,7 @@ ) from PyQt5.QtCore import ( Qt, QRect, pyqtSignal, QEvent, QAbstractItemModel, - QModelIndex, pyqtProperty, QSize, QPersistentModelIndex, QPoint + QModelIndex, pyqtProperty, QSize, QPersistentModelIndex, QPoint, QItemSelectionModel ) from PyQt5.QtWidgets import ( QTreeView, QWidget, QStyledItemDelegate, @@ -357,6 +357,10 @@ def __init__(self, parent: QWidget | None = None) -> None: self._edit_pressed_idx = QPersistentModelIndex() self.clicked.connect(self._on_clicked) + if self.selectionModel(): + self.selectionModel().currentChanged.connect(self._on_current_changed) + self.collapsed.connect(self._on_group_collapsed) + self.expanded.connect(self._on_group_expanded) ThemeManagerInstance.themeChanged.connect(self._on_theme_changed) # Initialization of private attributes for properties self._badge_background_color = QColor() @@ -420,6 +424,11 @@ def clear_items(self) -> None: self._items_by_id.clear() self._active_id = None + @staticmethod + def _normalize_id(value) -> str: + """Normalizes item identifiers for consistent internal lookups.""" + return str(value) + def set_items( self, items: Iterable[SidebarItem], @@ -437,6 +446,13 @@ def set_items( group_icon: The icon for the group (optional). expand_group: Whether to expand the group by default. """ + previous_active_id = self._active_id + current_index = self.currentIndex() + if current_index.isValid(): + current_id = current_index.data(ROLE_ID) + if current_id is not None: + previous_active_id = self._normalize_id(current_id) + self.clear_items() parent_item = self._model.invisibleRootItem() @@ -461,28 +477,41 @@ def set_items( std.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) parent_item.appendRow(std) - self._items_by_id[it.id] = std + self._items_by_id[self._normalize_id(it.id)] = std if group_title is not None and expand_group: self.expand(self._model.index(0, 0)) - # Automatic selection of the first item - first_index = None - if group_title is not None: - group_idx = self._model.index(0, 0) - if self._model.rowCount(group_idx) > 0: - first_index = self._model.index(0, 0, group_idx) - else: - if self._model.rowCount() > 0: - first_index = self._model.index(0, 0) - - if first_index and first_index.isValid(): - self.setCurrentIndex(first_index) - id_ = first_index.data(ROLE_ID) - if id_: - self._active_id = str(id_) - self.itemActivatedById.emit(str(id_)) - + selected_index = None + if previous_active_id: + selected_item = self._items_by_id.get(previous_active_id) + if selected_item: + selected_index = selected_item.index() + + if selected_index is None: + # Automatic selection of the first item + if group_title is not None: + group_idx = self._model.index(0, 0) + if self._model.rowCount(group_idx) > 0: + selected_index = self._model.index(0, 0, group_idx) + elif self._model.rowCount() > 0: + selected_index = self._model.index(0, 0) + + if selected_index and selected_index.isValid(): + selection_model = self.selectionModel() + if selection_model: + selection_model.setCurrentIndex( + selected_index, + QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows + ) + else: + self.setCurrentIndex(selected_index) + + selected_id = selected_index.data(ROLE_ID) + if selected_id is not None: + self._active_id = self._normalize_id(selected_id) + self.itemActivatedById.emit(str(selected_id)) + # If there is a group, ensure it is visible (didn't scroll out of view due to scrolling to child) if group_title is not None: self.updateGeometry() @@ -490,7 +519,7 @@ def set_items( def update_count(self, id: str, count: int) -> None: """Updates the count badge for a specific item.""" - item = self._items_by_id.get(id) + item = self._items_by_id.get(self._normalize_id(id)) if not item: return item.setData(int(count), ROLE_COUNT) @@ -500,11 +529,19 @@ def update_count(self, id: str, count: int) -> None: def set_selected(self, id: str) -> None: """Selects an item by its ID.""" - item = self._items_by_id.get(id) + normalized_id = self._normalize_id(id) + item = self._items_by_id.get(normalized_id) if not item: return - self.setCurrentIndex(item.index()) - self._active_id = id + selection_model = self.selectionModel() + if selection_model: + selection_model.setCurrentIndex( + item.index(), + QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows + ) + else: + self.setCurrentIndex(item.index()) + self._active_id = normalized_id self.scrollTo(item.index(), QAbstractItemView.PositionAtCenter) def get_selected_id(self) -> str | None: @@ -576,6 +613,73 @@ def _is_over_edit_button(self, pos: QPoint, index: QModelIndex) -> bool: def _on_theme_changed(self, theme_id: str) -> None: """Handles theme change events.""" self.viewport().update() + + def _on_current_changed(self, current: QModelIndex, previous: QModelIndex) -> None: + """Keeps active item id in sync with selection model changes.""" + if not current.isValid(): + return + if current.data(ROLE_IS_GROUP): + return + current_id = current.data(ROLE_ID) + if current_id: + self._active_id = str(current_id) + + def _remember_active_child_for_group(self, group_index: QModelIndex) -> None: + """Stores current child selection before group collapse.""" + if not group_index.isValid(): + return + + current_index = self.currentIndex() + if current_index.isValid() and current_index.parent() == group_index: + current_id = current_index.data(ROLE_ID) + if current_id: + self._active_id = str(current_id) + return + + selection_model = self.selectionModel() + if not selection_model: + return + + for selected_index in selection_model.selectedRows(0): + if selected_index.parent() == group_index: + selected_id = selected_index.data(ROLE_ID) + if selected_id: + self._active_id = str(selected_id) + return + + def _restore_active_selection_for_group(self, group_index: QModelIndex) -> None: + """Restores selected child in the expanded group if it was previously active.""" + if not group_index.isValid() or not self._active_id: + return + + item = self._items_by_id.get(self._normalize_id(self._active_id)) + if not item: + return + + item_index = item.index() + if not item_index.isValid() or item_index.parent() != group_index: + return + + selection_model = self.selectionModel() + if selection_model: + selection_model.setCurrentIndex( + item_index, + QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows + ) + else: + self.setCurrentIndex(item_index) + + def _on_group_collapsed(self, index: QModelIndex) -> None: + """Remembers selected child when a group gets collapsed.""" + if not index.isValid() or not index.data(ROLE_IS_GROUP): + return + self._remember_active_child_for_group(index) + + def _on_group_expanded(self, index: QModelIndex) -> None: + """Restores selected child when a group gets expanded.""" + if not index.isValid() or not index.data(ROLE_IS_GROUP): + return + self._restore_active_selection_for_group(index) def mousePressEvent(self, event: QMouseEvent) -> None: """Handles mouse press events.""" @@ -586,18 +690,12 @@ def mousePressEvent(self, event: QMouseEvent) -> None: if is_group: # Intercept click on group: only collapse/expand + self._remember_active_child_for_group(index) self.setUpdatesEnabled(False) if self.isExpanded(index): self.collapse(index) else: self.expand(index) - # Restore selection if the active item is inside this group - if self._active_id: - item = self._items_by_id.get(self._active_id) - if item: - idx = item.index() - if idx.isValid() and idx.parent() == index: - self.setCurrentIndex(idx) self.setUpdatesEnabled(True) event.accept() return @@ -672,4 +770,4 @@ def _on_clicked(self, index: QModelIndex) -> None: id_ = index.data(ROLE_ID) if id_: self._active_id = str(id_) - self.itemActivatedById.emit(str(id_)) \ No newline at end of file + self.itemActivatedById.emit(str(id_)) diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index 8f9e3ad..56f1754 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,13 +17,12 @@ RELEASE_NOTES = { - "0.2.17": [ + "0.2.18": [ { - "title": "Общее повышение стабильности", + "title": "Стабильность выделения в боковой панели", "items": [ - "Повышена общая стабильность сессий, настроек, уведомлений и обновлений.", - "Добавлена более безопасная обработка дубликатов файлов и неполных данных сессии в edge-case сценариях.", - "Снижен риск сбоев интерфейса при частых действиях (уведомления, переключение темы, запуск установщика обновления).", + "Исправлена ошибка, из-за которой при сворачивании и разворачивании групп в боковой панели иногда визуально пропадала подсветка выбранного Отдела или Категории.", + "После разворачивания группы выбранный пункт теперь восстанавливается стабильнее.", ], }, ],