Вид пресета, привязка к ссылке и путь к файлу получили значки. Значок вида совпадает с кнопкой похожего смысла: включённый пресет — галочка, как у «Сделать активным»; импортированный — как у «Импорт»; встроенный — библиотека; свой — карандаш. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
705 lines
28 KiB
Python
705 lines
28 KiB
Python
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)
|
||
|
||
def setVisible(self, visible: bool) -> None: # noqa: N802
|
||
self.visible = bool(visible)
|
||
|
||
def toolTip(self) -> str: # noqa: N802
|
||
return getattr(self, "tooltip_value", "")
|
||
|
||
def setToolTip(self, text: str) -> None: # noqa: N802
|
||
self.tooltip_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)
|
||
|
||
from log.log import log
|
||
|
||
page._raw_load_runtime.stop.assert_called_once_with(
|
||
blocking=False,
|
||
log_fn=log,
|
||
warning_prefix="raw preset load worker",
|
||
)
|
||
page._raw_load_runtime.cancel.assert_called_once_with()
|
||
|
||
# Запись дожидаемся: поздний save-worker не должен перезаписать файл
|
||
# поверх синхронной записи при закрытии.
|
||
page._raw_save_runtime.stop.assert_called_once_with(
|
||
blocking=True,
|
||
log_fn=log,
|
||
warning_prefix="raw preset save worker",
|
||
)
|
||
page._raw_save_runtime.cancel.assert_called_once_with()
|
||
|
||
for runtime, prefix in (
|
||
(page._raw_activate_runtime, "raw preset activate worker"),
|
||
(page._raw_action_runtime, "raw preset action worker"),
|
||
):
|
||
runtime.stop.assert_called_once_with(blocking=False, log_fn=log, warning_prefix=prefix)
|
||
runtime.cancel.assert_called_once_with()
|
||
|
||
def _make_closing_raw_editor_page(self, *, publish_pending: bool, events: list):
|
||
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
|
||
|
||
page = PresetRawEditorPage.__new__(PresetRawEditorPage)
|
||
page._preset_path = "presets/winws2/My.txt"
|
||
page._preset_file_name = "My.txt"
|
||
page._raw_text_editor = SimpleNamespace(
|
||
content_publish_pending=publish_pending,
|
||
current_text=lambda: "--filter-tcp=443\n--lua-desync=fake\n",
|
||
cleanup=Mock(),
|
||
)
|
||
for attr in ("_raw_load_runtime", "_raw_save_runtime", "_raw_activate_runtime", "_raw_action_runtime"):
|
||
runtime = SimpleNamespace(
|
||
stop=Mock(side_effect=lambda *, blocking, log_fn=None, warning_prefix: events.append(("stop", warning_prefix))),
|
||
cancel=Mock(),
|
||
is_running=lambda: False,
|
||
)
|
||
setattr(page, attr, runtime)
|
||
page._save_timer = Mock()
|
||
page._commit_timer = Mock()
|
||
page._app_event_filter_installed = False
|
||
page._ui_state_unsubscribe = None
|
||
page._save_raw_preset_on_close_fn = lambda file_name, text: events.append(("save", file_name, text))
|
||
return page
|
||
|
||
def test_raw_preset_cleanup_writes_unsaved_editor_text_synchronously(self) -> None:
|
||
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
|
||
|
||
events: list = []
|
||
page = self._make_closing_raw_editor_page(publish_pending=True, events=events)
|
||
|
||
PresetRawEditorPage.cleanup(page)
|
||
|
||
# Правка, набранная меньше чем за секунду до закрытия, раньше уходила
|
||
# в асинхронную очередь, которую cleanup тут же сбрасывал.
|
||
self.assertIn(("save", "My.txt", "--filter-tcp=443\n--lua-desync=fake\n"), events)
|
||
# Сначала дожидаемся идущей записи, потом пишем свежий текст.
|
||
self.assertLess(
|
||
events.index(("stop", "raw preset save worker")),
|
||
events.index(("save", "My.txt", "--filter-tcp=443\n--lua-desync=fake\n")),
|
||
)
|
||
|
||
def test_raw_preset_cleanup_skips_sync_write_after_forced_termination(self) -> None:
|
||
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
|
||
|
||
events: list = []
|
||
page = self._make_closing_raw_editor_page(publish_pending=True, events=events)
|
||
page._raw_save_runtime.stop = Mock(return_value=True)
|
||
|
||
PresetRawEditorPage.cleanup(page)
|
||
|
||
# Прерванный поток мог держать замки записи: синхронная запись
|
||
# подвесила бы закрытие окна.
|
||
self.assertFalse([event for event in events if event[0] == "save"])
|
||
|
||
def test_raw_preset_cleanup_does_not_touch_file_without_local_edits(self) -> None:
|
||
from presets.ui.common.preset_subpage_base import PresetRawEditorPage
|
||
|
||
events: list = []
|
||
page = self._make_closing_raw_editor_page(publish_pending=False, events=events)
|
||
|
||
PresetRawEditorPage.cleanup(page)
|
||
|
||
# Без своих правок редактор может держать устаревший текст — писать
|
||
# его значило бы затереть правку, сделанную снаружи.
|
||
self.assertFalse([event for event in events if event[0] == "save"])
|
||
|
||
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.metaGroup = _Label()
|
||
page.statusIcon = Mock()
|
||
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, "Активный пресет")
|
||
# Включённый пресет отмечен тем же значком, что и кнопка «Сделать активным».
|
||
from qfluentwidgets import FluentIcon
|
||
|
||
page.statusIcon.setIcon.assert_called_with(FluentIcon.ACCEPT)
|
||
self.assertFalse(page.metaGroup.visible)
|
||
|
||
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()
|