All checks were successful
Zapret source guards / validate (push) Successful in 7s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
862 lines
35 KiB
Python
862 lines
35 KiB
Python
"""Страница Hosts: разбор блоков, снимок, черновик и запись одной кнопкой."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import unittest
|
||
from types import SimpleNamespace
|
||
from unittest.mock import patch
|
||
|
||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||
|
||
from PyQt6 import QtCore
|
||
from PyQt6.QtCore import QPoint
|
||
from PyQt6.QtWidgets import QApplication
|
||
|
||
from hosts.draft import HostsDraft
|
||
from hosts.hosts_blocks import (
|
||
BLOCK_ADOBE,
|
||
BLOCK_MAX,
|
||
BLOCK_TELEGRAM,
|
||
BLOCK_USER,
|
||
BLOCK_ZAPRETGUI,
|
||
parse_hosts_blocks,
|
||
)
|
||
from hosts.page_snapshot import (
|
||
CATEGORY_DIRECT,
|
||
CATEGORY_OTHER,
|
||
HostsPageSnapshot,
|
||
HostsServiceEntry,
|
||
build_page_snapshot,
|
||
)
|
||
|
||
|
||
BEGIN = "# >>> zapretgui:hosts managed begin >>>"
|
||
END = "# <<< zapretgui:hosts managed end <<<"
|
||
|
||
|
||
def _managed(*lines: str) -> str:
|
||
return "\n".join([BEGIN, "# Generated by ZapretGUI. Do not edit this block manually.", *lines, END, ""])
|
||
|
||
|
||
# ── поддельный каталог ─────────────────────────────────────────
|
||
|
||
# Alpha: адреса p2 = адреса p1 и ещё один (как comss_dns у Truth Social).
|
||
# Beta: у p1 и p2 адреса одинаковые.
|
||
_ROWS = {
|
||
("Alpha", "p1"): [("alpha.example", "1.1.1.1")],
|
||
("Alpha", "p2"): [("alpha.example", "1.1.1.1"), ("alpha.example", "2.2.2.2")],
|
||
("Beta", "p1"): [("beta.example", "3.3.3.3")],
|
||
("Beta", "p2"): [("beta.example", "3.3.3.3")],
|
||
("Direct", "hosts"): [("direct.example", "4.4.4.4"), ("direct.example", "2a00::4")],
|
||
}
|
||
|
||
|
||
def _candidates(service: str, profile: str) -> dict[str, list[str]]:
|
||
result: dict[str, list[str]] = {}
|
||
for domain, ip in _ROWS[(service, profile)]:
|
||
result.setdefault(domain, []).append(ip)
|
||
return result
|
||
|
||
|
||
_PROFILE_INDEX = {
|
||
"dns_profiles": ["hosts", "p1", "p2"],
|
||
"dns_profile_names": {"hosts": "Вкл. (активировать hosts)", "p1": "Профиль 1", "p2": "Профиль 2"},
|
||
"services": ["Direct", "Alpha", "Beta"],
|
||
"available_by_service": {"Direct": ["hosts"], "Alpha": ["p1", "p2"], "Beta": ["p1", "p2"]},
|
||
"has_proxy_by_service": {"Direct": False, "Alpha": True, "Beta": True},
|
||
"category_by_service": {"Alpha": "other", "Beta": "other"},
|
||
"icon_by_service": {},
|
||
"service_id_by_name": {},
|
||
"direct_profile": "hosts",
|
||
"domain_names_by_service": {
|
||
"Direct": ["direct.example"],
|
||
"Alpha": ["alpha.example"],
|
||
"Beta": ["beta.example"],
|
||
},
|
||
"profile_domain_ip_candidates_by_service": {
|
||
"Direct": {"hosts": _candidates("Direct", "hosts")},
|
||
"Alpha": {"p1": _candidates("Alpha", "p1"), "p2": _candidates("Alpha", "p2")},
|
||
"Beta": {"p1": _candidates("Beta", "p1"), "p2": _candidates("Beta", "p2")},
|
||
},
|
||
}
|
||
|
||
|
||
def _fake_catalog():
|
||
return (
|
||
patch("hosts.proxy_domains.get_services_profile_index", return_value=_PROFILE_INDEX),
|
||
patch("hosts.proxy_domains.get_service_domain_ip_rows", side_effect=lambda s, p: list(_ROWS.get((s, p), []))),
|
||
patch(
|
||
"hosts.proxy_domains.get_dns_profile_display_name",
|
||
side_effect=lambda p: _PROFILE_INDEX["dns_profile_names"].get(p, p),
|
||
),
|
||
)
|
||
|
||
|
||
def _snapshot(hosts_text: str, *, saved: dict[str, str] | None = None, ipv6: bool = True) -> HostsPageSnapshot:
|
||
first, second, third = _fake_catalog()
|
||
with first, second, third:
|
||
return build_page_snapshot(
|
||
hosts_text=hosts_text,
|
||
hosts_path="hosts",
|
||
hosts_exists=True,
|
||
readable=True,
|
||
read_only=False,
|
||
ipv6_available=ipv6,
|
||
saved_selection=saved,
|
||
)
|
||
|
||
|
||
def _written_block(selection: dict[str, str], *, ipv6: bool = True) -> str:
|
||
"""Блок ровно так, как его пишет HostsManager для этого выбора."""
|
||
from hosts import hosts as hosts_module
|
||
|
||
with patch.object(hosts_module, "get_service_domain_ip_rows", side_effect=lambda s, p: list(_ROWS[(s, p)])):
|
||
rows, _requested = hosts_module._build_service_selection_rows(selection)
|
||
rows, _had = hosts_module._filter_desired_hosts_rows(rows, allow_ipv6=ipv6)
|
||
return _managed(*(f"{ip} {domain}" for domain, ip in rows))
|
||
|
||
|
||
class HostsBlocksParseTests(unittest.TestCase):
|
||
def test_hosts_text_is_split_by_owner(self) -> None:
|
||
text = "\n".join(
|
||
[
|
||
"# comment",
|
||
"10.0.0.1 manual.example",
|
||
BEGIN,
|
||
"2.2.2.2 managed.example",
|
||
END,
|
||
"# --- Telegram Proxy (auto-managed by Zapret 2 GUI) ---",
|
||
"127.0.0.1 web.telegram.org",
|
||
"",
|
||
"# MAX BLOCKED BY ZAPRET GUI",
|
||
"0.0.0.0 max.ru",
|
||
"# END MAX BLOCK",
|
||
"# Adobe Activation Block",
|
||
"# Generated by Zapret-WinGUI",
|
||
"0.0.0.0 lmlicenses.wip4.adobe.com",
|
||
"",
|
||
"10.0.0.2 another.example",
|
||
]
|
||
)
|
||
|
||
blocks = {block.kind: block.lines for block in parse_hosts_blocks(text)}
|
||
|
||
self.assertEqual(blocks[BLOCK_ZAPRETGUI], ("2.2.2.2 managed.example",))
|
||
self.assertEqual(blocks[BLOCK_TELEGRAM], ("127.0.0.1 web.telegram.org",))
|
||
self.assertEqual(blocks[BLOCK_MAX], ("0.0.0.0 max.ru",))
|
||
# У блока Adobe нет строки конца: он кончается на первой пустой строке.
|
||
self.assertEqual(blocks[BLOCK_ADOBE], ("0.0.0.0 lmlicenses.wip4.adobe.com",))
|
||
self.assertEqual(blocks[BLOCK_USER], ("10.0.0.1 manual.example", "10.0.0.2 another.example"))
|
||
|
||
def test_managed_block_keeps_every_address_of_a_domain(self) -> None:
|
||
snapshot = _snapshot(_written_block({"Alpha": "p2"}))
|
||
|
||
block = snapshot.block(BLOCK_ZAPRETGUI)
|
||
self.assertEqual(block.lines, ("1.1.1.1 alpha.example", "2.2.2.2 alpha.example"))
|
||
|
||
|
||
class HostsLineClassifierTests(unittest.TestCase):
|
||
def test_every_line_gets_owner_and_role(self) -> None:
|
||
from hosts.hosts_blocks import ROLE_BLANK, ROLE_COMMENT, ROLE_ENTRY, ROLE_MARKER, STATE_NONE, classify_hosts_line
|
||
|
||
lines = [
|
||
"# windows comment",
|
||
BEGIN,
|
||
"# Generated by ZapretGUI.",
|
||
"2.2.2.2 managed.example",
|
||
END,
|
||
"# Adobe Block",
|
||
"0.0.0.0 adobe.example",
|
||
"",
|
||
"10.0.0.1 manual.example # мой комментарий",
|
||
]
|
||
state = STATE_NONE
|
||
result = []
|
||
for line in lines:
|
||
kind, role, state = classify_hosts_line(line, state)
|
||
result.append((kind, role))
|
||
|
||
self.assertEqual(
|
||
result,
|
||
[
|
||
(None, ROLE_COMMENT),
|
||
(BLOCK_ZAPRETGUI, ROLE_MARKER),
|
||
(BLOCK_ZAPRETGUI, ROLE_COMMENT),
|
||
(BLOCK_ZAPRETGUI, ROLE_ENTRY),
|
||
(BLOCK_ZAPRETGUI, ROLE_MARKER),
|
||
(BLOCK_ADOBE, ROLE_MARKER),
|
||
(BLOCK_ADOBE, ROLE_ENTRY),
|
||
(None, ROLE_BLANK),
|
||
(BLOCK_USER, ROLE_ENTRY),
|
||
],
|
||
)
|
||
|
||
|
||
class HostsPageSnapshotTests(unittest.TestCase):
|
||
def test_every_written_selection_is_read_back_the_same(self) -> None:
|
||
for selection in (
|
||
{"Alpha": "p1"},
|
||
{"Alpha": "p2"},
|
||
{"Beta": "p1"},
|
||
{"Beta": "p2"},
|
||
{"Direct": "hosts", "Alpha": "p2", "Beta": "p2"},
|
||
):
|
||
with self.subTest(selection=selection):
|
||
snapshot = _snapshot(_written_block(selection), saved=selection)
|
||
current = {entry.name: entry.current for entry in snapshot.services if entry.current}
|
||
self.assertEqual(current, selection)
|
||
|
||
def test_fuller_profile_wins_even_without_saved_selection(self) -> None:
|
||
# Адреса p1 — часть адресов p2: без выбора из настроек всё равно p2.
|
||
snapshot = _snapshot(_written_block({"Alpha": "p2"}))
|
||
|
||
self.assertEqual(snapshot.service("Alpha").current, "p2")
|
||
|
||
def test_snapshot_reads_hosts_without_writing(self) -> None:
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
from hosts import hosts as hosts_module
|
||
from hosts import page_snapshot
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
missing = Path(tmp) / "etc" / "hosts"
|
||
first, second, third = _fake_catalog()
|
||
with (
|
||
first,
|
||
second,
|
||
third,
|
||
patch.object(hosts_module, "HOSTS_PATH", missing),
|
||
patch("hosts.proxy_domains.get_hosts_catalog_signature", return_value=None),
|
||
patch("hosts.proxy_domains.load_user_hosts_selection", return_value={}),
|
||
patch("hosts.ipv6_detection.is_ipv6_available", return_value=True),
|
||
patch.object(page_snapshot, "_SNAPSHOT", None),
|
||
patch.object(hosts_module.HostsManager, "is_hosts_file_accessible") as write_probe,
|
||
):
|
||
hosts_module.invalidate_hosts_file_cache()
|
||
snapshot = page_snapshot.load_page_snapshot()
|
||
|
||
write_probe.assert_not_called()
|
||
self.assertFalse(snapshot.hosts_exists)
|
||
self.assertTrue(snapshot.readable)
|
||
self.assertFalse(missing.parent.exists())
|
||
hosts_module.invalidate_hosts_file_cache()
|
||
|
||
|
||
def _manual_snapshot(block_lines: tuple[str, ...] = (), user_lines: tuple[str, ...] = (), **kwargs) -> HostsPageSnapshot:
|
||
text_lines: list[str] = list(user_lines)
|
||
if block_lines:
|
||
text_lines = [BEGIN, *block_lines, END, *user_lines]
|
||
return HostsPageSnapshot(
|
||
services=(
|
||
HostsServiceEntry("Direct", CATEGORY_DIRECT, "fa5s.globe", None, ("hosts",), kwargs.pop("direct", None)),
|
||
HostsServiceEntry("Alpha", CATEGORY_OTHER, "fa5s.globe", None, ("p1", "p2"), kwargs.pop("alpha", None)),
|
||
HostsServiceEntry("Beta", CATEGORY_OTHER, "fa5s.globe", None, ("p1",), kwargs.pop("beta", None)),
|
||
),
|
||
dns_profiles=(("p1", "Профиль 1"), ("p2", "Профиль 2")),
|
||
direct_profile="hosts",
|
||
rows={key: tuple(value) for key, value in _ROWS.items()},
|
||
blocks=tuple(parse_hosts_blocks("\n".join(text_lines))),
|
||
**kwargs,
|
||
)
|
||
|
||
|
||
class HostsDraftTests(unittest.TestCase):
|
||
def test_clicks_change_only_the_draft_until_values_match_again(self) -> None:
|
||
draft = HostsDraft(_manual_snapshot())
|
||
|
||
self.assertFalse(draft.has_user_changes())
|
||
self.assertTrue(draft.set("Alpha", "p1"))
|
||
self.assertTrue(draft.has_user_changes())
|
||
self.assertTrue(draft.is_changed("Alpha"))
|
||
self.assertTrue(draft.has_user_changes())
|
||
self.assertEqual(draft.selection(), {"Alpha": "p1"})
|
||
|
||
self.assertTrue(draft.set("Alpha", None))
|
||
self.assertFalse(draft.has_user_changes())
|
||
|
||
def test_unknown_profile_is_refused(self) -> None:
|
||
draft = HostsDraft(_manual_snapshot())
|
||
|
||
self.assertFalse(draft.set("Beta", "p2"))
|
||
self.assertFalse(draft.has_user_changes())
|
||
|
||
def test_dns_for_all_skips_services_without_that_profile(self) -> None:
|
||
draft = HostsDraft(_manual_snapshot())
|
||
|
||
changed, skipped = draft.set_all_dns("p2")
|
||
|
||
self.assertEqual((changed, skipped), (1, ["Beta"]))
|
||
self.assertEqual(draft.value("Direct"), None)
|
||
self.assertEqual(draft.selection(), {"Alpha": "p2"})
|
||
|
||
def test_rebase_keeps_only_changes_not_yet_written(self) -> None:
|
||
draft = HostsDraft(_manual_snapshot())
|
||
draft.set("Alpha", "p1")
|
||
draft.set("Beta", "p1")
|
||
|
||
draft.rebase(_manual_snapshot(block_lines=("1.1.1.1 alpha.example",), alpha="p1"))
|
||
|
||
self.assertFalse(draft.is_changed("Alpha"))
|
||
self.assertTrue(draft.is_changed("Beta"))
|
||
|
||
def test_adobe_toggle_is_part_of_the_draft(self) -> None:
|
||
draft = HostsDraft(_manual_snapshot())
|
||
|
||
self.assertTrue(draft.set_adobe(True))
|
||
self.assertTrue(draft.adobe_changed)
|
||
self.assertTrue(draft.set_adobe(False))
|
||
self.assertFalse(draft.has_user_changes())
|
||
|
||
|
||
|
||
class HostsPageTests(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls) -> None:
|
||
cls._app = QApplication.instance() or QApplication([])
|
||
|
||
def _page(self, snapshot: HostsPageSnapshot):
|
||
from hosts.ui.page import HostsPage
|
||
|
||
feature = SimpleNamespace(
|
||
peek_page_snapshot=lambda: snapshot,
|
||
create_snapshot_worker=lambda request_id, parent=None: None,
|
||
create_apply_worker=lambda request_id, selection, adobe, parent=None: None,
|
||
)
|
||
self.opened_file_page: list[bool] = []
|
||
page = HostsPage(
|
||
deps=SimpleNamespace(hosts_feature=feature, open_file_page=lambda: self.opened_file_page.append(True))
|
||
)
|
||
self.addCleanup(page.deleteLater)
|
||
# Запись не запускаем по-настоящему: запоминаем, что ушло бы в файл.
|
||
self.writes: list[dict] = []
|
||
self.write_callbacks: list[dict] = []
|
||
|
||
def _start(**kwargs):
|
||
self.write_callbacks.append(kwargs)
|
||
worker_factory = kwargs["worker_factory"]
|
||
captured = {}
|
||
page._hosts.create_apply_worker = lambda request_id, selection, adobe, parent=None: captured.update(
|
||
selection=dict(selection), adobe=adobe
|
||
)
|
||
worker_factory(len(self.write_callbacks))
|
||
self.writes.append(captured)
|
||
return True
|
||
|
||
patcher = patch.object(page._apply_runtime, "start_qthread_worker", side_effect=_start)
|
||
patcher.start()
|
||
self.addCleanup(patcher.stop)
|
||
reread = patch.object(page, "_request_snapshot")
|
||
reread.start()
|
||
self.addCleanup(reread.stop)
|
||
current = patch.object(page._apply_runtime, "is_current", return_value=True)
|
||
current.start()
|
||
self.addCleanup(current.stop)
|
||
page._set_snapshot(snapshot)
|
||
page.resize(1000, 700)
|
||
page.show()
|
||
QApplication.processEvents()
|
||
# Вход страницы (выплывание) проверяется отдельно; здесь — готовая страница.
|
||
page.tiles.finish_float_in()
|
||
return page
|
||
|
||
def _finish_write(self, page, *, success: bool = True, snapshot=None, message: str = "") -> None:
|
||
callback = self.write_callbacks[-1]["on_loaded"]
|
||
callback(len(self.write_callbacks), SimpleNamespace(success=success, snapshot=snapshot, message=message))
|
||
|
||
def test_click_on_tile_writes_hosts_right_away(self) -> None:
|
||
page = self._page(_manual_snapshot())
|
||
|
||
page._on_tile_activated("Direct")
|
||
|
||
self.assertEqual(self.writes, [{"selection": {"Direct": "hosts"}, "adobe": None}])
|
||
self.assertTrue(page._applying)
|
||
self._finish_write(page, snapshot=_manual_snapshot(block_lines=("4.4.4.4 direct.example",), direct="hosts"))
|
||
self.assertFalse(page._applying)
|
||
self.assertFalse(page._draft.has_user_changes())
|
||
self.assertTrue(page.tiles.tiles()[1].is_on)
|
||
|
||
def test_clicks_during_write_are_written_next_in_one_go(self) -> None:
|
||
page = self._page(_manual_snapshot())
|
||
|
||
page._on_tile_activated("Direct")
|
||
page._set_service_profile("Alpha", "p1")
|
||
page._set_service_profile("Beta", "p1")
|
||
|
||
self.assertEqual(len(self.writes), 1)
|
||
self._finish_write(page, snapshot=_manual_snapshot(block_lines=("4.4.4.4 direct.example",), direct="hosts"))
|
||
self.assertEqual(len(self.writes), 2)
|
||
self.assertEqual(self.writes[1]["selection"], {"Direct": "hosts", "Alpha": "p1", "Beta": "p1"})
|
||
|
||
def test_failed_write_returns_tiles_to_file_state(self) -> None:
|
||
page = self._page(_manual_snapshot())
|
||
page._on_tile_activated("Direct")
|
||
|
||
with patch("hosts.ui.page.InfoBar") as info_bar:
|
||
self._finish_write(page, success=False, message="только чтение")
|
||
|
||
info_bar.error.assert_called_once()
|
||
self.assertFalse(page._draft.has_user_changes())
|
||
self.assertFalse(page._draft.value("Direct"))
|
||
|
||
def test_turn_all_off_removes_extra_block_lines_too(self) -> None:
|
||
page = self._page(_manual_snapshot(block_lines=("5.5.5.5 gone.example",)))
|
||
|
||
page._turn_all_off()
|
||
|
||
self.assertEqual(self.writes, [{"selection": {}, "adobe": None}])
|
||
|
||
def test_adobe_tile_writes_the_block(self) -> None:
|
||
from hosts.ui.page import ADOBE_TILE_KEY
|
||
|
||
page = self._page(_manual_snapshot())
|
||
|
||
page._on_tile_activated(ADOBE_TILE_KEY)
|
||
|
||
self.assertEqual(self.writes[0]["adobe"], True)
|
||
|
||
def test_search_opens_with_ctrl_f_and_hides_with_escape(self) -> None:
|
||
from PyQt6.QtCore import Qt
|
||
from PyQt6.QtTest import QTest
|
||
|
||
page = self._page(_manual_snapshot())
|
||
self.assertFalse(page.search_edit.isVisible())
|
||
|
||
page.tiles.setFocus()
|
||
QTest.keyClick(page.tiles, Qt.Key.Key_F, Qt.KeyboardModifier.ControlModifier)
|
||
self.assertTrue(page.search_edit.isVisible())
|
||
|
||
page.search_edit.setText("alp")
|
||
self.assertEqual([tile.key for tile in page.tiles.tiles() if tile.kind == "tile"], ["Alpha"])
|
||
QTest.keyClick(page.search_edit, Qt.Key.Key_Escape)
|
||
self.assertFalse(page.search_edit.isVisible())
|
||
self.assertIn("Beta", [tile.key for tile in page.tiles.tiles()])
|
||
|
||
def test_summary_stays_visible_when_tiles_are_scrolled(self) -> None:
|
||
page = self._page(_manual_snapshot())
|
||
with patch("hosts.ui.page.are_live_animations_enabled", return_value=False):
|
||
page.verticalScrollBar().setValue(200)
|
||
QApplication.processEvents()
|
||
# Сводка не прячется анимацией: уезжает вверх вместе со страницей.
|
||
self.assertFalse(page.top_panel.isHidden())
|
||
self.assertEqual(page.top_panel.maximumHeight(), 16777215)
|
||
|
||
def test_write_status_is_one_line_pill_that_stays(self) -> None:
|
||
page = self._page(_manual_snapshot())
|
||
status = page.summary_status
|
||
self.assertTrue(status.isHidden())
|
||
|
||
with patch("hosts.ui.page.are_live_animations_enabled", return_value=False):
|
||
page._applying = True
|
||
page._render_summary()
|
||
self.assertFalse(status.isHidden())
|
||
self.assertEqual(status.text(), "Записываю…")
|
||
|
||
page._applying = False
|
||
page._just_written = True
|
||
page._render_summary()
|
||
|
||
self.assertFalse(status.isHidden())
|
||
self.assertTrue(status.text().startswith("Записано"))
|
||
self.assertFalse(status.label.wordWrap())
|
||
# Надпись целиком, в одну строку, без обрезки по ширине.
|
||
self.assertGreaterEqual(status.sizeHint().width(), status.label.fontMetrics().horizontalAdvance(status.text()))
|
||
|
||
def test_tiles_use_switch_for_direct_and_profile_icons_for_dns(self) -> None:
|
||
page = self._page(_manual_snapshot(beta="p1"))
|
||
tiles = {tile.key: tile for tile in page.tiles.tiles() if tile.kind == "tile"}
|
||
|
||
self.assertTrue(tiles["Direct"].has_switch)
|
||
self.assertFalse(tiles["Direct"].has_choices)
|
||
self.assertFalse(tiles["Alpha"].has_switch)
|
||
# Иконки всех профилей по порядку; у Beta профиля p2 нет — место пустое.
|
||
self.assertEqual([c.profile_id for c in tiles["Alpha"].choices], ["p1", "p2"])
|
||
self.assertEqual([c.available for c in tiles["Beta"].choices], [True, False])
|
||
self.assertEqual([c.label for c in tiles["Alpha"].choices], ["Профиль 1", "Профиль 2"])
|
||
self.assertIsNone(tiles["Alpha"].selected)
|
||
self.assertEqual(tiles["Alpha"].state_text, "Выкл.")
|
||
self.assertEqual(tiles["Beta"].selected, "p1")
|
||
self.assertEqual(tiles["Beta"].state_text, "Профиль 1")
|
||
self.assertTrue(tiles["Beta"].is_on)
|
||
# У группы DNS-сервисов — легенда иконок.
|
||
groups = [tile for tile in page.tiles.tiles() if tile.kind == "group"]
|
||
self.assertTrue(any(group.legend for group in groups))
|
||
|
||
def _click(self, page, point) -> None:
|
||
from PyQt6.QtCore import Qt as _Qt
|
||
from PyQt6.QtTest import QTest as _QTest
|
||
|
||
_QTest.mouseClick(page.tiles, _Qt.MouseButton.LeftButton, _Qt.KeyboardModifier.NoModifier, point)
|
||
QApplication.processEvents()
|
||
|
||
def test_click_on_profile_icon_writes_and_second_click_turns_off(self) -> None:
|
||
page = self._page(_manual_snapshot())
|
||
icon = page.tiles.choice_rect("Alpha", "p2")
|
||
self.assertFalse(icon.isNull())
|
||
|
||
self._click(page, icon.center())
|
||
self.assertEqual(self.writes[-1]["selection"], {"Alpha": "p2"})
|
||
|
||
self._finish_write(page, snapshot=_manual_snapshot(block_lines=("1.1.1.1 alpha.example", "2.2.2.2 alpha.example"), alpha="p2"))
|
||
self._click(page, page.tiles.choice_rect("Alpha", "p2").center())
|
||
self.assertEqual(self.writes[-1]["selection"], {})
|
||
|
||
def test_click_on_tile_body_or_missing_profile_does_nothing(self) -> None:
|
||
page = self._page(_manual_snapshot())
|
||
rect = page.tiles.tile_rect("Alpha")
|
||
self._click(page, rect.topRight() + QPoint(-20, 20))
|
||
beta = page.tiles.tile_rect("Beta")
|
||
missing = page.tiles._choice_rect(beta, 1)
|
||
self._click(page, missing.center())
|
||
self.assertEqual(self.writes, [])
|
||
|
||
def test_mouse_click_does_not_scroll_the_page(self) -> None:
|
||
page = self._page(_manual_snapshot())
|
||
page.resize(1000, 260)
|
||
QApplication.processEvents()
|
||
bar = page.verticalScrollBar()
|
||
self.assertGreater(bar.maximum(), 0)
|
||
bar.setValue(0)
|
||
rect = page.tiles.tile_rect("Beta")
|
||
page.tiles.setFocus(QtCore.Qt.FocusReason.MouseFocusReason)
|
||
self._click(page, page.tiles._choice_rect(rect, 0).center())
|
||
self.assertEqual(bar.value(), 0)
|
||
|
||
def test_enter_cycles_profiles_on_dns_tile(self) -> None:
|
||
from PyQt6.QtCore import Qt as _Qt
|
||
from PyQt6.QtTest import QTest as _QTest
|
||
|
||
page = self._page(_manual_snapshot(alpha="p1"))
|
||
page.tiles._set_cursor(page.tiles._index_of("Alpha"), ensure_visible=False)
|
||
chosen = []
|
||
page.tiles.profile_chosen.connect(lambda key, value: chosen.append((key, value)))
|
||
_QTest.keyClick(page.tiles, _Qt.Key.Key_Return)
|
||
self.assertEqual(chosen, [("Alpha", "p2")])
|
||
|
||
def _grid_with(self, **tile_kwargs):
|
||
from hosts.ui.services_tiles import HostsChoice, HostsTile, HostsTilesGrid
|
||
|
||
choices = (HostsChoice("p1", "Профиль 1", "fa5s.cat", "#F59E0B"), HostsChoice("p2", "Профиль 2", "fa5s.bug", "#E5484D"))
|
||
grid = HostsTilesGrid()
|
||
self.addCleanup(grid.deleteLater)
|
||
clock = [100.0]
|
||
grid._now = lambda: clock[0]
|
||
grid.resize(600, 100)
|
||
|
||
def tiles(selected=None, on=False):
|
||
return [
|
||
HostsTile("tile", "Alpha", key="Alpha", choices=choices, selected=selected, is_on=bool(selected)),
|
||
HostsTile("tile", "Direct", key="Direct", has_switch=True, is_on=on),
|
||
]
|
||
|
||
return grid, tiles, clock
|
||
|
||
def test_profile_change_spins_only_that_tile_and_stops(self) -> None:
|
||
grid, tiles, clock = self._grid_with()
|
||
with patch("hosts.ui.services_tiles.are_live_animations_enabled", return_value=True):
|
||
grid.set_tiles(tiles())
|
||
# Первая загрузка не анимируется.
|
||
self.assertEqual(grid._changes, {})
|
||
self.assertFalse(grid._frames.isActive())
|
||
|
||
grid.set_tiles(tiles(selected="p2"))
|
||
self.assertEqual(set(grid._changes), {"Alpha"})
|
||
self.assertEqual(grid._changes["Alpha"].kind, "pick")
|
||
self.assertTrue(grid._frames.isActive())
|
||
|
||
# Посреди анимации плитка рисуется по кадрам, без кэша.
|
||
clock[0] += grid.CHANGE_SECONDS / 2
|
||
grid.grab()
|
||
drawn = grid.rendered_tiles
|
||
grid.grab()
|
||
self.assertGreater(grid.rendered_tiles, drawn)
|
||
|
||
clock[0] += grid.CHANGE_SECONDS
|
||
grid._on_frame()
|
||
self.assertEqual(grid._changes, {})
|
||
self.assertFalse(grid._frames.isActive())
|
||
grid.grab()
|
||
drawn = grid.rendered_tiles
|
||
grid.grab()
|
||
self.assertEqual(grid.rendered_tiles, drawn)
|
||
|
||
grid.set_tiles(tiles(selected=None, on=True))
|
||
self.assertEqual(grid._changes["Alpha"].kind, "drop")
|
||
self.assertEqual(grid._changes["Direct"].kind, "switch")
|
||
|
||
def test_service_icon_sways_and_glows_on_switch(self) -> None:
|
||
grid, tiles, clock = self._grid_with()
|
||
with patch("hosts.ui.services_tiles.are_live_animations_enabled", return_value=True):
|
||
grid.set_tiles(tiles(on=True))
|
||
grid.set_tiles(tiles(selected="p1", on=False))
|
||
by_key = {tile.key: tile for tile in grid.tiles()}
|
||
|
||
clock[0] += grid.CHANGE_SECONDS * 0.1
|
||
angle, glow, color = grid._icon_motion(by_key["Alpha"], clock[0])
|
||
self.assertNotEqual(angle, 0.0)
|
||
self.assertGreater(glow, 0)
|
||
angle, glow, color = grid._icon_motion(by_key["Direct"], clock[0])
|
||
self.assertNotEqual(angle, 0.0)
|
||
# Тумблер выключили — свечение серое.
|
||
self.assertEqual(color.name(), "#a0a0a0")
|
||
|
||
clock[0] += grid.CHANGE_SECONDS
|
||
self.assertEqual(grid._icon_motion(by_key["Alpha"], clock[0])[:2], (0.0, 0))
|
||
|
||
def test_no_change_animation_when_live_animations_are_off(self) -> None:
|
||
grid, tiles, _clock = self._grid_with()
|
||
with patch("hosts.ui.services_tiles.are_live_animations_enabled", return_value=False):
|
||
grid.set_tiles(tiles())
|
||
grid.set_tiles(tiles(selected="p1", on=True))
|
||
self.assertEqual(grid._changes, {})
|
||
self.assertFalse(grid._frames.isActive())
|
||
|
||
def test_unchanged_tiles_are_not_redrawn(self) -> None:
|
||
page = self._page(_manual_snapshot())
|
||
grid = page.tiles
|
||
grid.grab()
|
||
drawn = grid.rendered_tiles
|
||
self.assertGreater(drawn, 0)
|
||
grid.grab()
|
||
self.assertEqual(grid.rendered_tiles, drawn)
|
||
# Щелчок по иконке перерисовывает только изменившуюся плитку.
|
||
with patch("hosts.ui.services_tiles.are_live_animations_enabled", return_value=False):
|
||
self._click(page, grid.choice_rect("Alpha", "p1").center())
|
||
before = grid.rendered_tiles
|
||
grid.grab()
|
||
self.assertLessEqual(grid.rendered_tiles - before, 2)
|
||
|
||
def test_tile_pixmap_cache_does_not_grow_while_window_is_resized(self) -> None:
|
||
page = self._page(_manual_snapshot())
|
||
grid = page.tiles
|
||
grid.grab()
|
||
one_width = len(grid._pixmaps)
|
||
self.assertGreater(one_width, 0)
|
||
for width in range(620, 1020, 20):
|
||
grid.resize(width, grid.height())
|
||
grid.grab()
|
||
self.assertLessEqual(len(grid._pixmaps), one_width)
|
||
|
||
def test_note_in_brackets_moves_to_second_line(self) -> None:
|
||
from hosts.ui.services_tiles import split_service_title
|
||
|
||
self.assertEqual(
|
||
split_service_title("YouTube (иногда может не работать с ним!)"),
|
||
("YouTube", "иногда может не работать с ним!"),
|
||
)
|
||
self.assertEqual(split_service_title("Discord"), ("Discord", ""))
|
||
|
||
def test_page_scrolls_as_a_whole(self) -> None:
|
||
page = self._page(_manual_snapshot())
|
||
page.resize(900, 220)
|
||
page.show()
|
||
QApplication.processEvents()
|
||
|
||
# Как все страницы: прокручивается сама страница, сводка уезжает
|
||
# вверх вместе с плитками, отдельной прокрутки у плиток нет.
|
||
from PyQt6.QtWidgets import QAbstractScrollArea
|
||
|
||
self.assertGreater(page.verticalScrollBar().maximum(), 0)
|
||
parent = page.tiles.parentWidget()
|
||
while parent is not None and parent is not page:
|
||
self.assertNotIsInstance(parent, QAbstractScrollArea)
|
||
parent = parent.parentWidget()
|
||
self.assertIs(parent, page)
|
||
self.assertTrue(page.content.isAncestorOf(page.tiles))
|
||
self.assertTrue(page.content.isAncestorOf(page.summary_card))
|
||
|
||
def test_file_button_opens_file_page(self) -> None:
|
||
page = self._page(_manual_snapshot())
|
||
|
||
page.file_button.click()
|
||
|
||
self.assertEqual(self.opened_file_page, [True])
|
||
|
||
def test_snapshot_read_before_write_does_not_replace_fresh_result(self) -> None:
|
||
page = self._page(_manual_snapshot())
|
||
fresh = page._snapshot
|
||
page._snapshot_epoch = page._write_epoch
|
||
page._write_epoch += 1 # запись прошла, пока шло чтение.
|
||
|
||
with patch.object(page._snapshot_runtime, "is_current", return_value=True):
|
||
page._on_snapshot_loaded(1, _manual_snapshot(block_lines=("3.3.3.3 beta.example",), beta="p1"))
|
||
|
||
self.assertIs(page._snapshot, fresh)
|
||
self.assertTrue(page._snapshot_pending)
|
||
|
||
def test_page_receives_narrow_feature_not_services(self) -> None:
|
||
import dataclasses
|
||
|
||
from app.feature_facades.hosts import HostsFeature
|
||
|
||
names = {field.name for field in dataclasses.fields(HostsFeature)}
|
||
self.assertTrue({"peek_page_snapshot", "create_snapshot_worker", "create_apply_worker"} <= names)
|
||
self.assertFalse(any("manager" in name or "runtime" in name for name in names))
|
||
|
||
|
||
class HostsFileCommandsTests(unittest.TestCase):
|
||
def _hosts_path(self, content: bytes | None):
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
from hosts import hosts as hosts_module
|
||
|
||
tmp = tempfile.TemporaryDirectory()
|
||
self.addCleanup(tmp.cleanup)
|
||
path = Path(tmp.name) / "hosts"
|
||
if content is not None:
|
||
path.write_bytes(content)
|
||
patcher = patch.object(hosts_module, "HOSTS_PATH", path)
|
||
patcher.start()
|
||
self.addCleanup(patcher.stop)
|
||
self.addCleanup(hosts_module.invalidate_hosts_file_cache)
|
||
hosts_module.invalidate_hosts_file_cache()
|
||
return path
|
||
|
||
def test_repeated_writes_keep_a_single_bom(self) -> None:
|
||
from hosts import hosts as hosts_module
|
||
|
||
path = self._hosts_path("\ufeff\ufeff1.1.1.1 a.example\n".encode("utf-8"))
|
||
for _ in range(3):
|
||
hosts_module.invalidate_hosts_file_cache()
|
||
self.assertTrue(hosts_module.safe_write_hosts_file(hosts_module.safe_read_hosts_file()))
|
||
|
||
self.assertEqual(path.read_bytes(), "\ufeff1.1.1.1 a.example\n".encode("utf-8"))
|
||
|
||
def test_file_text_is_saved_as_is(self) -> None:
|
||
from hosts import commands as hosts_commands
|
||
|
||
path = self._hosts_path(b"1.1.1.1 a.example\n")
|
||
loaded = hosts_commands.load_hosts_text()
|
||
self.assertEqual(loaded.text, "1.1.1.1 a.example\n")
|
||
|
||
result = hosts_commands.save_hosts_text("# мой\n2.2.2.2 b.example")
|
||
|
||
self.assertTrue(result.success)
|
||
self.assertEqual(path.read_text(encoding="utf-8-sig"), "# мой\n2.2.2.2 b.example\n")
|
||
|
||
def test_read_only_file_is_not_saved(self) -> None:
|
||
import stat
|
||
|
||
from hosts import commands as hosts_commands
|
||
|
||
path = self._hosts_path(b"1.1.1.1 a.example\n")
|
||
os.chmod(path, stat.S_IREAD)
|
||
self.addCleanup(os.chmod, path, stat.S_IREAD | stat.S_IWRITE)
|
||
|
||
self.assertTrue(hosts_commands.load_hosts_text().read_only)
|
||
result = hosts_commands.save_hosts_text("2.2.2.2 b.example\n")
|
||
|
||
self.assertFalse(result.success)
|
||
self.assertEqual(path.read_bytes(), b"1.1.1.1 a.example\n")
|
||
|
||
def test_loading_missing_file_does_not_create_it(self) -> None:
|
||
from hosts import commands as hosts_commands
|
||
|
||
path = self._hosts_path(None)
|
||
|
||
loaded = hosts_commands.load_hosts_text()
|
||
|
||
self.assertFalse(loaded.exists)
|
||
self.assertEqual(loaded.text, "")
|
||
self.assertFalse(path.exists())
|
||
|
||
|
||
class HostsFilePageTests(unittest.TestCase):
|
||
TEXT = "\n".join(["# comment", BEGIN, "2.2.2.2 managed.example", END, "10.0.0.1 manual.example", ""])
|
||
|
||
@classmethod
|
||
def setUpClass(cls) -> None:
|
||
cls._app = QApplication.instance() or QApplication([])
|
||
|
||
def _page(self, *, read_only: bool = False):
|
||
from hosts.state import HostsFileText
|
||
from hosts.ui.file_page import HostsFilePage
|
||
|
||
self.opened_hosts_page: list[bool] = []
|
||
page = HostsFilePage(
|
||
deps=SimpleNamespace(
|
||
hosts_feature=SimpleNamespace(),
|
||
open_hosts_page=lambda: self.opened_hosts_page.append(True),
|
||
)
|
||
)
|
||
self.addCleanup(page.deleteLater)
|
||
page.resize(900, 700)
|
||
page.show()
|
||
page.apply_file_text(HostsFileText(text=self.TEXT, path="hosts", read_only=read_only))
|
||
QApplication.processEvents()
|
||
return page
|
||
|
||
def test_lines_are_colored_by_owner(self) -> None:
|
||
page = self._page()
|
||
highlighter = page.editor._highlighter
|
||
document = page.editor.document()
|
||
|
||
def color_of(line: int):
|
||
layout = document.findBlockByNumber(line).layout()
|
||
return layout.formats()[0].format.foreground().color().name()
|
||
|
||
managed, user = color_of(2), color_of(4)
|
||
self.assertNotEqual(managed, user)
|
||
self.assertEqual(managed, highlighter.format_for(BLOCK_ZAPRETGUI, "entry").foreground().color().name())
|
||
self.assertEqual(page.legend[BLOCK_ZAPRETGUI].label.text(), "ZapretGUI: 1")
|
||
self.assertEqual(page.legend[BLOCK_USER].label.text(), "Ваши строки: 1")
|
||
|
||
def test_editing_enables_save_and_revert_restores_text(self) -> None:
|
||
page = self._page()
|
||
self.assertFalse(page.save_button.isEnabled())
|
||
|
||
page.editor.appendPlainText("3.3.3.3 new.example")
|
||
|
||
self.assertTrue(page.save_button.isEnabled())
|
||
page._revert()
|
||
self.assertEqual(page.editor.toPlainText(), self.TEXT)
|
||
self.assertFalse(page.save_button.isEnabled())
|
||
|
||
def test_read_only_file_cannot_be_saved(self) -> None:
|
||
page = self._page(read_only=True)
|
||
page.editor.appendPlainText("3.3.3.3 new.example")
|
||
|
||
self.assertFalse(page.save_button.isEnabled())
|
||
self.assertTrue(page.notice_card.isVisible())
|
||
|
||
def test_saving_edited_zapretgui_block_warns_about_apply(self) -> None:
|
||
page = self._page()
|
||
page.editor.setPlainText(self.TEXT.replace("2.2.2.2", "9.9.9.9"))
|
||
text = page.editor.toPlainText()
|
||
|
||
with (
|
||
patch.object(page._save_runtime, "is_current", return_value=True),
|
||
patch("hosts.ui.file_page.InfoBar") as info_bar,
|
||
):
|
||
page._on_saved(1, SimpleNamespace(success=True), text, True)
|
||
|
||
info_bar.warning.assert_called_once()
|
||
self.assertFalse(page.is_dirty())
|
||
|
||
def test_search_bar_waits_for_ctrl_f(self) -> None:
|
||
page = self._page()
|
||
|
||
self.assertTrue(page.find_bar.isHidden())
|
||
page.find_controller.open_panel()
|
||
self.assertFalse(page.find_bar.isHidden())
|
||
|
||
def test_breadcrumb_leads_back_to_hosts_page(self) -> None:
|
||
page = self._page()
|
||
|
||
page._on_breadcrumb_item_changed("hosts")
|
||
|
||
self.assertEqual(self.opened_hosts_page, [True])
|
||
|
||
def test_page_is_registered_under_hosts(self) -> None:
|
||
from app.page_names import PageName
|
||
from ui.navigation.schema import PAGE_ROUTE_SPECS
|
||
|
||
spec = PAGE_ROUTE_SPECS[PageName.HOSTS_FILE]
|
||
self.assertEqual(spec.breadcrumb_parent, PageName.HOSTS)
|
||
self.assertTrue(spec.is_hidden)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|