zapretgui/tests/test_hosts_page_draft.py

862 lines
35 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.

"""Страница 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()