zapretgui/tests/test_preset_subpage_ui_guards.py
2026-07-06 23:23:23 +03:00

611 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import Mock, patch
class _PlainTextEditor:
def __init__(self, text: str = "") -> None:
self._text = str(text)
self.plain_text_read_calls: list[str] = []
self.plain_text_calls: list[str] = []
def toPlainText(self) -> str: # noqa: N802
self.plain_text_read_calls.append(self._text)
return self._text
def setPlainText(self, text: str) -> None: # noqa: N802
value = str(text)
self.plain_text_calls.append(value)
self._text = value
class _Button:
def __init__(self, *, enabled: bool = True) -> None:
self._enabled = bool(enabled)
self.enabled_calls: list[bool] = []
self.visible = True
def isEnabled(self) -> bool: # noqa: N802
return self._enabled
def setEnabled(self, enabled: bool) -> None: # noqa: N802
value = bool(enabled)
self.enabled_calls.append(value)
self._enabled = value
def setVisible(self, visible: bool) -> None: # noqa: N802
self.visible = bool(visible)
def isVisible(self) -> bool: # noqa: N802
return self.visible
class _Label:
def __init__(self) -> None:
self.text_value = ""
def text(self) -> str:
return self.text_value
def setText(self, text: str) -> None: # noqa: N802
self.text_value = str(text)
class _Signal:
def connect(self, _callback) -> None:
pass
class _Worker:
def __init__(self) -> None:
self.activated = _Signal()
self.failed = _Signal()
self.finished = _Signal()
self.start_calls = 0
def start(self) -> None:
self.start_calls += 1
def deleteLater(self) -> None: # noqa: N802
pass
class _RunningRuntime:
def isRunning(self) -> bool: # noqa: N802
return True
def is_running(self) -> bool:
return True
class _StartRuntime:
def is_running(self) -> bool:
return False
def start_qthread_worker(self, *, worker_factory, **_kwargs):
worker = worker_factory(0)
return 0, worker
class _FakeBreadcrumb:
def __init__(self) -> None:
self.items: list[tuple[str, str]] = []
self.clear_calls = 0
def blockSignals(self, _blocked) -> None: # noqa: N802
pass
def clear(self) -> None:
self.clear_calls += 1
self.items = []
def addItem(self, key: str, text: str) -> None: # noqa: N802
self.items.append((str(key), str(text)))
def count(self) -> int:
return len(self.items)
def setProperty(self, *_args) -> None: # noqa: N802
pass
def property(self, *_args) -> str:
return ""
class PresetSubpageUiGuardTests(unittest.TestCase):
def test_raw_preset_load_while_worker_runs_queues_latest_request(self) -> None:
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
runtime = SimpleNamespace(
is_running=Mock(return_value=True),
start_qthread_worker=Mock(),
)
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
page._raw_load_runtime = runtime
page._raw_load_request_id = 2
page._raw_load_pending = False
page._raw_load_start_scheduled = False
page._is_loading = False
page._preset_file_name = "Default.txt"
page._set_footer = Mock()
page.create_raw_preset_load_worker = Mock()
PresetRawEditorPage._request_raw_preset_text(page)
self.assertEqual(page._raw_load_request_id, 3)
self.assertTrue(page._raw_load_pending)
self.assertTrue(page._is_loading)
page._set_footer.assert_called_once_with("Загрузка...")
runtime.start_qthread_worker.assert_not_called()
page.create_raw_preset_load_worker.assert_not_called()
def test_pending_raw_preset_load_restarts_after_worker_signal(self) -> None:
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
worker = SimpleNamespace(_request_id=4)
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
page._cleanup_in_progress = False
page._raw_load_runtime_request_id = 4
page._raw_load_pending = True
page._raw_load_start_scheduled = False
page._request_raw_preset_text = Mock()
callbacks = []
with patch(
"presets.ui.common.preset_subpage_base.QTimer.singleShot",
side_effect=lambda _delay, callback: callbacks.append(callback),
):
PresetRawEditorPage._on_raw_preset_worker_finished(page, worker)
page._request_raw_preset_text.assert_not_called()
self.assertEqual(len(callbacks), 1)
callbacks[0]()
self.assertFalse(page._raw_load_pending)
page._request_raw_preset_text.assert_called_once_with()
def test_stale_raw_preset_load_finish_does_not_restart_pending_load(self) -> None:
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
page._cleanup_in_progress = False
page._raw_load_runtime_request_id = 4
page._raw_load_pending = True
page._schedule_pending_raw_preset_load_start = Mock()
PresetRawEditorPage._on_raw_preset_worker_finished(page, SimpleNamespace(_request_id=3))
page._schedule_pending_raw_preset_load_start.assert_not_called()
def test_raw_preset_cleanup_does_not_wait_for_load_worker(self) -> None:
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
page._raw_load_runtime = SimpleNamespace(stop=Mock(), cancel=Mock())
page._raw_save_runtime = SimpleNamespace(stop=Mock(), cancel=Mock())
page._raw_activate_runtime = SimpleNamespace(stop=Mock(), cancel=Mock())
page._raw_action_runtime = SimpleNamespace(stop=Mock(), cancel=Mock())
PresetRawEditorPage._stop_raw_worker_runtimes(page)
page._raw_load_runtime.stop.assert_called_once_with(
blocking=False,
warning_prefix="raw preset load worker",
)
page._raw_load_runtime.cancel.assert_called_once_with()
for runtime, prefix in (
(page._raw_save_runtime, "raw preset save worker"),
(page._raw_activate_runtime, "raw preset activate worker"),
(page._raw_action_runtime, "raw preset action worker"),
):
runtime.stop.assert_called_once_with(blocking=False, warning_prefix=prefix)
runtime.cancel.assert_called_once_with()
def test_raw_preset_load_skips_duplicate_plain_text_update(self) -> None:
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
page._raw_load_request_id = 3
page._preset_file_name = "Default.txt"
page._preset_name = "Default"
page._preset_path = None
page._preset_origin = "user"
page._raw_editor_text_snapshot = "--new\n--filter-tcp=443\n"
page._is_loading = True
page.editor = _PlainTextEditor("--new\n--filter-tcp=443\n")
page._set_footer = Mock()
page._refresh_header = Mock()
callbacks = []
result = SimpleNamespace(
file_name="Default.txt",
display_name="Default",
path="C:/Zapret/Dev/presets/winws2/Default.txt",
origin="user",
text="--new\n--filter-tcp=443\n",
footer_text="Готово",
)
with patch(
"presets.ui.common.preset_subpage_base.QTimer.singleShot",
side_effect=lambda _delay, callback: callbacks.append(callback),
):
PresetRawEditorPage._on_raw_preset_text_loaded(page, 3, result)
self.assertEqual(page.editor.plain_text_calls, [])
page._set_footer.assert_not_called()
page._refresh_header.assert_not_called()
self.assertTrue(page._is_loading)
self.assertEqual(len(callbacks), 1)
callbacks[0]()
self.assertEqual(page.editor.plain_text_calls, [])
self.assertEqual(page.editor.plain_text_read_calls, [])
page._set_footer.assert_called_once_with("Готово")
page._refresh_header.assert_called_once_with()
self.assertFalse(page._is_loading)
def test_raw_preset_load_applies_editor_text_after_worker_signal(self) -> None:
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
page._raw_load_request_id = 4
page._cleanup_in_progress = False
page._preset_file_name = "Default.txt"
page._preset_name = "Default"
page._preset_path = None
page._preset_origin = "user"
page._is_loading = True
page.editor = _PlainTextEditor("")
page._set_footer = Mock()
page._refresh_header = Mock()
callbacks = []
result = SimpleNamespace(
file_name="Default.txt",
display_name="Default",
path="C:/Zapret/Dev/presets/winws2/Default.txt",
origin="user",
text="--new\n--filter-tcp=443\n",
footer_text="Готово",
)
with patch(
"presets.ui.common.preset_subpage_base.QTimer.singleShot",
side_effect=lambda _delay, callback: callbacks.append(callback),
):
PresetRawEditorPage._on_raw_preset_text_loaded(page, 4, result)
self.assertEqual(page.editor.plain_text_calls, [])
page._set_footer.assert_not_called()
page._refresh_header.assert_not_called()
self.assertTrue(page._is_loading)
self.assertEqual(len(callbacks), 1)
callbacks[0]()
self.assertEqual(page.editor.plain_text_calls, [result.text])
page._set_footer.assert_called_once_with("Готово")
page._refresh_header.assert_called_once_with()
self.assertFalse(page._is_loading)
def test_raw_preset_load_updates_active_preset_state_from_worker_result(self) -> None:
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
page._raw_load_request_id = 4
page._cleanup_in_progress = False
page._raw_load_pending = False
page._raw_load_start_scheduled = False
page._preset_file_name = "Default.txt"
page._preset_name = "Default"
page._preset_path = None
page._preset_origin = "user"
page._active_preset_file_name = ""
page._active_preset_name = ""
page._is_loading = True
page.editor = _PlainTextEditor("")
page._set_footer = Mock()
page._refresh_header = Mock(wraps=lambda: PresetRawEditorPage._refresh_header(page))
page._rebuild_breadcrumb = Mock()
page.statusLabel = _Label()
page.metaLabel = _Label()
page.pathLabel = _Label()
page.activateButton = _Button()
callbacks = []
result = SimpleNamespace(
file_name="Default.txt",
display_name="Default",
path="C:/Zapret/Dev/presets/winws2/Default.txt",
origin="user",
text="--new\n",
footer_text="Готово",
active_file_name="Default.txt",
active_name="Default",
)
with patch(
"presets.ui.common.preset_subpage_base.QTimer.singleShot",
side_effect=lambda _delay, callback: callbacks.append(callback),
):
PresetRawEditorPage._on_raw_preset_text_loaded(page, 4, result)
callbacks[0]()
self.assertEqual(page._active_preset_file_name, "Default.txt")
self.assertEqual(page._active_preset_name, "Default")
self.assertFalse(page.activateButton.visible)
self.assertEqual(page.statusLabel.text_value, "Активный пресет")
def test_pending_raw_text_apply_is_ignored_after_new_load_is_requested(self) -> None:
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
runtime = SimpleNamespace(
is_running=Mock(return_value=True),
start_qthread_worker=Mock(),
)
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
page._raw_load_runtime = runtime
page._raw_load_request_id = 4
page._raw_load_pending = False
page._raw_load_start_scheduled = False
page._raw_text_apply_scheduled = False
page._pending_raw_text_apply = None
page._cleanup_in_progress = False
page._preset_file_name = "Old.txt"
page._preset_name = "Old"
page._preset_path = None
page._preset_origin = "user"
page._is_loading = True
page.editor = _PlainTextEditor("")
page._set_footer = Mock()
page._refresh_header = Mock()
callbacks = []
result = SimpleNamespace(
file_name="Old.txt",
display_name="Old",
path="C:/Zapret/Dev/presets/winws2/Old.txt",
origin="user",
text="--new\n--filter-tcp=443\n",
footer_text="Готово",
)
with patch(
"presets.ui.common.preset_subpage_base.QTimer.singleShot",
side_effect=lambda _delay, callback: callbacks.append(callback),
):
PresetRawEditorPage._on_raw_preset_text_loaded(page, 4, result)
PresetRawEditorPage._request_raw_preset_text(page)
self.assertTrue(page._raw_load_pending)
self.assertEqual(page._raw_load_request_id, 5)
self.assertEqual(len(callbacks), 1)
callbacks[0]()
self.assertEqual(page.editor.plain_text_calls, [])
page._refresh_header.assert_not_called()
self.assertTrue(page._is_loading)
def test_pending_raw_preset_load_ignores_old_load_error(self) -> None:
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
page._raw_load_request_id = 5
page._raw_load_pending = True
page._raw_load_start_scheduled = False
page._is_loading = True
page._set_footer = Mock()
PresetRawEditorPage._on_raw_preset_text_failed(page, 5, "old error")
page._set_footer.assert_not_called()
self.assertTrue(page._is_loading)
def test_raw_preset_activation_skips_duplicate_button_disable(self) -> None:
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
worker = _Worker()
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
page._raw_activate_request_id = 0
page._preset_file_name = "Default.txt"
page.activateButton = _Button(enabled=False)
page.create_raw_preset_activate_worker = Mock(return_value=worker)
PresetRawEditorPage._request_preset_activation(page)
self.assertEqual(page.activateButton.enabled_calls, [])
page.create_raw_preset_activate_worker.assert_called_once_with(1, "Default.txt", page)
self.assertEqual(worker.start_calls, 1)
def test_raw_preset_save_while_worker_runs_defers_editor_read(self) -> None:
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
page._cleanup_in_progress = False
page._preset_path = object()
page._preset_file_name = "Default.txt"
page._raw_save_runtime = _RunningRuntime()
page._pending_raw_preset_save = None
page.editor = _PlainTextEditor("--new\n--filter-tcp=443\n")
self.assertTrue(PresetRawEditorPage._save_file(page, publish_content_changed=True))
self.assertEqual(page.editor.plain_text_read_calls, [])
self.assertEqual(page._pending_raw_preset_save, ("Default.txt", None, True))
def test_pending_raw_preset_save_restarts_after_worker_signal(self) -> None:
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
worker = object()
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
page._cleanup_in_progress = False
page._pending_raw_preset_save = ("Default.txt", None, True)
page._after_raw_preset_save = None
page._raw_save_runtime = _StartRuntime()
page._raw_save_request_id = 1
page._raw_save_succeeded = True
page._raw_editor_text_snapshot = "--new\n--filter-tcp=443\n"
page._set_footer = Mock()
page.editor = _PlainTextEditor("--new\n--filter-tcp=443\n")
page.create_raw_preset_save_worker = Mock(return_value=worker)
callbacks = []
with patch(
"presets.ui.common.preset_subpage_base.QTimer.singleShot",
side_effect=lambda _delay, callback: callbacks.append(callback),
):
PresetRawEditorPage._on_raw_preset_save_worker_finished(page, SimpleNamespace(_request_id=1))
page.create_raw_preset_save_worker.assert_not_called()
self.assertEqual(page.editor.plain_text_read_calls, [])
self.assertEqual(len(callbacks), 1)
callbacks[0]()
self.assertEqual(page.editor.plain_text_read_calls, [])
page.create_raw_preset_save_worker.assert_called_once_with(
2,
file_name="Default.txt",
source_text="--new\n--filter-tcp=443\n",
publish_content_changed=True,
parent=page,
)
def test_raw_preset_save_uses_snapshot_when_editor_is_unchanged(self) -> None:
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
page._raw_editor_text_snapshot = "--new\n--filter-tcp=443\n"
page.editor = _PlainTextEditor("--new\n--filter-tcp=443\n")
text = PresetRawEditorPage._resolve_raw_preset_save_text(page, None)
self.assertEqual(text, "--new\n--filter-tcp=443\n")
self.assertEqual(page.editor.plain_text_read_calls, [])
def test_raw_preset_save_reads_editor_when_snapshot_is_invalidated(self) -> None:
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
page._raw_editor_text_snapshot = None
page.editor = _PlainTextEditor("--new\n--filter-tcp=443\n")
text = PresetRawEditorPage._resolve_raw_preset_save_text(page, None)
self.assertEqual(text, "--new\n--filter-tcp=443\n")
self.assertEqual(page._raw_editor_text_snapshot, "--new\n--filter-tcp=443\n")
self.assertEqual(page.editor.plain_text_read_calls, ["--new\n--filter-tcp=443\n"])
def test_raw_preset_save_after_text_change_reads_editor_once(self) -> None:
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
# После правки (textChanged инвалидирует мемо) сохранение перечитывает
# живой текст из редактора ровно один раз — инкрементального кэша по
# contentsChange больше нет, он молча терял вставленный текст.
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
page._cleanup_in_progress = False
page._is_loading = False
page._raw_editor_text_snapshot = "--new\nold\n"
page._raw_save_runtime = _StartRuntime()
page._raw_save_request_id = 0
page._raw_save_succeeded = True
page._save_timer = Mock()
page._commit_timer = Mock()
page._set_footer = Mock()
page.editor = _PlainTextEditor("--new\nlatest\n")
page.create_raw_preset_save_worker = Mock(return_value=object())
PresetRawEditorPage._on_text_changed(page)
PresetRawEditorPage._start_raw_preset_save_worker(
page,
file_name="Default.txt",
source_text=None,
publish_content_changed=True,
)
self.assertEqual(page.editor.plain_text_read_calls, ["--new\nlatest\n"])
page.create_raw_preset_save_worker.assert_called_once_with(
1,
file_name="Default.txt",
source_text="--new\nlatest\n",
publish_content_changed=True,
parent=page,
)
def test_rebuild_breadcrumb_restores_items_after_click_truncation(self) -> None:
# Клик по крошке заставляет BreadcrumbBar удалить элементы правее
# выбранного, при этом текстовый ключ пути не меняется. Перестройка
# обязана восстановить все крошки, а не скипаться по мемо-ключу.
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
breadcrumb = _FakeBreadcrumb()
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
page._breadcrumb = breadcrumb
page._preset_name = "Мой пресет"
PresetRawEditorPage._rebuild_breadcrumb(page)
self.assertEqual(breadcrumb.count(), 3)
clear_calls_after_build = breadcrumb.clear_calls
# Повторный вызов с тем же ключом и полным баром — без перестройки.
PresetRawEditorPage._rebuild_breadcrumb(page)
self.assertEqual(breadcrumb.clear_calls, clear_calls_after_build)
# Симулируем клик по корневой крошке: остаётся только первый элемент.
breadcrumb.items = breadcrumb.items[:1]
PresetRawEditorPage._rebuild_breadcrumb(page)
self.assertEqual(breadcrumb.count(), 3)
self.assertEqual([key for key, _text in breadcrumb.items], ["root", "list", "raw_preset"])
def test_breadcrumb_item_click_restores_crumbs_and_navigates(self) -> None:
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
breadcrumb = _FakeBreadcrumb()
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
page._breadcrumb = breadcrumb
page._preset_name = "Мой пресет"
page._open_root_callback = Mock()
page._open_back_callback = Mock()
PresetRawEditorPage._rebuild_breadcrumb(page)
breadcrumb.items = breadcrumb.items[:1]
PresetRawEditorPage._on_breadcrumb_item_changed(page, "root")
self.assertEqual(breadcrumb.count(), 3)
page._open_root_callback.assert_called_once()
page._open_back_callback.assert_not_called()
def test_status_message_update_skips_runtime_toggle_render(self) -> None:
from app.state_store import AppUiState
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
page._cleanup_in_progress = False
page._render_runtime_toggle = Mock(
side_effect=AssertionError("status message must not repaint runtime toggle")
)
page._render_footer_status = Mock()
PresetRawEditorPage._on_ui_state_changed(
page,
AppUiState(last_status_message="Запущено"),
frozenset({"last_status_message"}),
)
page._render_runtime_toggle.assert_not_called()
page._render_footer_status.assert_called_once()
if __name__ == "__main__":
unittest.main()