From aab9e7d2ae9686d00bc456d74e71fdcc5e0dfc42 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Tue, 17 Mar 2026 11:53:13 +0300 Subject: [PATCH 01/15] fix(search-table-state): restore documents after clearing query --- README.md | 11 +++++------ README_RU.md | 11 +++++------ app.py | 2 +- modules/main/mvc/main_controller.py | 24 ++++++++++++++---------- tests/test_main.py | 14 ++++++++++++++ utils/whats_new_modal.py | 11 +++++------ 6 files changed, 44 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 52da745..d47072a 100644 --- a/README.md +++ b/README.md @@ -35,16 +35,15 @@ Allows authorized users to create, edit, and manage document pages. Support for --- -## 💡 What's New (v0.2.1) - Stability & Profile Improvements +## 💡 What's New (v0.2.2) - Search & Table Stability -This patch release improves reliability and profile editing behavior. +This patch release improves table refresh behavior after search input changes. ### 🛠 Improved -- Fixed a crash when opening the profile window for accounts without a filled display name. -- Improved reliability of profile data updates after backend deployment updates. -- Improved handling of empty values in document and page names. -- Improved stability of search and document loading in edge cases. +- Fixed an intermittent issue where clearing the search field did not restore all documents in the selected category. +- Fixed a state sync issue where switching categories or departments could leave the table empty after clearing search. +- Improved handling of whitespace-only input in the search field. --- diff --git a/README_RU.md b/README_RU.md index 7797807..6fc4cac 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,16 +35,15 @@ --- -## 💡 Что нового (v0.2.1) - Стабильность и профиль +## 💡 Что нового (v0.2.2) - Стабильность поиска и таблицы -Это патч-обновление повышает надежность и улучшает поведение при работе с профилем. +Это патч-обновление улучшает поведение таблицы после изменений в строке поиска. ### 🛠 Улучшено -- Исправлен сбой при открытии профиля для аккаунтов без заполненного отображаемого имени. -- Повышена надежность обновления данных профиля после обновлений backend-сервиса. -- Улучшена обработка пустых значений в названиях документов и страниц. -- Повышена стабильность поиска и загрузки документов в пограничных сценариях. +- Исправлена периодическая проблема, при которой после очистки поиска не возвращались все документы выбранной категории. +- Исправлена рассинхронизация состояния, из-за которой после очистки поиска таблица могла оставаться пустой при переключении категорий или отделов. +- Улучшена обработка строки поиска, состоящей только из пробелов. --- diff --git a/app.py b/app.py index d277c84..85befea 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.2.1" +APP_VERSION = "0.2.2" class Application: """ diff --git a/modules/main/mvc/main_controller.py b/modules/main/mvc/main_controller.py index 964f014..1615ae0 100644 --- a/modules/main/mvc/main_controller.py +++ b/modules/main/mvc/main_controller.py @@ -98,10 +98,14 @@ def __init__( # Controller Handlers # ==================== + def _normalized_search_text(self) -> str: + """Returns trimmed search text to avoid treating whitespace as active search.""" + return (self.view.get_search_text() or "").strip() + def _on_search_lineedit_text_changed(self) -> None: """Handles the search line edit text change.""" - search_text = self.view.get_search_text() + search_text = self._normalized_search_text() # If empty, update immediately without delay if not search_text: @@ -129,7 +133,7 @@ def _on_search_filters_changed(self, checked: bool) -> None: def _perform_search(self) -> None: """Executes the search request after delay.""" - search_text = self.view.get_search_text() + search_text = self._normalized_search_text() filters = self.view.get_search_filters() if not search_text: return @@ -455,7 +459,7 @@ def _on_department_selected(self, selected, deselected) -> None: # With active search keep previous table until new results arrive. # This avoids a blank table if the first request after idle fails. - if self.view.get_search_text(): + if self._normalized_search_text(): self._on_search_lineedit_text_changed() else: # No search active: clear stale documents and load current category data. @@ -477,13 +481,13 @@ def _on_category_selected(self, selected, deselected) -> None: if cat_id is not None and cat_id != self.model.current_category_id: self.model.current_category_id = cat_id - if self.view.get_search_text(): + if self._normalized_search_text(): self._on_search_lineedit_text_changed() else: self._update_documents_list() else: self.model.current_category_id = None - if self.view.get_search_text(): + if self._normalized_search_text(): self._on_search_lineedit_text_changed() else: self._update_documents_list() @@ -691,7 +695,7 @@ def _on_table_scroll(self, value: int) -> None: if not at_bottom: return - if self.view.get_search_text(): + if self._normalized_search_text(): # Search results are held fully in memory — paginate client-side self._load_more_search_results() elif not self.is_loading and self.has_more: @@ -763,7 +767,7 @@ def _on_search_finished(self, data: list) -> None: # If the user cleared the search field while the worker was running, # discard the result — _update_documents_list already took over. - search_text = self.view.get_search_text() + search_text = self._normalized_search_text() if not search_text: return @@ -1018,7 +1022,7 @@ def _update_documents_list(self) -> None: def _load_more_documents(self) -> None: """Loads the next page of documents.""" # Do not load more if loading, no category selected, or SEARCH IS ACTIVE - if self.is_loading or self.model.current_category_id is None or self.view.get_search_text(): + if self.is_loading or self.model.current_category_id is None or self._normalized_search_text(): return self.is_loading = True @@ -1070,7 +1074,7 @@ def _on_load_more_finished(self, docs: list) -> None: self.search_results_all = [] # Prevent updating UI if search is active (stale request) - if self.view.get_search_text(): + if self._normalized_search_text(): return # With a large limit, we assume we've fetched all documents. @@ -1161,7 +1165,7 @@ def _update_app_data(self): # Trigger update manually after state is restored if dept_exists: - if self.view.get_search_text(): + if self._normalized_search_text(): # Perform search immediately, bypassing debounce timer self._perform_search() else: diff --git a/tests/test_main.py b/tests/test_main.py index cdbed17..55ea0ee 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -144,6 +144,20 @@ def test_load_more_documents(self, controller): assert kwargs['offset'] == 0 assert kwargs['limit'] == controller.limit + def test_load_more_documents_when_search_has_only_whitespace(self, controller): + """Whitespace-only search text should be treated as empty and must not block loading.""" + controller.model.current_category_id = 10 + controller.is_loading = False + controller.has_more = True + controller.offset = 0 + controller.view.get_search_text.return_value = " " + + with patch("modules.main.mvc.main_controller.APIWorker") as MockWorker: + controller._load_more_documents() + + assert controller.is_loading is True + MockWorker.assert_called_once() + def test_update_documents_list_keeps_previous_table_until_success(self, controller): """Reloading the table should not clear the old data before the new response arrives.""" controller.model.current_category_id = 10 diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index e5d9145..7c31705 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,14 +17,13 @@ RELEASE_NOTES = { - "0.2.1": [ + "0.2.2": [ { - "title": "Стабильность и профиль", + "title": "Стабильность поиска и таблицы", "items": [ - "Исправлен сбой при открытии профиля для аккаунтов без заполненного отображаемого имени.", - "Повышена надежность обновления данных профиля после обновлений backend-сервиса.", - "Улучшена обработка пустых значений в названиях документов и страниц.", - "Повышена стабильность поиска и загрузки документов в пограничных сценариях.", + "Исправлена периодическая проблема, при которой после очистки поиска не возвращались все документы выбранной категории.", + "Исправлена рассинхронизация состояния, из-за которой после очистки поиска таблица могла оставаться пустой при переключении категорий или отделов.", + "Улучшена обработка строки поиска, состоящей только из пробелов.", ], }, ], From b529f8ee7aafab41d60f5658c555120deffd59e9 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Tue, 17 Mar 2026 12:04:18 +0300 Subject: [PATCH 02/15] fix(config-loading): harden config parsing for empty and invalid yaml --- README.md | 10 +++++----- README_RU.md | 10 +++++----- app.py | 2 +- tests/test_utils.py | 18 ++++++++++++++++++ utils/file_utils.py | 13 +++++++++++-- utils/whats_new_modal.py | 10 +++++----- 6 files changed, 45 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index d47072a..fec41d5 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,15 @@ Allows authorized users to create, edit, and manage document pages. Support for --- -## 💡 What's New (v0.2.2) - Search & Table Stability +## 💡 What's New (v0.2.3) - Configuration Stability -This patch release improves table refresh behavior after search input changes. +This patch release improves application startup resilience with invalid config files. ### 🛠 Improved -- Fixed an intermittent issue where clearing the search field did not restore all documents in the selected category. -- Fixed a state sync issue where switching categories or departments could leave the table empty after clearing search. -- Improved handling of whitespace-only input in the search field. +- Hardened `config.yaml` parsing for empty configuration files. +- Added validation for invalid config root types to prevent startup/runtime crashes. +- Added test coverage for empty and malformed config structure scenarios. --- diff --git a/README_RU.md b/README_RU.md index 6fc4cac..1d2e69f 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,15 +35,15 @@ --- -## 💡 Что нового (v0.2.2) - Стабильность поиска и таблицы +## 💡 Что нового (v0.2.3) - Стабильность конфигурации -Это патч-обновление улучшает поведение таблицы после изменений в строке поиска. +Это патч-обновление повышает устойчивость запуска приложения при проблемах с файлом конфигурации. ### 🛠 Улучшено -- Исправлена периодическая проблема, при которой после очистки поиска не возвращались все документы выбранной категории. -- Исправлена рассинхронизация состояния, из-за которой после очистки поиска таблица могла оставаться пустой при переключении категорий или отделов. -- Улучшена обработка строки поиска, состоящей только из пробелов. +- Усилена обработка `config.yaml` при пустом содержимом. +- Добавлена проверка некорректного формата корневого узла конфигурации для предотвращения сбоев на старте и в рантайме. +- Добавлены тесты для пустой и структурно некорректной конфигурации. --- diff --git a/app.py b/app.py index 85befea..9b5bd11 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.2.2" +APP_VERSION = "0.2.3" class Application: """ diff --git a/tests/test_utils.py b/tests/test_utils.py index fec1950..743ee20 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -197,6 +197,24 @@ def test_load_config_yaml_error(self, tmp_path): config = load_config() assert config == {} + def test_load_config_empty_yaml_returns_empty_dict(self, tmp_path): + """Empty YAML should not return None to callers.""" + config_file = tmp_path / "config.yaml" + config_file.write_text("", encoding="utf-8") + + with patch("utils.file_utils.get_app_root", return_value=tmp_path): + config = load_config() + assert config == {} + + def test_load_config_non_mapping_root_returns_empty_dict(self, tmp_path): + """Only mapping root is supported for config structure.""" + config_file = tmp_path / "config.yaml" + config_file.write_text("- item1\n- item2\n", encoding="utf-8") + + with patch("utils.file_utils.get_app_root", return_value=tmp_path): + config = load_config() + assert config == {} + def test_read_json_success(self, tmp_path): """Test successful reading of a JSON file.""" diff --git a/utils/file_utils.py b/utils/file_utils.py index 99975d4..64bea33 100644 --- a/utils/file_utils.py +++ b/utils/file_utils.py @@ -13,8 +13,17 @@ def load_config() -> dict: """ try: config_path = get_app_root() / "config.yaml" - with open(config_path, 'r') as file: - return yaml.safe_load(file) + with open(config_path, "r", encoding="utf-8") as file: + data = yaml.safe_load(file) + if data is None: + return {} + if not isinstance(data, dict): + logging.error( + "Configuration file has invalid root type: expected mapping, got %s.", + type(data).__name__, + ) + return {} + return data except FileNotFoundError: logging.error("Configuration file not found.", exc_info=True) return {} diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index 7c31705..4a22630 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,13 +17,13 @@ RELEASE_NOTES = { - "0.2.2": [ + "0.2.3": [ { - "title": "Стабильность поиска и таблицы", + "title": "Стабильность конфигурации", "items": [ - "Исправлена периодическая проблема, при которой после очистки поиска не возвращались все документы выбранной категории.", - "Исправлена рассинхронизация состояния, из-за которой после очистки поиска таблица могла оставаться пустой при переключении категорий или отделов.", - "Улучшена обработка строки поиска, состоящей только из пробелов.", + "Усилена обработка config.yaml при пустом содержимом.", + "Добавлена защита от некорректного формата конфигурации для предотвращения сбоев на старте и в рантайме.", + "Добавлены проверки для пустой и структурно некорректной конфигурации.", ], }, ], From 25c3e6ea5d58e748821d9698d3a90af05ab00603 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Tue, 17 Mar 2026 12:06:14 +0300 Subject: [PATCH 03/15] fix(update-flow): handle download cancelation and cleanup consistently --- README.md | 10 ++++---- README_RU.md | 10 ++++---- app.py | 2 +- core/updater.py | 11 +++++++++ tests/test_updater.py | 49 +++++++++++++++++++++++++++++++++++++++- utils/whats_new_modal.py | 10 ++++---- 6 files changed, 75 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index fec41d5..e66909c 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,15 @@ Allows authorized users to create, edit, and manage document pages. Support for --- -## 💡 What's New (v0.2.3) - Configuration Stability +## 💡 What's New (v0.2.4) - Update Flow Stability -This patch release improves application startup resilience with invalid config files. +This patch release improves reliability of the in-app update download flow. ### 🛠 Improved -- Hardened `config.yaml` parsing for empty configuration files. -- Added validation for invalid config root types to prevent startup/runtime crashes. -- Added test coverage for empty and malformed config structure scenarios. +- Added explicit cancellation handling for update downloads. +- Fixed update manager state cleanup after canceled or failed download. +- Added tests for cancellation behavior and manager cleanup logic. --- diff --git a/README_RU.md b/README_RU.md index 1d2e69f..cc09052 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,15 +35,15 @@ --- -## 💡 Что нового (v0.2.3) - Стабильность конфигурации +## 💡 Что нового (v0.2.4) - Стабильность обновления -Это патч-обновление повышает устойчивость запуска приложения при проблемах с файлом конфигурации. +Это патч-обновление повышает надежность встроенного процесса скачивания обновлений. ### 🛠 Улучшено -- Усилена обработка `config.yaml` при пустом содержимом. -- Добавлена проверка некорректного формата корневого узла конфигурации для предотвращения сбоев на старте и в рантайме. -- Добавлены тесты для пустой и структурно некорректной конфигурации. +- Добавлена явная обработка отмены скачивания обновления. +- Исправлена очистка состояния менеджера обновления после отмены и ошибок скачивания. +- Добавлены тесты для сценариев отмены скачивания и корректного завершения состояния менеджера. --- diff --git a/app.py b/app.py index 9b5bd11..69329a2 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.2.3" +APP_VERSION = "0.2.4" class Application: """ diff --git a/core/updater.py b/core/updater.py index 020b95f..d8f5703 100644 --- a/core/updater.py +++ b/core/updater.py @@ -78,6 +78,7 @@ class UpdateDownloader(QThread): """Thread for downloading the update file.""" progress = pyqtSignal(int, int) # downloaded_bytes, total_bytes finished = pyqtSignal(str) # путь к скачанному файлу + canceled = pyqtSignal() error = pyqtSignal(str) def __init__(self, url: str, expected_size: int = 0): @@ -121,6 +122,7 @@ def run(self): os.remove(path) except OSError: pass + self.canceled.emit() return if chunk: @@ -193,6 +195,7 @@ def _start_download(self, url, size): self._downloader = UpdateDownloader(url, size) self._downloader.progress.connect(self.progress_dialog.set_progress) self._downloader.finished.connect(self._on_download_finished) + self._downloader.canceled.connect(self._on_download_canceled) self._downloader.error.connect(self._on_download_error) self.progress_dialog.canceled.connect(self._downloader.stop) @@ -201,6 +204,7 @@ def _start_download(self, url, size): def _on_download_error(self, error_msg): self.progress_dialog.close() + self._downloader = None # If main window is not created yet (check on startup), NotificationService won't work if NotificationService().main_window: NotificationService().show_toast("error", "Ошибка", f"Ошибка скачивания: {error_msg}") @@ -208,6 +212,12 @@ def _on_download_error(self, error_msg): # Use standard QMessageBox as a fallback QMessageBox.critical(self.parent_widget, "Ошибка", f"Ошибка скачивания:\n{error_msg}") + def _on_download_canceled(self): + self.progress_dialog.close() + self._downloader = None + if NotificationService().main_window: + NotificationService().show_toast("info", "Обновление", "Скачивание обновления отменено.") + def _on_download_finished(self, file_path): # Force set 100% so user sees completion self.progress_dialog.set_progress(100, 100) @@ -216,6 +226,7 @@ def _on_download_finished(self, file_path): def _show_install_confirmation(self, file_path): self.progress_dialog.close() + self._downloader = None dialog = InstallConfirmDialog(self.parent_widget) if dialog.exec_() == QDialog.Accepted: self._install_update(file_path) diff --git a/tests/test_updater.py b/tests/test_updater.py index 35d6c21..5ad5ef7 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -1,7 +1,7 @@ import pytest import requests from unittest.mock import Mock, patch, MagicMock -from core.updater import GitHubUpdateChecker, UpdateDownloader +from core.updater import GitHubUpdateChecker, UpdateDownloader, UpdateManager @@ -147,3 +147,50 @@ def test_download_error(self): downloader.error.emit.assert_called_once() assert "Fail" in downloader.error.emit.call_args[0][0] + + def test_download_canceled_emits_signal(self, tmp_path): + """Stopping download should emit canceled and not emit finished.""" + url = "http://test.com/file.exe" + downloader = UpdateDownloader(url, 10) + downloader.finished = Mock() + downloader.progress = Mock() + downloader.error = Mock() + downloader.canceled = Mock() + + with patch("requests.get") as mock_get: + mock_response = Mock() + mock_response.headers = {"content-length": "10"} + + def chunks(): + yield b"12345" + downloader.stop() + yield b"67890" + + mock_response.iter_content.return_value = chunks() + mock_response.raise_for_status = Mock() + mock_get.return_value.__enter__.return_value = mock_response + + target_file = tmp_path / "update.exe" + with patch("core.updater.tempfile.mkstemp", return_value=(123, str(target_file))), \ + patch("core.updater.os.close"): + downloader.run() + + downloader.canceled.emit.assert_called_once() + downloader.finished.emit.assert_not_called() + + +class TestUpdateManager: + def test_on_download_canceled_cleans_state(self): + manager = UpdateManager(current_version="0.2.0", repo_name="owner/repo") + manager.progress_dialog = Mock() + manager._downloader = Mock() + + with patch("core.updater.NotificationService") as mock_notification_service: + service_instance = mock_notification_service.return_value + service_instance.main_window = Mock() + + manager._on_download_canceled() + + manager.progress_dialog.close.assert_called_once() + assert manager._downloader is None + service_instance.show_toast.assert_called_once() diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index 4a22630..30eb0ce 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,13 +17,13 @@ RELEASE_NOTES = { - "0.2.3": [ + "0.2.4": [ { - "title": "Стабильность конфигурации", + "title": "Стабильность обновления", "items": [ - "Усилена обработка config.yaml при пустом содержимом.", - "Добавлена защита от некорректного формата конфигурации для предотвращения сбоев на старте и в рантайме.", - "Добавлены проверки для пустой и структурно некорректной конфигурации.", + "Добавлена явная обработка отмены скачивания обновления.", + "Исправлена очистка состояния менеджера обновления после отмены и ошибок скачивания.", + "Добавлены проверки для сценариев отмены скачивания и корректного завершения состояния менеджера.", ], }, ], From 8ebddfcb849d40b744b6f71bb0251e66062b0134 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Tue, 17 Mar 2026 12:07:57 +0300 Subject: [PATCH 04/15] fix(settings-schema): migrate legacy search filters to current format --- README.md | 10 +++--- README_RU.md | 10 +++--- app.py | 2 +- tests/test_settings_manager.py | 65 ++++++++++++++++++++++++++++++++++ utils/settings_manager.py | 51 +++++++++++++++++++++++--- utils/whats_new_modal.py | 10 +++--- 6 files changed, 127 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index e66909c..e304176 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,15 @@ Allows authorized users to create, edit, and manage document pages. Support for --- -## 💡 What's New (v0.2.4) - Update Flow Stability +## 💡 What's New (v0.2.5) - Settings Compatibility -This patch release improves reliability of the in-app update download flow. +This patch release improves compatibility and reliability of saved search settings. ### 🛠 Improved -- Added explicit cancellation handling for update downloads. -- Fixed update manager state cleanup after canceled or failed download. -- Added tests for cancellation behavior and manager cleanup logic. +- Migrated legacy search filter settings to the current format automatically. +- Unified default search filter schema with the current main window behavior. +- Added tests for legacy-to-current settings migration and schema consistency. --- diff --git a/README_RU.md b/README_RU.md index cc09052..6a3ff30 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,15 +35,15 @@ --- -## 💡 Что нового (v0.2.4) - Стабильность обновления +## 💡 Что нового (v0.2.5) - Совместимость настроек -Это патч-обновление повышает надежность встроенного процесса скачивания обновлений. +Это патч-обновление повышает совместимость и надежность сохранённых настроек поиска. ### 🛠 Улучшено -- Добавлена явная обработка отмены скачивания обновления. -- Исправлена очистка состояния менеджера обновления после отмены и ошибок скачивания. -- Добавлены тесты для сценариев отмены скачивания и корректного завершения состояния менеджера. +- Добавлена автоматическая миграция старого формата фильтров поиска в текущий формат. +- Унифицирована схема фильтров по умолчанию с текущим поведением главного окна. +- Добавлены тесты на миграцию настроек и согласованность схемы. --- diff --git a/app.py b/app.py index 69329a2..2920c75 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.2.4" +APP_VERSION = "0.2.5" class Application: """ diff --git a/tests/test_settings_manager.py b/tests/test_settings_manager.py index 9eeedab..befe329 100644 --- a/tests/test_settings_manager.py +++ b/tests/test_settings_manager.py @@ -1,3 +1,5 @@ +import json + from unittest.mock import MagicMock, patch from utils.settings_manager import SettingsManager @@ -11,3 +13,66 @@ def test_default_settings_include_whats_new_version(self): manager = SettingsManager(user_id=1) assert manager.get_default_settings()["last_seen_whats_new_version"] == "" + + def test_default_search_filters_schema(self): + with patch("utils.settings_manager.get_app_data_dir") as mock_app_dir: + mock_app_dir.return_value = MagicMock() + manager = SettingsManager(user_id=1) + + assert manager.get_default_settings()["search_filters"] == { + "include_pages": True, + "search_by_name": True, + "search_by_code": True, + "exact_match": False, + } + + def test_load_settings_migrates_legacy_search_filters(self, tmp_path): + settings_file = tmp_path / "Profiles" / "user_settings_1.json" + settings_file.parent.mkdir(parents=True, exist_ok=True) + settings_file.write_text( + json.dumps( + { + "search_filters": { + "search_in_pages": False, + "search_field": "code", + "match_mode": "exact", + } + } + ), + encoding="utf-8", + ) + + with patch("utils.settings_manager.get_app_data_dir", return_value=tmp_path): + manager = SettingsManager(user_id=1) + assert manager.get_setting("search_filters") == { + "include_pages": False, + "search_by_name": False, + "search_by_code": True, + "exact_match": True, + } + + def test_load_settings_keeps_current_schema(self, tmp_path): + settings_file = tmp_path / "Profiles" / "user_settings_1.json" + settings_file.parent.mkdir(parents=True, exist_ok=True) + settings_file.write_text( + json.dumps( + { + "search_filters": { + "include_pages": False, + "search_by_name": True, + "search_by_code": False, + "exact_match": True, + } + } + ), + encoding="utf-8", + ) + + with patch("utils.settings_manager.get_app_data_dir", return_value=tmp_path): + manager = SettingsManager(user_id=1) + assert manager.get_setting("search_filters") == { + "include_pages": False, + "search_by_name": True, + "search_by_code": False, + "exact_match": True, + } diff --git a/utils/settings_manager.py b/utils/settings_manager.py index fdb3786..5cec1fc 100644 --- a/utils/settings_manager.py +++ b/utils/settings_manager.py @@ -32,12 +32,47 @@ def get_default_settings(self) -> Dict[str, Any]: "theme": 1, # 0 for light, 1 for dark "last_seen_whats_new_version": "", "search_filters": { - "search_in_pages": True, - "search_field": "name", - "match_mode": "contains" + "include_pages": True, + "search_by_name": True, + "search_by_code": True, + "exact_match": False, } } + @staticmethod + def _normalize_search_filters(filters: Any, default_filters: Dict[str, Any]) -> Dict[str, Any]: + """Normalizes legacy and current search filter schemas to the current format.""" + normalized = dict(default_filters) + if not isinstance(filters, dict): + return normalized + + # Legacy schema migration. + if "search_in_pages" in filters: + normalized["include_pages"] = bool(filters.get("search_in_pages", normalized["include_pages"])) + + if "search_field" in filters: + field = str(filters.get("search_field", "")).strip().lower() + if field == "name": + normalized["search_by_name"] = True + normalized["search_by_code"] = False + elif field == "code": + normalized["search_by_name"] = False + normalized["search_by_code"] = True + elif field == "both": + normalized["search_by_name"] = True + normalized["search_by_code"] = True + + if "match_mode" in filters: + mode = str(filters.get("match_mode", "")).strip().lower() + normalized["exact_match"] = mode in {"exact", "equals", "strict"} + + # Current schema overrides. + for key in ("include_pages", "search_by_name", "search_by_code", "exact_match"): + if key in filters: + normalized[key] = bool(filters[key]) + + return normalized + def load_settings(self) -> Dict[str, Any]: """ Loads settings from the user's settings file. @@ -51,10 +86,16 @@ def load_settings(self) -> Dict[str, Any]: try: with open(self.settings_file, "r", encoding="utf-8") as f: loaded_settings = json.load(f) - + # Merge loaded settings with defaults to ensure all keys are present settings = default_settings.copy() - settings.update(loaded_settings) + if isinstance(loaded_settings, dict): + settings.update(loaded_settings) + + settings["search_filters"] = self._normalize_search_filters( + settings.get("search_filters"), + default_settings.get("search_filters", {}), + ) return settings except (json.JSONDecodeError, IOError) as e: diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index 30eb0ce..1652fac 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,13 +17,13 @@ RELEASE_NOTES = { - "0.2.4": [ + "0.2.5": [ { - "title": "Стабильность обновления", + "title": "Совместимость настроек", "items": [ - "Добавлена явная обработка отмены скачивания обновления.", - "Исправлена очистка состояния менеджера обновления после отмены и ошибок скачивания.", - "Добавлены проверки для сценариев отмены скачивания и корректного завершения состояния менеджера.", + "Добавлена автоматическая миграция старого формата фильтров поиска в текущий формат.", + "Унифицирована схема фильтров по умолчанию с текущим поведением главного окна.", + "Добавлены проверки миграции настроек и согласованности схемы.", ], }, ], From 41bb933ac914910835c1f7513b8443597ad069fd Mon Sep 17 00:00:00 2001 From: PN Tech Date: Tue, 17 Mar 2026 12:12:08 +0300 Subject: [PATCH 05/15] fix(error-handling): improve http detail extraction and fallback messages --- README.md | 10 +++++----- README_RU.md | 10 +++++----- app.py | 2 +- tests/test_utils.py | 19 +++++++++++++++++++ utils/error_messages.py | 27 +++++++++++++++++++++++---- utils/whats_new_modal.py | 10 +++++----- 6 files changed, 58 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index e304176..50b89c3 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,15 @@ Allows authorized users to create, edit, and manage document pages. Support for --- -## 💡 What's New (v0.2.5) - Settings Compatibility +## 💡 What's New (v0.2.6) - Error Handling Quality -This patch release improves compatibility and reliability of saved search settings. +This patch release improves user-facing HTTP error messages and fallback behavior. ### 🛠 Improved -- Migrated legacy search filter settings to the current format automatically. -- Unified default search filter schema with the current main window behavior. -- Added tests for legacy-to-current settings migration and schema consistency. +- Improved extraction of API error details from different response formats. +- Added robust fallback for non-JSON HTTP responses. +- Added tests for list-based validation details and generic HTTP fallback messages. --- diff --git a/README_RU.md b/README_RU.md index 6a3ff30..148a293 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,15 +35,15 @@ --- -## 💡 Что нового (v0.2.5) - Совместимость настроек +## 💡 Что нового (v0.2.6) - Качество обработки ошибок -Это патч-обновление повышает совместимость и надежность сохранённых настроек поиска. +Это патч-обновление улучшает пользовательские сообщения об HTTP-ошибках и fallback-поведение. ### 🛠 Улучшено -- Добавлена автоматическая миграция старого формата фильтров поиска в текущий формат. -- Унифицирована схема фильтров по умолчанию с текущим поведением главного окна. -- Добавлены тесты на миграцию настроек и согласованность схемы. +- Улучшено извлечение деталей API-ошибок из разных форматов ответа. +- Добавлен устойчивый fallback для HTTP-ответов без JSON. +- Добавлены тесты для валидационных ошибок списком и универсального fallback-сообщения. --- diff --git a/app.py b/app.py index 2920c75..c249c63 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.2.5" +APP_VERSION = "0.2.6" class Application: """ diff --git a/tests/test_utils.py b/tests/test_utils.py index 743ee20..9c39e55 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -113,6 +113,25 @@ def test_http_errors(self, status_code, json_data, expected_part): exception = requests.exceptions.HTTPError(response=mock_response) assert expected_part in get_friendly_error_message(exception) + def test_http_error_detail_list_is_rendered(self): + mock_response = Mock(spec=requests.Response) + mock_response.status_code = 422 + mock_response.json.return_value = {"detail": ["field A invalid", "field B required"]} + + exception = requests.exceptions.HTTPError(response=mock_response) + message = get_friendly_error_message(exception) + assert "field A invalid" in message + assert "field B required" in message + + def test_http_error_non_json_response_fallback(self): + mock_response = Mock(spec=requests.Response) + mock_response.status_code = 418 + mock_response.json.side_effect = ValueError("not json") + + exception = requests.exceptions.HTTPError(response=mock_response) + message = get_friendly_error_message(exception) + assert "HTTP 418" in message + class TestValidators: diff --git a/utils/error_messages.py b/utils/error_messages.py index a5d65f8..b5dc192 100644 --- a/utils/error_messages.py +++ b/utils/error_messages.py @@ -1,5 +1,23 @@ import requests + +def _extract_http_detail(response) -> str | None: + """Extracts API error detail from JSON response payload.""" + try: + payload = response.json() + except ValueError: + return None + + detail = payload.get("detail") if isinstance(payload, dict) else payload + if detail is None: + return None + if isinstance(detail, str): + return detail + if isinstance(detail, list): + return "; ".join(str(item) for item in detail if item is not None) or None + return str(detail) + + def get_friendly_error_message(exception: Exception) -> str: """ Translates exceptions into user-friendly Russian messages. @@ -22,10 +40,7 @@ def get_friendly_error_message(exception: Exception) -> str: status_code = exception.response.status_code # Try to get detail from JSON response - try: - detail = exception.response.json().get("detail") - except: - detail = None + detail = _extract_http_detail(exception.response) if status_code == 400: return f"Ошибка запроса: {detail}" if detail else "Некорректный запрос." @@ -50,5 +65,9 @@ def get_friendly_error_message(exception: Exception) -> str: if status_code >= 500: return f"Внутренняя ошибка сервера ({status_code}). Попробуйте позже." + + if detail: + return f"HTTP {status_code}: {detail}" + return f"Ошибка запроса (HTTP {status_code})." return str(exception) diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index 1652fac..04049fc 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,13 +17,13 @@ RELEASE_NOTES = { - "0.2.5": [ + "0.2.6": [ { - "title": "Совместимость настроек", + "title": "Качество обработки ошибок", "items": [ - "Добавлена автоматическая миграция старого формата фильтров поиска в текущий формат.", - "Унифицирована схема фильтров по умолчанию с текущим поведением главного окна.", - "Добавлены проверки миграции настроек и согласованности схемы.", + "Улучшено извлечение деталей API-ошибок из разных форматов ответа.", + "Добавлен устойчивый fallback для HTTP-ответов без JSON.", + "Добавлены проверки для валидационных ошибок списком и универсального fallback-сообщения.", ], }, ], From 4e998bbfae42c1f5599d00fd2a0d0926bd458eb2 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Tue, 17 Mar 2026 12:35:26 +0300 Subject: [PATCH 06/15] fix(main-stability): prevent editor open crash and harden token lookup --- README.md | 12 ++++-------- README_RU.md | 12 ++++-------- app.py | 2 +- modules/main/mvc/main_controller.py | 10 +++++++--- modules/main/mvc/main_model.py | 7 ++++--- tests/test_main.py | 13 +++++++++++++ tests/test_main_model.py | 14 ++++++++++++++ utils/whats_new_modal.py | 10 +++++----- 8 files changed, 52 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 50b89c3..04a661b 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,11 @@ Allows authorized users to create, edit, and manage document pages. Support for --- -## 💡 What's New (v0.2.6) - Error Handling Quality +## 🛠 What's New (v0.2.7) -This patch release improves user-facing HTTP error messages and fallback behavior. - -### 🛠 Improved - -- Improved extraction of API error details from different response formats. -- Added robust fallback for non-JSON HTTP responses. -- Added tests for list-based validation details and generic HTTP fallback messages. +- Opening a document is now more stable if data loading is temporarily unavailable. +- Error handling in this flow is now clearer and less disruptive. +- Session recovery in edge cases is more reliable after restarts. --- diff --git a/README_RU.md b/README_RU.md index 148a293..cf4cdf9 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,15 +35,11 @@ --- -## 💡 Что нового (v0.2.6) - Качество обработки ошибок +## 🛠 Что нового (v0.2.7) -Это патч-обновление улучшает пользовательские сообщения об HTTP-ошибках и fallback-поведение. - -### 🛠 Улучшено - -- Улучшено извлечение деталей API-ошибок из разных форматов ответа. -- Добавлен устойчивый fallback для HTTP-ответов без JSON. -- Добавлены тесты для валидационных ошибок списком и универсального fallback-сообщения. +- Открытие документа стало стабильнее, даже если загрузка данных временно недоступна. +- Сообщения об ошибках в этом сценарии стали понятнее и менее навязчивыми. +- Восстановление сессии в редких сбойных ситуациях после перезапуска работает надёжнее. --- diff --git a/app.py b/app.py index c249c63..1115db8 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.2.6" +APP_VERSION = "0.2.7" class Application: """ diff --git a/modules/main/mvc/main_controller.py b/modules/main/mvc/main_controller.py index 1615ae0..43af542 100644 --- a/modules/main/mvc/main_controller.py +++ b/modules/main/mvc/main_controller.py @@ -545,9 +545,13 @@ def _on_edit_button_clicked(self) -> None: logger.error(f"Error fetching document details: {e}") # Get selected document pages list - pages = self.model.get_document_pages( - document_id=document_id - ) + try: + pages = self.model.get_document_pages( + document_id=document_id + ) + except Exception as e: + self._handle_error(e, "Ошибка загрузки документа") + return # Show document editor window self.editor_window = EditorWindow( diff --git a/modules/main/mvc/main_model.py b/modules/main/mvc/main_model.py index c0afe35..2136bd5 100644 --- a/modules/main/mvc/main_model.py +++ b/modules/main/mvc/main_model.py @@ -342,11 +342,12 @@ def _get_user_token(self) -> str | None: return None last_logged = read_json(self.LOCAL_DIR_LAST_LOGGED) - - if not last_logged: + if not isinstance(last_logged, dict): return None - user_id = last_logged["user_id"] + user_id = last_logged.get("user_id") + if user_id in (None, ""): + return None access_token = keyring.get_password( service_name="Documents Exp", diff --git a/tests/test_main.py b/tests/test_main.py index 55ea0ee..f1438fe 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -298,3 +298,16 @@ def test_search_task_handles_invalid_virtual_category_id(self, controller): group_id=None, filters={}, ) + + def test_edit_button_handles_pages_loading_error(self, controller): + """Editor opening should fail safely when pages loading raises.""" + controller.model.selected_document = (55, False) + controller.model.get_document.return_value = {"id": 55} + controller.model.get_document_pages.side_effect = RuntimeError("pages failed") + + with patch.object(controller, "_handle_error") as mock_handle_error, \ + patch("modules.main.mvc.main_controller.EditorWindow") as MockEditor: + controller._on_edit_button_clicked() + + mock_handle_error.assert_called_once() + MockEditor.assert_not_called() diff --git a/tests/test_main_model.py b/tests/test_main_model.py index d5c3c22..f147af4 100644 --- a/tests/test_main_model.py +++ b/tests/test_main_model.py @@ -32,3 +32,17 @@ def test_normalize_user_data_keeps_flat_payload(self): flat = {"id": 1, "email": "flat@x.test"} assert MainModel._normalize_user_data(flat) == flat + def test_get_user_token_returns_none_for_non_dict_last_logged(self): + model = self._build_model() + + with patch("modules.main.mvc.main_model.read_json", return_value=[]): + assert model._get_user_token() is None + + def test_get_user_token_returns_none_for_missing_user_id(self): + model = self._build_model() + + with patch("modules.main.mvc.main_model.read_json", return_value={}), \ + patch("modules.main.mvc.main_model.keyring.get_password") as mock_get_password: + assert model._get_user_token() is None + mock_get_password.assert_not_called() + diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index 04049fc..b027a74 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,13 +17,13 @@ RELEASE_NOTES = { - "0.2.6": [ + "0.2.7": [ { - "title": "Качество обработки ошибок", + "title": "Обновление стабильности", "items": [ - "Улучшено извлечение деталей API-ошибок из разных форматов ответа.", - "Добавлен устойчивый fallback для HTTP-ответов без JSON.", - "Добавлены проверки для валидационных ошибок списком и универсального fallback-сообщения.", + "Открытие документа стало стабильнее, даже если загрузка данных временно недоступна.", + "Сообщения об ошибках в этом сценарии стали понятнее и менее навязчивыми.", + "Восстановление сессии в редких сбойных ситуациях после перезапуска работает надёжнее.", ], }, ], From a89b47a22f4dc5eb60436493fdd050dfa158d4b0 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Tue, 17 Mar 2026 12:38:59 +0300 Subject: [PATCH 07/15] fix(auth-stability): harden validation state and confirmation cancel flows --- README.md | 8 ++--- README_RU.md | 8 ++--- app.py | 2 +- modules/auth/mvc/auth_controller.py | 19 +++++++++++ tests/test_auth.py | 51 ++++++++++++++++++++++++++++- utils/whats_new_modal.py | 10 +++--- 6 files changed, 83 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 04a661b..3751d74 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.7) +## 🛠 What's New (v0.2.8) -- Opening a document is now more stable if data loading is temporarily unavailable. -- Error handling in this flow is now clearer and less disruptive. -- Session recovery in edge cases is more reliable after restarts. +- Login and account recovery forms now react more reliably to invalid input. +- Action buttons are disabled immediately when entered data is not valid. +- If email-code confirmation is canceled, the flow now stops safely without extra error popups. --- diff --git a/README_RU.md b/README_RU.md index cf4cdf9..2f76461 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,11 +35,11 @@ --- -## 🛠 Что нового (v0.2.7) +## 🛠 Что нового (v0.2.8) -- Открытие документа стало стабильнее, даже если загрузка данных временно недоступна. -- Сообщения об ошибках в этом сценарии стали понятнее и менее навязчивыми. -- Восстановление сессии в редких сбойных ситуациях после перезапуска работает надёжнее. +- Формы входа и восстановления аккаунта теперь стабильнее реагируют на невалидный ввод. +- Кнопки действий сразу блокируются, если введённые данные не проходят проверку. +- При отмене ввода кода подтверждения процесс теперь корректно завершается без лишних ошибок. --- diff --git a/app.py b/app.py index 1115db8..9aa7612 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.2.7" +APP_VERSION = "0.2.8" class Application: """ diff --git a/modules/auth/mvc/auth_controller.py b/modules/auth/mvc/auth_controller.py index 5a19bdd..337cddf 100644 --- a/modules/auth/mvc/auth_controller.py +++ b/modules/auth/mvc/auth_controller.py @@ -154,6 +154,13 @@ def signup(self, data: dict, email: str, password: str) -> None: message=f"Код подтверждения отправлен на {email}" ) code = EmailConfirmDialog.get_code(parent=self.auth_window) + if not code: + NotificationService().show_toast( + notification_type="info", + title="Подтверждение отменено", + message="Регистрация не завершена: код подтверждения не введён." + ) + return # Create success callback success_cb = lambda data: self.signup_user(user_data=data) @@ -228,6 +235,13 @@ def open_email_confirm_modal_window(self, data, email: str) -> None: """ logging.info(f"Reset password data received: {data}") code = EmailConfirmDialog.get_code(parent=self.auth_window) + if not code: + NotificationService().show_toast( + notification_type="info", + title="Подтверждение отменено", + message="Сброс пароля не завершён: код подтверждения не введён." + ) + return # Create success callback success_cb = lambda data: self.switch_to_reset_password_page(data=data) @@ -328,10 +342,12 @@ def on_login_page_lineedits_changed(self) -> None: # Validate email if not self.field_validator.validate_email(email=email): + self.view.login_page.update_submit_button_state(state=False) return # Validate password if not self.field_validator.validate_password(password=password): + self.view.login_page.update_submit_button_state(state=False) return # Defining login button state (True if both lineedits has text, else False) @@ -400,10 +416,12 @@ def on_signup_page_lineedits_changed(self) -> None: # Validate email if not self.field_validator.validate_email(email=email): + self.view.signup_page.update_submit_button_state(state=False) return # Validate password if not self.field_validator.validate_password(password=password): + self.view.signup_page.update_submit_button_state(state=False) return # Check password matching @@ -491,6 +509,7 @@ def on_reset_password_page_lineedits_changed(self) -> None: # Validate password if not self.field_validator.validate_password(password=password): + self.view.forgot_page_reset_password.update_submit_button_state(state=False) return # Check password matching diff --git a/tests/test_auth.py b/tests/test_auth.py index c77ca76..446056d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -215,4 +215,53 @@ def test_validation_login_invalid(self, controller): controller.view.login_page.get_email.return_value = "invalid" controller.on_login_page_lineedits_changed() - controller.view.login_page.update_submit_button_state.assert_not_called() + controller.view.login_page.update_submit_button_state.assert_called_once_with(state=False) + + def test_validation_login_invalid_password_disables_submit(self, controller): + controller.field_validator.validate_email.return_value = True + controller.field_validator.validate_password.return_value = False + controller.view.login_page.get_email.return_value = "valid@test.com" + controller.view.login_page.get_password.return_value = "short" + + controller.on_login_page_lineedits_changed() + + controller.view.login_page.update_submit_button_state.assert_called_once_with(state=False) + + def test_validation_signup_invalid_email_disables_submit(self, controller): + controller.field_validator.validate_email.return_value = False + controller.view.signup_page.get_email.return_value = "invalid" + + controller.on_signup_page_lineedits_changed() + + controller.view.signup_page.update_submit_button_state.assert_called_once_with(state=False) + + def test_validation_reset_password_invalid_disables_submit(self, controller): + controller.field_validator.validate_password.return_value = False + controller.view.forgot_page_reset_password.get_password.return_value = "123" + controller.view.forgot_page_reset_password.get_confirm_password.return_value = "123" + + controller.on_reset_password_page_lineedits_changed() + + controller.view.forgot_page_reset_password.update_submit_button_state.assert_called_once_with(state=False) + + def test_signup_skips_worker_when_confirmation_code_cancelled(self, controller): + with patch("modules.auth.mvc.auth_controller.EmailConfirmDialog.get_code", return_value=None), \ + patch.object(controller, "_create_worker") as mock_create_worker, \ + patch("modules.auth.mvc.auth_controller.NotificationService") as MockNotify: + controller.signup(data={}, email="new@test.com", password="StrongPass123") + + mock_create_worker.assert_not_called() + MockNotify.return_value.show_toast.assert_any_call( + notification_type="info", + title="Подтверждение отменено", + message="Регистрация не завершена: код подтверждения не введён." + ) + + def test_reset_confirm_skips_worker_when_confirmation_code_cancelled(self, controller): + with patch("modules.auth.mvc.auth_controller.EmailConfirmDialog.get_code", return_value=""), \ + patch.object(controller, "_create_worker") as mock_create_worker, \ + patch("modules.auth.mvc.auth_controller.NotificationService") as MockNotify: + controller.open_email_confirm_modal_window(data={}, email="new@test.com") + + mock_create_worker.assert_not_called() + MockNotify.return_value.show_toast.assert_called_once() diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index b027a74..cfedb9b 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,13 +17,13 @@ RELEASE_NOTES = { - "0.2.7": [ + "0.2.8": [ { - "title": "Обновление стабильности", + "title": "Улучшения авторизации", "items": [ - "Открытие документа стало стабильнее, даже если загрузка данных временно недоступна.", - "Сообщения об ошибках в этом сценарии стали понятнее и менее навязчивыми.", - "Восстановление сессии в редких сбойных ситуациях после перезапуска работает надёжнее.", + "Формы входа и восстановления аккаунта теперь стабильнее реагируют на невалидный ввод.", + "Кнопки действий сразу блокируются, если введённые данные не проходят проверку.", + "При отмене ввода кода подтверждения процесс теперь корректно завершается без лишних ошибок.", ], }, ], From 24e24b563af2764a78f22e8c09a54959af4905c9 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Tue, 17 Mar 2026 12:40:41 +0300 Subject: [PATCH 08/15] fix(document-editor-stability): harden session-token edge cases --- README.md | 8 ++++---- README_RU.md | 8 ++++---- app.py | 2 +- .../document_editor/mvc/document_editor_model.py | 13 ++++++++----- tests/test_document_editor.py | 16 ++++++++++++++++ utils/whats_new_modal.py | 10 +++++----- 6 files changed, 38 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 3751d74..45f206c 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.8) +## 🛠 What's New (v0.2.9) -- Login and account recovery forms now react more reliably to invalid input. -- Action buttons are disabled immediately when entered data is not valid. -- If email-code confirmation is canceled, the flow now stops safely without extra error popups. +- Document editor now handles rare session-data issues more safely. +- In problematic cases, background document actions fail gracefully instead of breaking the flow. +- Overall reliability of save/upload operations after app restarts is improved. --- diff --git a/README_RU.md b/README_RU.md index 2f76461..69eaea8 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,11 +35,11 @@ --- -## 🛠 Что нового (v0.2.8) +## 🛠 Что нового (v0.2.9) -- Формы входа и восстановления аккаунта теперь стабильнее реагируют на невалидный ввод. -- Кнопки действий сразу блокируются, если введённые данные не проходят проверку. -- При отмене ввода кода подтверждения процесс теперь корректно завершается без лишних ошибок. +- Редактор документов теперь устойчивее обрабатывает редкие проблемы с данными сессии. +- В проблемных ситуациях фоновые операции с документом завершаются безопасно, без срыва сценария. +- Повышена общая надёжность сохранения и загрузки файлов после перезапуска приложения. --- diff --git a/app.py b/app.py index 9aa7612..445d382 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.2.8" +APP_VERSION = "0.2.9" class Application: """ diff --git a/modules/document_editor/mvc/document_editor_model.py b/modules/document_editor/mvc/document_editor_model.py index 303f9ec..db6fead 100644 --- a/modules/document_editor/mvc/document_editor_model.py +++ b/modules/document_editor/mvc/document_editor_model.py @@ -190,11 +190,12 @@ def save_document(self, data: dict) -> None: def _get_user_token(self) -> str | None: last_logged = read_json(self.LOCAL_DIR_LAST_LOGGED) - - if not last_logged: + if not isinstance(last_logged, dict): return None - user_id = last_logged["user_id"] + user_id = last_logged.get("user_id") + if user_id in (None, ""): + return None access_token = keyring.get_password( service_name="Documents Exp", @@ -222,10 +223,12 @@ def _refresh_tokens(self) -> bool: """Refreshes tokens using the stored refresh token.""" try: last_logged = read_json(self.LOCAL_DIR_LAST_LOGGED) - if not last_logged: + if not isinstance(last_logged, dict): return False user_id = last_logged.get("user_id") + if user_id in (None, ""): + return False refresh_token = keyring.get_password("Documents Exp", f"refresh_token_{user_id}") if not refresh_token: @@ -237,4 +240,4 @@ def _refresh_tokens(self) -> bool: return True except Exception as e: logging.error(f"Failed to refresh tokens: {e}") - return False \ No newline at end of file + return False diff --git a/tests/test_document_editor.py b/tests/test_document_editor.py index 7b29c40..e953a99 100644 --- a/tests/test_document_editor.py +++ b/tests/test_document_editor.py @@ -92,6 +92,22 @@ def test_export_to_docx(self, model, tmp_path): mock_doc.add_table.assert_called() mock_doc.save.assert_called_with(str(tmp_path / "export.docx")) + def test_get_user_token_returns_none_for_non_dict_last_logged(self, model): + with patch("modules.document_editor.mvc.document_editor_model.read_json", return_value=[]): + assert model._get_user_token() is None + + def test_get_user_token_returns_none_for_missing_user_id(self, model): + with patch("modules.document_editor.mvc.document_editor_model.read_json", return_value={}), \ + patch("modules.document_editor.mvc.document_editor_model.keyring.get_password") as mock_get_password: + assert model._get_user_token() is None + mock_get_password.assert_not_called() + + def test_refresh_tokens_returns_false_for_missing_user_id(self, model): + with patch("modules.document_editor.mvc.document_editor_model.read_json", return_value={}), \ + patch("modules.document_editor.mvc.document_editor_model.keyring.get_password") as mock_get_password: + assert model._refresh_tokens() is False + mock_get_password.assert_not_called() + class TestDocumentEditorController: diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index cfedb9b..e5f1ccd 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,13 +17,13 @@ RELEASE_NOTES = { - "0.2.8": [ + "0.2.9": [ { - "title": "Улучшения авторизации", + "title": "Улучшения редактора документов", "items": [ - "Формы входа и восстановления аккаунта теперь стабильнее реагируют на невалидный ввод.", - "Кнопки действий сразу блокируются, если введённые данные не проходят проверку.", - "При отмене ввода кода подтверждения процесс теперь корректно завершается без лишних ошибок.", + "Редактор документов теперь устойчивее обрабатывает редкие проблемы с данными сессии.", + "В проблемных ситуациях фоновые операции с документом завершаются безопасно, без срыва сценария.", + "Повышена общая надёжность сохранения и загрузки файлов после перезапуска приложения.", ], }, ], From 319a808c2fc44901ea8cb79ecc9370cb00b9128f Mon Sep 17 00:00:00 2001 From: PN Tech Date: Tue, 17 Mar 2026 12:44:01 +0300 Subject: [PATCH 09/15] fix(api-stability): handle whitespace-empty successful responses safely --- README.md | 8 ++++---- README_RU.md | 8 ++++---- api/api_client.py | 8 +++++++- app.py | 2 +- tests/test_apiclient.py | 27 +++++++++++++++++++++++++++ utils/whats_new_modal.py | 10 +++++----- 6 files changed, 48 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 45f206c..bd97cd2 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.9) +## 🛠 What's New (v0.2.10) -- Document editor now handles rare session-data issues more safely. -- In problematic cases, background document actions fail gracefully instead of breaking the flow. -- Overall reliability of save/upload operations after app restarts is improved. +- Improved stability when the server returns an unexpected empty response. +- The app now handles this case more gracefully instead of interrupting your flow. +- Reliability of background data loading and refresh operations is improved. --- diff --git a/README_RU.md b/README_RU.md index 69eaea8..9bf57ae 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,11 +35,11 @@ --- -## 🛠 Что нового (v0.2.9) +## 🛠 Что нового (v0.2.10) -- Редактор документов теперь устойчивее обрабатывает редкие проблемы с данными сессии. -- В проблемных ситуациях фоновые операции с документом завершаются безопасно, без срыва сценария. -- Повышена общая надёжность сохранения и загрузки файлов после перезапуска приложения. +- Повышена стабильность в редком случае, когда сервер возвращает неожиданный пустой ответ. +- Теперь приложение корректно обрабатывает такой сценарий без срыва рабочего процесса. +- Улучшена надёжность фоновой загрузки и обновления данных. --- diff --git a/api/api_client.py b/api/api_client.py index fb446ab..37bb925 100644 --- a/api/api_client.py +++ b/api/api_client.py @@ -539,4 +539,10 @@ def _request(self, method: str, url: str, timeout: int = 10, **kwargs) -> dict: request.raise_for_status() if request.status_code == 204 or not request.content: return {} - return request.json() + try: + return request.json() + except ValueError: + # Some backends may return 200 with an empty/whitespace body. + if not (request.text or "").strip(): + return {} + raise diff --git a/app.py b/app.py index 445d382..967ac44 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.2.9" +APP_VERSION = "0.2.10" class Application: """ diff --git a/tests/test_apiclient.py b/tests/test_apiclient.py index 5c078a7..a24acc8 100644 --- a/tests/test_apiclient.py +++ b/tests/test_apiclient.py @@ -170,6 +170,33 @@ def test_request_returns_empty_dict_for_empty_body(self, client): assert result == {} + def test_request_returns_empty_dict_for_whitespace_json_body(self, client): + with patch.object(client.session, "request") as mock_request: + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b" \n\t " + mock_response.text = " \n\t " + mock_response.raise_for_status = Mock() + mock_response.json.side_effect = ValueError("No JSON object could be decoded") + mock_request.return_value = mock_response + + result = client._request("GET", f"{self.BASE_URL}/app/search") + + assert result == {} + + def test_request_raises_for_non_json_non_empty_body(self, client): + with patch.object(client.session, "request") as mock_request: + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b"error" + mock_response.text = "error" + mock_response.raise_for_status = Mock() + mock_response.json.side_effect = ValueError("No JSON object could be decoded") + mock_request.return_value = mock_response + + with pytest.raises(ValueError): + client._request("GET", f"{self.BASE_URL}/app/search") + def test_search_data(self, client): token = "auth_token" query = "test query" diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index e5f1ccd..d8b997f 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,13 +17,13 @@ RELEASE_NOTES = { - "0.2.9": [ + "0.2.10": [ { - "title": "Улучшения редактора документов", + "title": "Улучшения стабильности API", "items": [ - "Редактор документов теперь устойчивее обрабатывает редкие проблемы с данными сессии.", - "В проблемных ситуациях фоновые операции с документом завершаются безопасно, без срыва сценария.", - "Повышена общая надёжность сохранения и загрузки файлов после перезапуска приложения.", + "Повышена стабильность в редком случае, когда сервер возвращает неожиданный пустой ответ.", + "Теперь приложение корректно обрабатывает такой сценарий без срыва рабочего процесса.", + "Улучшена надёжность фоновой загрузки и обновления данных.", ], }, ], From bf1ee8bd42dadb6d9a5723196db2baf48580b8e0 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Tue, 17 Mar 2026 12:47:22 +0300 Subject: [PATCH 10/15] fix(update-stability): guarantee cancel completion and cleanup in downloader --- README.md | 8 ++++---- README_RU.md | 8 ++++---- app.py | 2 +- core/updater.py | 19 +++++++++++++++++-- tests/test_updater.py | 30 ++++++++++++++++++++++++++++++ utils/whats_new_modal.py | 10 +++++----- 6 files changed, 61 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index bd97cd2..5724e3e 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.10) +## 🛠 What's New (v0.2.11) -- Improved stability when the server returns an unexpected empty response. -- The app now handles this case more gracefully instead of interrupting your flow. -- Reliability of background data loading and refresh operations is improved. +- Update download cancellation is now handled more reliably. +- The update window no longer gets stuck in rare cancel-at-finish scenarios. +- Temporary installer files are cleaned up more consistently after cancellation. --- diff --git a/README_RU.md b/README_RU.md index 9bf57ae..376fa12 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,11 +35,11 @@ --- -## 🛠 Что нового (v0.2.10) +## 🛠 Что нового (v0.2.11) -- Повышена стабильность в редком случае, когда сервер возвращает неожиданный пустой ответ. -- Теперь приложение корректно обрабатывает такой сценарий без срыва рабочего процесса. -- Улучшена надёжность фоновой загрузки и обновления данных. +- Отмена скачивания обновления теперь обрабатывается надёжнее. +- Окно обновления больше не зависает в редком сценарии отмены в конце загрузки. +- Временные файлы установщика после отмены очищаются стабильнее. --- diff --git a/app.py b/app.py index 967ac44..74f13f6 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.2.10" +APP_VERSION = "0.2.11" class Application: """ diff --git a/core/updater.py b/core/updater.py index d8f5703..bfd6fb9 100644 --- a/core/updater.py +++ b/core/updater.py @@ -92,6 +92,7 @@ def stop(self): self._is_running = False def run(self): + path = None try: # Use requests context manager to ensure connection closure with requests.get(self.url, stream=True, timeout=30, allow_redirects=True) as response: @@ -140,10 +141,24 @@ def run(self): self.progress.emit(downloaded_size, total_size) last_emitted_size = downloaded_size - if self._is_running: - self.finished.emit(path) + if not self._is_running: + try: + os.remove(path) + except OSError: + pass + self.canceled.emit() + return + + self.finished.emit(path) except Exception as e: + if path and not self._is_running: + try: + os.remove(path) + except OSError: + pass + self.canceled.emit() + return logger.error(f"Download failed: {e}") self.error.emit(str(e)) diff --git a/tests/test_updater.py b/tests/test_updater.py index 5ad5ef7..09c38b1 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -178,6 +178,36 @@ def chunks(): downloader.canceled.emit.assert_called_once() downloader.finished.emit.assert_not_called() + def test_download_canceled_after_last_chunk_still_emits_signal(self, tmp_path): + """Cancel at the end of streaming should still emit canceled and clean temp file.""" + url = "http://test.com/file.exe" + downloader = UpdateDownloader(url, 5) + downloader.finished = Mock() + downloader.progress = Mock() + downloader.error = Mock() + downloader.canceled = Mock() + + with patch("requests.get") as mock_get: + mock_response = Mock() + mock_response.headers = {"content-length": "5"} + + def chunks(): + yield b"12345" + downloader.stop() + + mock_response.iter_content.return_value = chunks() + mock_response.raise_for_status = Mock() + mock_get.return_value.__enter__.return_value = mock_response + + target_file = tmp_path / "update.exe" + with patch("core.updater.tempfile.mkstemp", return_value=(123, str(target_file))), \ + patch("core.updater.os.close"): + downloader.run() + + downloader.canceled.emit.assert_called_once() + downloader.finished.emit.assert_not_called() + assert not target_file.exists() + class TestUpdateManager: def test_on_download_canceled_cleans_state(self): diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index d8b997f..c298f40 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,13 +17,13 @@ RELEASE_NOTES = { - "0.2.10": [ + "0.2.11": [ { - "title": "Улучшения стабильности API", + "title": "Улучшения обновления", "items": [ - "Повышена стабильность в редком случае, когда сервер возвращает неожиданный пустой ответ.", - "Теперь приложение корректно обрабатывает такой сценарий без срыва рабочего процесса.", - "Улучшена надёжность фоновой загрузки и обновления данных.", + "Отмена скачивания обновления теперь обрабатывается надёжнее.", + "Окно обновления больше не зависает в редком сценарии отмены в конце загрузки.", + "Временные файлы установщика после отмены очищаются стабильнее.", ], }, ], From 7c5422da59962524a1870c51d5903586743977f1 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Tue, 17 Mar 2026 12:50:24 +0300 Subject: [PATCH 11/15] fix(notification-stability): keep toast stack consistent on manual close --- README.md | 8 ++++---- README_RU.md | 8 ++++---- app.py | 2 +- tests/test_notifications.py | 19 ++++++++++++++++++- utils/notifications/notification_service.py | 16 +++++++++++++--- utils/whats_new_modal.py | 10 +++++----- 6 files changed, 45 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 5724e3e..87c1f72 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.11) +## 🛠 What's New (v0.2.12) -- Update download cancellation is now handled more reliably. -- The update window no longer gets stuck in rare cancel-at-finish scenarios. -- Temporary installer files are cleaned up more consistently after cancellation. +- On-screen notifications now disappear more reliably when closed manually. +- Notification stacking stays consistent during rapid close/show actions. +- Overall UI responsiveness in notification-heavy flows is improved. --- diff --git a/README_RU.md b/README_RU.md index 376fa12..fc6d71e 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,11 +35,11 @@ --- -## 🛠 Что нового (v0.2.11) +## 🛠 Что нового (v0.2.12) -- Отмена скачивания обновления теперь обрабатывается надёжнее. -- Окно обновления больше не зависает в редком сценарии отмены в конце загрузки. -- Временные файлы установщика после отмены очищаются стабильнее. +- Экранные уведомления теперь надёжнее исчезают при ручном закрытии. +- Стек уведомлений сохраняет корректное расположение при частом закрытии и появлении новых сообщений. +- Улучшена общая отзывчивость интерфейса в сценариях с большим числом уведомлений. --- diff --git a/app.py b/app.py index 74f13f6..33dadef 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.2.11" +APP_VERSION = "0.2.12" class Application: """ diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 2128bf3..f97bddb 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -39,6 +39,8 @@ def test_show_toast(self, mock_toast_class, mock_tm): mock_toast_instance = Mock() mock_toast_instance.height.return_value = 100 mock_toast_instance.width.return_value = 300 + mock_toast_instance.destroyed = Mock() + mock_toast_instance.destroyed.connect = Mock() mock_toast_class.return_value = mock_toast_instance # Call method @@ -54,11 +56,26 @@ def test_show_toast(self, mock_toast_class, mock_tm): # Verify that toast is added to list and shown assert mock_toast_instance in service.active_toasts + mock_toast_instance.destroyed.connect.assert_called_once() mock_toast_instance.show_animated.assert_called_once() + @patch("utils.notifications.notification_service.ThemeManagerInstance") + def test_destroyed_toast_is_removed_from_stack(self, mock_tm): + service = NotificationService() + service.main_window = Mock(spec=QWidget) + + toast = Mock() + service.active_toasts = [toast] + + with patch.object(service, "_reposition_toasts") as mock_reposition: + service._on_toast_destroyed(toast) + + assert toast not in service.active_toasts + mock_reposition.assert_called_once() + def test_singleton_behavior(self): """Test Singleton pattern.""" with patch("utils.notifications.notification_service.ThemeManagerInstance"): s1 = NotificationService() s2 = NotificationService() - assert s1 is s2 \ No newline at end of file + assert s1 is s2 diff --git a/utils/notifications/notification_service.py b/utils/notifications/notification_service.py index 7c1a66b..9c33036 100644 --- a/utils/notifications/notification_service.py +++ b/utils/notifications/notification_service.py @@ -63,7 +63,7 @@ def set_main_window(self, main_window: QWidget): self.main_window.removeEventFilter(self.resize_filter) # Clear active toasts from the previous window to prevent positioning issues - for toast in self.active_toasts: + for toast in list(self.active_toasts): toast.close() self.active_toasts.clear() @@ -108,6 +108,7 @@ def show_toast( position_y -= active_toast.height() + self.SPACING self.active_toasts.append(toast) + toast.destroyed.connect(lambda *_: self._on_toast_destroyed(toast)) toast.show_animated(position_y) # Schedule closing @@ -121,7 +122,16 @@ def _close_toast(self, toast_to_close: ToastNotification): """ if toast_to_close in self.active_toasts: toast_to_close.close_animated() - self.active_toasts.remove(toast_to_close) + self._remove_toast_from_stack(toast_to_close) + + def _on_toast_destroyed(self, toast: ToastNotification) -> None: + """Keeps the stack consistent when a toast is closed manually.""" + self._remove_toast_from_stack(toast) + + def _remove_toast_from_stack(self, toast: ToastNotification) -> None: + """Removes a toast reference from active stack and repositions others.""" + if toast in self.active_toasts: + self.active_toasts.remove(toast) self._reposition_toasts() def _reposition_toasts(self): @@ -147,4 +157,4 @@ def show_modal(self): (Not yet implemented) """ # TODO: Implement modal dialog logic - pass \ No newline at end of file + pass diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index c298f40..0ac6def 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,13 +17,13 @@ RELEASE_NOTES = { - "0.2.11": [ + "0.2.12": [ { - "title": "Улучшения обновления", + "title": "Улучшения уведомлений", "items": [ - "Отмена скачивания обновления теперь обрабатывается надёжнее.", - "Окно обновления больше не зависает в редком сценарии отмены в конце загрузки.", - "Временные файлы установщика после отмены очищаются стабильнее.", + "Экранные уведомления теперь надёжнее исчезают при ручном закрытии.", + "Стек уведомлений сохраняет корректное расположение при частом закрытии и появлении новых сообщений.", + "Улучшена общая отзывчивость интерфейса в сценариях с большим числом уведомлений.", ], }, ], From 33b063affb0c0b0cc206ece67faffd1527260a29 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Tue, 17 Mar 2026 12:52:47 +0300 Subject: [PATCH 12/15] fix(notification-lifecycle): handle missing main window safely --- README.md | 8 ++++---- README_RU.md | 8 ++++---- app.py | 2 +- tests/test_notifications.py | 15 +++++++++++++++ utils/notifications/notification_service.py | 2 ++ utils/whats_new_modal.py | 8 ++++---- 6 files changed, 30 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 87c1f72..2e84ba6 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.12) +## 🛠 What's New (v0.2.13) -- On-screen notifications now disappear more reliably when closed manually. -- Notification stacking stays consistent during rapid close/show actions. -- Overall UI responsiveness in notification-heavy flows is improved. +- Improved stability of the notification system during window transitions. +- In rare cases with no active main window, notifications now reset safely. +- Reduced risk of UI interruptions when reopening or switching screens. --- diff --git a/README_RU.md b/README_RU.md index fc6d71e..064a0c1 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,11 +35,11 @@ --- -## 🛠 Что нового (v0.2.12) +## 🛠 Что нового (v0.2.13) -- Экранные уведомления теперь надёжнее исчезают при ручном закрытии. -- Стек уведомлений сохраняет корректное расположение при частом закрытии и появлении новых сообщений. -- Улучшена общая отзывчивость интерфейса в сценариях с большим числом уведомлений. +- Повышена стабильность системы уведомлений при переключении и переоткрытии окон. +- В редком сценарии без активного главного окна уведомления теперь сбрасываются безопасно. +- Снижен риск сбоев интерфейса при переходах между экранами. --- diff --git a/app.py b/app.py index 33dadef..f9a283d 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.2.12" +APP_VERSION = "0.2.13" class Application: """ diff --git a/tests/test_notifications.py b/tests/test_notifications.py index f97bddb..e5ea942 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -79,3 +79,18 @@ def test_singleton_behavior(self): s1 = NotificationService() s2 = NotificationService() assert s1 is s2 + + @patch("utils.notifications.notification_service.ThemeManagerInstance") + def test_set_main_window_accepts_none_without_crash(self, mock_tm): + service = NotificationService() + old_window = Mock(spec=QWidget) + old_toast = Mock() + service.main_window = old_window + service.active_toasts = [old_toast] + + service.set_main_window(None) + + old_window.removeEventFilter.assert_called_once() + old_toast.close.assert_called_once() + assert service.main_window is None + assert service.active_toasts == [] diff --git a/utils/notifications/notification_service.py b/utils/notifications/notification_service.py index 9c33036..3132349 100644 --- a/utils/notifications/notification_service.py +++ b/utils/notifications/notification_service.py @@ -68,6 +68,8 @@ def set_main_window(self, main_window: QWidget): self.active_toasts.clear() self.main_window = main_window + if not self.main_window: + return self.main_window.installEventFilter(self.resize_filter) def show_toast( diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index 0ac6def..0b56481 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,13 +17,13 @@ RELEASE_NOTES = { - "0.2.12": [ + "0.2.13": [ { "title": "Улучшения уведомлений", "items": [ - "Экранные уведомления теперь надёжнее исчезают при ручном закрытии.", - "Стек уведомлений сохраняет корректное расположение при частом закрытии и появлении новых сообщений.", - "Улучшена общая отзывчивость интерфейса в сценариях с большим числом уведомлений.", + "Повышена стабильность системы уведомлений при переключении и переоткрытии окон.", + "В редком сценарии без активного главного окна уведомления теперь сбрасываются безопасно.", + "Снижен риск сбоев интерфейса при переходах между экранами.", ], }, ], From e3b7534915a1c830f31e0fcab441870df7b79f40 Mon Sep 17 00:00:00 2001 From: PN Tech Date: Tue, 17 Mar 2026 12:57:14 +0300 Subject: [PATCH 13/15] fix(auth-session): block continuation on incomplete saved user session --- README.md | 8 ++--- README_RU.md | 8 ++--- app.py | 2 +- modules/auth/mvc/auth_controller.py | 14 +++++++++ modules/auth/mvc/auth_model.py | 3 ++ tests/test_auth.py | 45 +++++++++++++++++++++++++++++ utils/whats_new_modal.py | 10 +++---- 7 files changed, 76 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2e84ba6..8ef191b 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.13) +## 🛠 What's New (v0.2.14) -- Improved stability of the notification system during window transitions. -- In rare cases with no active main window, notifications now reset safely. -- Reduced risk of UI interruptions when reopening or switching screens. +- Improved session-safety checks during login and sign-up completion. +- The app now prevents continuation if user session data is incomplete. +- Reduced risk of unstable authorization state after successful sign-in responses. --- diff --git a/README_RU.md b/README_RU.md index 064a0c1..dec7378 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,11 +35,11 @@ --- -## 🛠 Что нового (v0.2.13) +## 🛠 Что нового (v0.2.14) -- Повышена стабильность системы уведомлений при переключении и переоткрытии окон. -- В редком сценарии без активного главного окна уведомления теперь сбрасываются безопасно. -- Снижен риск сбоев интерфейса при переходах между экранами. +- Усилены проверки безопасности сессии при завершении входа и регистрации. +- Приложение больше не продолжает авторизацию, если данные сессии неполные. +- Снижен риск нестабильного состояния входа после успешного ответа сервера. --- diff --git a/app.py b/app.py index f9a283d..9f386dd 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.2.13" +APP_VERSION = "0.2.14" class Application: """ diff --git a/modules/auth/mvc/auth_controller.py b/modules/auth/mvc/auth_controller.py index 337cddf..c807016 100644 --- a/modules/auth/mvc/auth_controller.py +++ b/modules/auth/mvc/auth_controller.py @@ -96,6 +96,13 @@ def login_user(self, data: dict) -> None: try: auto_login = self.view.login_page.get_auto_login_state() user_id = self.model.save_user(user_data=data, auto_login=auto_login) + if not isinstance(user_id, int): + NotificationService().show_toast( + "error", + "Ошибка входа", + "Не удалось сохранить сессию. Попробуйте войти снова." + ) + return self.view.login_page.clear_lineedits() self.login_successful.emit("auth", user_id) @@ -120,6 +127,13 @@ def signup_user(self, user_data: dict) -> None: try: auto_login = self.view.signup_page.get_auto_login_state() user_id = self.model.save_user(user_data=user_data, auto_login=auto_login) + if not isinstance(user_id, int): + NotificationService().show_toast( + "error", + "Ошибка регистрации", + "Не удалось сохранить сессию. Попробуйте войти снова." + ) + return self.view.signup_page.clear_lineedits() diff --git a/modules/auth/mvc/auth_model.py b/modules/auth/mvc/auth_model.py index ecf4bd5..ab4c7cf 100644 --- a/modules/auth/mvc/auth_model.py +++ b/modules/auth/mvc/auth_model.py @@ -138,6 +138,9 @@ def save_user(self, user_data: dict, auto_login: bool) -> int | None: return None user_id = user.get("id", None) + if not isinstance(user_id, int): + logging.error("Cannot save user session: invalid or missing user id.") + return None # Save access and refresh tokens self.save_token(token_name=f"access_token_{user_id}", token="access_token", data=user_data) diff --git a/tests/test_auth.py b/tests/test_auth.py index 446056d..efd3fae 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -59,6 +59,21 @@ def test_save_user(self, model): # Check file writing (user data and last logged) assert mock_open.call_count >= 2 + def test_save_user_returns_none_for_invalid_user_id(self, model): + user_data = { + "user": {"id": None, "email": "test@test.com"}, + "access_token": "acc", + "refresh_token": "ref", + } + + with patch("modules.auth.mvc.auth_model.keyring.set_password") as mock_keyring, \ + patch("builtins.open", new_callable=MagicMock) as mock_open: + result = model.save_user(user_data, True) + + assert result is None + mock_keyring.assert_not_called() + mock_open.assert_not_called() + def test_save_token_raises_when_keyring_write_fails(self, model): with patch("modules.auth.mvc.auth_model.keyring.set_password", side_effect=Exception("keyring fail")): with pytest.raises(RuntimeError): @@ -169,6 +184,21 @@ def test_login_success_callback_handles_save_error(self, controller): controller.view.login_page.clear_lineedits.assert_not_called() MockNotify.return_value.show_toast.assert_called_once() + def test_login_success_callback_handles_invalid_user_id(self, controller): + data = {"user": {"id": None}} + controller.view.login_page.get_auto_login_state.return_value = True + controller.model.save_user.return_value = None + + mock_signal = Mock() + controller.login_successful.connect(mock_signal) + + with patch("modules.auth.mvc.auth_controller.NotificationService") as MockNotify: + controller.login_user(data) + + mock_signal.assert_not_called() + controller.view.login_page.clear_lineedits.assert_not_called() + MockNotify.return_value.show_toast.assert_called_once() + def test_reset_password_page_switch_handles_save_error(self, controller): controller.view.pages = {"change_password_change_page": Mock()} controller.model.save_token.side_effect = RuntimeError("keyring fail") @@ -265,3 +295,18 @@ def test_reset_confirm_skips_worker_when_confirmation_code_cancelled(self, contr mock_create_worker.assert_not_called() MockNotify.return_value.show_toast.assert_called_once() + + def test_signup_success_callback_handles_invalid_user_id(self, controller): + data = {"user": {"id": None}} + controller.view.signup_page.get_auto_login_state.return_value = True + controller.model.save_user.return_value = None + + mock_signal = Mock() + controller.login_successful.connect(mock_signal) + + with patch("modules.auth.mvc.auth_controller.NotificationService") as MockNotify: + controller.signup_user(data) + + mock_signal.assert_not_called() + controller.view.signup_page.clear_lineedits.assert_not_called() + MockNotify.return_value.show_toast.assert_called_once() diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index 0b56481..647527e 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,13 +17,13 @@ RELEASE_NOTES = { - "0.2.13": [ + "0.2.14": [ { - "title": "Улучшения уведомлений", + "title": "Улучшения авторизации", "items": [ - "Повышена стабильность системы уведомлений при переключении и переоткрытии окон.", - "В редком сценарии без активного главного окна уведомления теперь сбрасываются безопасно.", - "Снижен риск сбоев интерфейса при переходах между экранами.", + "Усилены проверки безопасности сессии при завершении входа и регистрации.", + "Приложение больше не продолжает авторизацию, если данные сессии неполные.", + "Снижен риск нестабильного состояния входа после успешного ответа сервера.", ], }, ], From c387ef728bf2a0f669b56739b501947343202d0d Mon Sep 17 00:00:00 2001 From: PN Tech Date: Tue, 17 Mar 2026 13:07:31 +0300 Subject: [PATCH 14/15] fix(toolbar-refresh): keep selected category stable during data update --- README.md | 8 ++--- README_RU.md | 8 ++--- app.py | 2 +- modules/main/mvc/main_controller.py | 55 ++++++++++++++++++++++------- tests/test_main.py | 42 ++++++++++++++++++++++ utils/whats_new_modal.py | 10 +++--- 6 files changed, 99 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 8ef191b..397dba2 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.14) +## 🛠 What's New (v0.2.15) -- Improved session-safety checks during login and sign-up completion. -- The app now prevents continuation if user session data is incomplete. -- Reduced risk of unstable authorization state after successful sign-in responses. +- Fixed update behavior in the main toolbar: selected department and category are now preserved. +- Table data now refreshes correctly for the current selection after pressing `Update`. +- New departments/categories can appear after update without resetting your current context. --- diff --git a/README_RU.md b/README_RU.md index dec7378..282fff2 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,11 +35,11 @@ --- -## 🛠 Что нового (v0.2.14) +## 🛠 Что нового (v0.2.15) -- Усилены проверки безопасности сессии при завершении входа и регистрации. -- Приложение больше не продолжает авторизацию, если данные сессии неполные. -- Снижен риск нестабильного состояния входа после успешного ответа сервера. +- Исправлено поведение кнопки `Обновить` в главной панели: выбранные отдел и категория теперь сохраняются. +- После нажатия `Обновить` таблица корректно перезагружается для текущего выбора. +- Новые отделы и категории могут добавляться при обновлении без сброса вашего контекста. --- diff --git a/app.py b/app.py index 9f386dd..4386e2d 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.2.14" +APP_VERSION = "0.2.15" class Application: """ diff --git a/modules/main/mvc/main_controller.py b/modules/main/mvc/main_controller.py index 43af542..9c407d3 100644 --- a/modules/main/mvc/main_controller.py +++ b/modules/main/mvc/main_controller.py @@ -499,9 +499,13 @@ def _on_category_selected(self, selected, deselected) -> None: def _on_update_button_clicked(self) -> None: """Handles the update button click.""" try: + saved_dept_id = self.model.current_department_id + saved_cat_id = self.model.current_category_id self.model.refresh_data() - self._update_app_data() - self._update_documents_list() # Reload list + self._update_app_data( + preferred_dept_id=saved_dept_id, + preferred_cat_id=saved_cat_id + ) NotificationService().show_toast( notification_type="success", title="Обновлено", @@ -1127,25 +1131,46 @@ def _on_load_more_error(self, error: Exception) -> None: self._handle_error(error, "Ошибка загрузки документов") - def _update_app_data(self): + def _update_app_data(self, preferred_dept_id=None, preferred_cat_id=None): """Updates the application data.""" self.is_updating_data = True + dept_exists = False try: # Save current selection to restore it after reload - saved_dept_id = self.model.current_department_id - saved_cat_id = self.model.current_category_id - - self._load_sidebar_data() + saved_dept_id = ( + preferred_dept_id + if preferred_dept_id is not None + else self.model.current_department_id + ) + saved_cat_id = ( + preferred_cat_id + if preferred_cat_id is not None + else self.model.current_category_id + ) # Check if saved department exists - dept_exists = any(d["id"] == saved_dept_id for d in self.model.departments) + matched_dept = next( + (d for d in self.model.departments if str(d.get("id")) == str(saved_dept_id)), + None + ) + dept_exists = matched_dept is not None + self.model.current_department_id = matched_dept.get("id") if matched_dept else ( + self.model.departments[0]["id"] if self.model.departments else None + ) + + # Rebuild sidebar/categories for the resolved current department. + self._load_sidebar_data() if dept_exists: - self.view.select_department(saved_dept_id) + self.view.select_department(self.model.current_department_id) # Restore category - cat_exists = any(c["id"] == saved_cat_id for c in self.model.categories) + matched_cat = next( + (c for c in self.model.categories if str(c.get("id")) == str(saved_cat_id)), + None + ) + cat_exists = matched_cat is not None if not cat_exists and isinstance( saved_cat_id, str @@ -1153,12 +1178,15 @@ def _update_app_data(self): cat_exists = True if cat_exists: - self.view.select_category(saved_cat_id) + self.model.current_category_id = ( + matched_cat.get("id") if matched_cat is not None else saved_cat_id + ) + self.view.select_category(self.model.current_category_id) else: # If category was deleted or not selected, select the first one if available # to match SidebarBlock behavior (which selects first item on reset) dept_cats = [c for c in self.model.categories - if c.get("group_id") == saved_dept_id] + if str(c.get("group_id")) == str(self.model.current_department_id)] if dept_cats: self.model.current_category_id = dept_cats[0]["id"] self.view.select_category(self.model.current_category_id) @@ -1174,6 +1202,9 @@ def _update_app_data(self): self._perform_search() else: self._update_documents_list() + else: + self.model.current_category_id = None + self._update_documents_list() self._update_create_category_state() self._update_create_document_state() diff --git a/tests/test_main.py b/tests/test_main.py index f1438fe..06c4c2a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -311,3 +311,45 @@ def test_edit_button_handles_pages_loading_error(self, controller): mock_handle_error.assert_called_once() MockEditor.assert_not_called() + + def test_update_button_keeps_selected_department_and_category(self, controller): + """Update must preserve current dept/category selection when new departments are added.""" + controller.model.current_department_id = 2 + controller.model.current_category_id = "20" + controller.model.departments = [ + {"id": 1, "name": "Dept 1", "documents_count": 5}, + {"id": 2, "name": "Dept 2", "documents_count": 3}, + ] + controller.model.categories = [ + {"id": 10, "name": "Cat 1", "group_id": 1, "documents_count": 2}, + {"id": 20, "name": "Cat 2", "group_id": 2, "documents_count": 4}, + ] + controller.view.get_search_text.return_value = "" + + def refresh_side_effect(): + # Simulate model refresh behavior that resets current department + controller.model.departments = [ + {"id": 1, "name": "Dept 1", "documents_count": 5}, + {"id": 2, "name": "Dept 2", "documents_count": 3}, + {"id": 3, "name": "New Dept", "documents_count": 1}, + ] + controller.model.categories = [ + {"id": 10, "name": "Cat 1", "group_id": 1, "documents_count": 2}, + {"id": 20, "name": "Cat 2", "group_id": 2, "documents_count": 4}, + {"id": 30, "name": "Cat 3", "group_id": 3, "documents_count": 1}, + ] + controller.model.current_department_id = 1 + controller.model.current_category_id = None + + controller.model.refresh_data.side_effect = refresh_side_effect + + with patch.object(controller, "_update_documents_list") as mock_update_docs: + controller._on_update_button_clicked() + + assert controller.model.current_department_id == 2 + assert controller.model.current_category_id == 20 + categories_payload = controller.view.update_categories.call_args[0][0] + assert any(getattr(item, "id", None) == 20 for item in categories_payload) + controller.view.select_department.assert_any_call(2) + controller.view.select_category.assert_any_call(20) + mock_update_docs.assert_called_once() diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index 647527e..b5987c1 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,13 +17,13 @@ RELEASE_NOTES = { - "0.2.14": [ + "0.2.15": [ { - "title": "Улучшения авторизации", + "title": "Улучшения панели инструментов", "items": [ - "Усилены проверки безопасности сессии при завершении входа и регистрации.", - "Приложение больше не продолжает авторизацию, если данные сессии неполные.", - "Снижен риск нестабильного состояния входа после успешного ответа сервера.", + "Исправлено поведение кнопки `Обновить`: выбранные отдел и категория теперь сохраняются.", + "После нажатия `Обновить` таблица корректно перезагружается для текущего выбора.", + "Новые отделы и категории появляются после обновления без сброса текущего контекста.", ], }, ], From 9cd4d59aaaedd9e1811b17ac7c2d846668dd869e Mon Sep 17 00:00:00 2001 From: PN Tech Date: Tue, 17 Mar 2026 13:17:26 +0300 Subject: [PATCH 15/15] fix(editor-toolbar): avoid false dirty state on no-op duplicate/delete --- README.md | 8 ++-- README_RU.md | 8 ++-- app.py | 2 +- .../mvc/document_editor_controller.py | 11 +++-- .../mvc/document_editor_view.py | 40 ++++++++++++++----- tests/test_document_editor.py | 20 ++++++++++ utils/whats_new_modal.py | 8 ++-- 7 files changed, 69 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 397dba2..45e0ecf 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.15) +## 🛠 What's New (v0.2.16) -- Fixed update behavior in the main toolbar: selected department and category are now preserved. -- Table data now refreshes correctly for the current selection after pressing `Update`. -- New departments/categories can appear after update without resetting your current context. +- 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. --- diff --git a/README_RU.md b/README_RU.md index 282fff2..e7e5d44 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,11 +35,11 @@ --- -## 🛠 Что нового (v0.2.15) +## 🛠 Что нового (v0.2.16) -- Исправлено поведение кнопки `Обновить` в главной панели: выбранные отдел и категория теперь сохраняются. -- После нажатия `Обновить` таблица корректно перезагружается для текущего выбора. -- Новые отделы и категории могут добавляться при обновлении без сброса вашего контекста. +- Улучшено поведение панели инструментов в окнах создания и редактирования документа. +- `Дублировать` и `Удалить` теперь помечают документ как изменённый только при реальном изменении страниц. +- Снижен риск ложного состояния “есть несохранённые изменения” при нажатии действий без выбранных страниц. --- diff --git a/app.py b/app.py index 4386e2d..d0addf1 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.2.15" +APP_VERSION = "0.2.16" class Application: """ diff --git a/modules/document_editor/mvc/document_editor_controller.py b/modules/document_editor/mvc/document_editor_controller.py index 7e06425..e0271f3 100644 --- a/modules/document_editor/mvc/document_editor_controller.py +++ b/modules/document_editor/mvc/document_editor_controller.py @@ -129,8 +129,10 @@ def _on_add_page_button_clicked(self) -> None: def _on_duplicate_page_button_clicked(self) -> None: """Handles the duplicate page button click event.""" - self.view.duplicate_selected_pages() - self._on_document_data_changed() + changed = self.view.duplicate_selected_pages() + if changed: + self._on_document_data_changed() + self._on_table_selection_changed() def _on_print_button_clicked(self) -> None: @@ -250,8 +252,9 @@ def _on_export_button_clicked(self) -> None: def _on_delete_page_button_clicked(self) -> None: """Handles the delete page button click event.""" - self.view.delete_selected_pages() - self._on_document_data_changed() + changed = self.view.delete_selected_pages() + if changed: + self._on_document_data_changed() self._on_table_selection_changed() diff --git a/modules/document_editor/mvc/document_editor_view.py b/modules/document_editor/mvc/document_editor_view.py index 813b8fa..19d1712 100644 --- a/modules/document_editor/mvc/document_editor_view.py +++ b/modules/document_editor/mvc/document_editor_view.py @@ -292,8 +292,12 @@ def add_new_page(self) -> None: self.table_view.edit(index) - def delete_selected_pages(self) -> None: - """Deletes selected or checked pages from the table.""" + def delete_selected_pages(self) -> bool: + """Deletes selected or checked pages from the table. + + Returns: + bool: True if any page was deleted, otherwise False. + """ model = self.table_view.model() rows_to_delete = set() @@ -313,10 +317,15 @@ def delete_selected_pages(self) -> None: model.removeRow(row) self.table_view.clearSelection() + return bool(rows_to_delete) - def duplicate_selected_pages(self) -> None: - """Duplicates selected or checked pages.""" + def duplicate_selected_pages(self) -> bool: + """Duplicates selected or checked pages. + + Returns: + bool: True if any page was duplicated, otherwise False. + """ model = self.table_view.model() rows_to_duplicate = set() @@ -375,6 +384,7 @@ def duplicate_selected_pages(self) -> None: model.index(insert_row, 0), QItemSelectionModel.Select | QItemSelectionModel.Rows ) + return bool(rows_to_duplicate) def has_selection_or_checks(self) -> bool: @@ -561,14 +571,22 @@ def has_selected_pages(self) -> bool: return self.pages_table.has_selection_or_checks() - def delete_selected_pages(self) -> None: - """Deletes selected or checked pages.""" - self.pages_table.delete_selected_pages() + def delete_selected_pages(self) -> bool: + """Deletes selected or checked pages. + + Returns: + bool: True if any page was deleted, otherwise False. + """ + return self.pages_table.delete_selected_pages() - def duplicate_selected_pages(self) -> None: - """Duplicates selected or checked pages.""" - self.pages_table.duplicate_selected_pages() + def duplicate_selected_pages(self) -> bool: + """Duplicates selected or checked pages. + + Returns: + bool: True if any page was duplicated, otherwise False. + """ + return self.pages_table.duplicate_selected_pages() def get_document_data(self) -> dict: @@ -811,4 +829,4 @@ def switch_to_files_tab(self) -> None: self.ui.tabs_tabWidget.setCurrentIndex(1) def set_file_drop_active(self, active: bool) -> None: - self.files_tab.file_drop_widget.setDragActive(active) \ No newline at end of file + self.files_tab.file_drop_widget.setDragActive(active) diff --git a/tests/test_document_editor.py b/tests/test_document_editor.py index e953a99..8730229 100644 --- a/tests/test_document_editor.py +++ b/tests/test_document_editor.py @@ -223,3 +223,23 @@ def test_delete_document_clicked(self, controller): controller.model.delete_document.assert_called_once() controller.window.document_deleted.emit.assert_called_once() controller.window.close.assert_called_once() + + def test_duplicate_without_selection_does_not_mark_document_edited(self, controller): + controller.model.is_document_edited = False + controller.view.duplicate_selected_pages.return_value = False + + with patch.object(controller, "_on_document_data_changed") as mock_changed: + controller._on_duplicate_page_button_clicked() + + mock_changed.assert_not_called() + controller.view.update_delete_page_button_state.assert_called() + + def test_delete_without_selection_does_not_mark_document_edited(self, controller): + controller.model.is_document_edited = False + controller.view.delete_selected_pages.return_value = False + + with patch.object(controller, "_on_document_data_changed") as mock_changed: + controller._on_delete_page_button_clicked() + + mock_changed.assert_not_called() + controller.view.update_delete_page_button_state.assert_called() diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index b5987c1..a0f6daa 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,13 +17,13 @@ RELEASE_NOTES = { - "0.2.15": [ + "0.2.16": [ { "title": "Улучшения панели инструментов", "items": [ - "Исправлено поведение кнопки `Обновить`: выбранные отдел и категория теперь сохраняются.", - "После нажатия `Обновить` таблица корректно перезагружается для текущего выбора.", - "Новые отделы и категории появляются после обновления без сброса текущего контекста.", + "Улучшено поведение панели инструментов в окнах создания и редактирования документа.", + "`Дублировать` и `Удалить` теперь помечают документ как изменённый только при реальном изменении страниц.", + "Снижен риск ложного состояния `есть несохранённые изменения` при нажатии действий без выбранных страниц.", ], }, ],