diff --git a/README.md b/README.md index 52da745..45e0ecf 100644 --- a/README.md +++ b/README.md @@ -35,16 +35,11 @@ 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.16) -This patch release improves reliability and profile editing behavior. - -### 🛠 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. +- 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 7797807..e7e5d44 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,16 +35,11 @@ --- -## 💡 Что нового (v0.2.1) - Стабильность и профиль +## 🛠 Что нового (v0.2.16) -Это патч-обновление повышает надежность и улучшает поведение при работе с профилем. - -### 🛠 Улучшено - -- Исправлен сбой при открытии профиля для аккаунтов без заполненного отображаемого имени. -- Повышена надежность обновления данных профиля после обновлений backend-сервиса. -- Улучшена обработка пустых значений в названиях документов и страниц. -- Повышена стабильность поиска и загрузки документов в пограничных сценариях. +- Улучшено поведение панели инструментов в окнах создания и редактирования документа. +- `Дублировать` и `Удалить` теперь помечают документ как изменённый только при реальном изменении страниц. +- Снижен риск ложного состояния “есть несохранённые изменения” при нажатии действий без выбранных страниц. --- 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 d277c84..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.1" +APP_VERSION = "0.2.16" class Application: """ diff --git a/core/updater.py b/core/updater.py index 020b95f..bfd6fb9 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): @@ -91,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: @@ -121,6 +123,7 @@ def run(self): os.remove(path) except OSError: pass + self.canceled.emit() return if chunk: @@ -138,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)) @@ -193,6 +210,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 +219,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 +227,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 +241,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/modules/auth/mvc/auth_controller.py b/modules/auth/mvc/auth_controller.py index 5a19bdd..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() @@ -154,6 +168,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 +249,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 +356,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 +430,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 +523,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/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/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_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/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/modules/main/mvc/main_controller.py b/modules/main/mvc/main_controller.py index 964f014..9c407d3 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() @@ -495,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="Обновлено", @@ -541,9 +549,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( @@ -691,7 +703,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 +775,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 +1030,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 +1082,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. @@ -1119,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 @@ -1145,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) @@ -1161,11 +1197,14 @@ 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: 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/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_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/tests/test_auth.py b/tests/test_auth.py index c77ca76..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") @@ -215,4 +245,68 @@ 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() + + 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/tests/test_document_editor.py b/tests/test_document_editor.py index 7b29c40..8730229 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: @@ -207,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/tests/test_main.py b/tests/test_main.py index cdbed17..06c4c2a 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 @@ -284,3 +298,58 @@ 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() + + 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/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/tests/test_notifications.py b/tests/test_notifications.py index 2128bf3..e5ea942 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,41 @@ 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 + + @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/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/tests/test_updater.py b/tests/test_updater.py index 35d6c21..09c38b1 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,80 @@ 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() + + 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): + 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/tests/test_utils.py b/tests/test_utils.py index fec1950..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: @@ -197,6 +216,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/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/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/notifications/notification_service.py b/utils/notifications/notification_service.py index 7c1a66b..3132349 100644 --- a/utils/notifications/notification_service.py +++ b/utils/notifications/notification_service.py @@ -63,11 +63,13 @@ 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() self.main_window = main_window + if not self.main_window: + return self.main_window.installEventFilter(self.resize_filter) def show_toast( @@ -108,6 +110,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 +124,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 +159,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/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 e5d9145..a0f6daa 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,14 +17,13 @@ RELEASE_NOTES = { - "0.2.1": [ + "0.2.16": [ { - "title": "Стабильность и профиль", + "title": "Улучшения панели инструментов", "items": [ - "Исправлен сбой при открытии профиля для аккаунтов без заполненного отображаемого имени.", - "Повышена надежность обновления данных профиля после обновлений backend-сервиса.", - "Улучшена обработка пустых значений в названиях документов и страниц.", - "Повышена стабильность поиска и загрузки документов в пограничных сценариях.", + "Улучшено поведение панели инструментов в окнах создания и редактирования документа.", + "`Дублировать` и `Удалить` теперь помечают документ как изменённый только при реальном изменении страниц.", + "Снижен риск ложного состояния `есть несохранённые изменения` при нажатии действий без выбранных страниц.", ], }, ],