zapret-kvn/tests/test_app_accent_widgets.py
loop-uh d5a4715771
Some checks failed
Windows project source guards / test (push) Has been cancelled
feat: use official Amnezia transport and organize runtime modules
2026-09-06 00:14:08 +03:00

339 lines
12 KiB
Python

"""Global accent theme tests — widget-level ACs (AC5, AC6, AC8, AC9).
Task: .agent/tasks/20260820-global-accent-theme.
Keep the ``test_app_*`` prefix: widget test modules must sort before
``tests/test_engine_process_stop.py`` which creates a bare QCoreApplication
at import time (see tests/test_app_nodes_page_view.py).
"""
from __future__ import annotations
import os
import re
import unittest
from pathlib import Path
from unittest import mock
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import QApplication
_existing = QApplication.instance()
if _existing is not None and not isinstance(_existing, QApplication):
raise RuntimeError(
"A bare QCoreApplication was created before test_app_accent_widgets "
"was imported; widget tests need a QApplication."
)
app = _existing or QApplication([])
from qfluentwidgets import qconfig, setTheme, setThemeColor, themeColor
from xray_fluent.profiles.models import AppSettings, Node, SecuritySettings
from xray_fluent.ui import theme
from xray_fluent.ui.nodes_table_delegate import NodesActivityDelegate
from xray_fluent.ui.nodes_table_model import COL_NAME, NodesTableModel
from xray_fluent.ui.settings_page import SettingsPage
from xray_fluent.ui.traffic_graph import DetailTrafficGraphWidget, TrafficGraphWidget
from xray_fluent.ui.zapret_page import ZapretPage
from xray_fluent.engines.zapret.manager import PresetInfo
REPO_ROOT = Path(__file__).resolve().parent.parent
UI_DIR = REPO_ROOT / "xray_fluent" / "ui"
class _AccentRestoreMixin(unittest.TestCase):
def setUp(self) -> None:
self._saved_theme = qconfig.themeMode.value
self._saved_accent = QColor(themeColor())
def tearDown(self) -> None:
setTheme(self._saved_theme)
setThemeColor(self._saved_accent)
QApplication.processEvents()
class NodesDelegateActiveFillTest(_AccentRestoreMixin):
"""AC5: soft accent fill for the active node row."""
def setUp(self) -> None:
super().setUp()
self.model = NodesTableModel()
self.nodes = [
Node(name="a", server="1.1.1.1", port=443, scheme="vless"),
Node(name="b", server="2.2.2.2", port=443, scheme="vless"),
]
self.model.set_nodes(self.nodes)
self.model.set_active_node_id(self.nodes[0].id)
def test_active_row_fill_matches_accent_soft_bg(self) -> None:
index = self.model.index(0, COL_NAME)
fill = NodesActivityDelegate.row_fill_color(index)
self.assertIsNotNone(fill)
self.assertEqual(fill.getRgb(), theme.accent_soft_bg().getRgb())
self.assertEqual(fill.alpha(), 38)
def test_inactive_row_has_no_fill(self) -> None:
index = self.model.index(1, COL_NAME)
self.assertIsNone(NodesActivityDelegate.row_fill_color(index))
def test_fill_follows_accent_change(self) -> None:
index = self.model.index(0, COL_NAME)
setThemeColor("#123456")
before = NodesActivityDelegate.row_fill_color(index).getRgb()
setThemeColor("#654321")
after = NodesActivityDelegate.row_fill_color(index).getRgb()
self.assertNotEqual(before, after)
self.assertEqual(after[:3], (0x65, 0x43, 0x21))
class TrafficGraphAccentRepaintTest(_AccentRestoreMixin):
"""AC6: traffic graphs schedule a repaint when only the accent changes."""
def test_compact_graph_updates_on_accent_change(self) -> None:
widget = TrafficGraphWidget()
try:
setThemeColor("#336699")
QApplication.processEvents()
with mock.patch.object(widget, "update") as update:
setThemeColor("#663399")
QApplication.processEvents()
update.assert_called()
finally:
widget.deleteLater()
QApplication.processEvents()
def test_detail_graph_updates_on_accent_change(self) -> None:
widget = DetailTrafficGraphWidget()
try:
setThemeColor("#336699")
QApplication.processEvents()
with mock.patch.object(widget, "update") as update:
setThemeColor("#663399")
QApplication.processEvents()
update.assert_called()
finally:
widget.deleteLater()
QApplication.processEvents()
class NodesPageAccentRepaintTest(_AccentRestoreMixin):
"""AC6: the nodes table viewport repaints when only the accent changes."""
def test_nodes_view_repaints_on_accent_change(self) -> None:
from xray_fluent.ui.nodes_page import NodesPage
page = NodesPage()
try:
setThemeColor("#336699")
QApplication.processEvents()
with mock.patch.object(page.table.viewport(), "update") as update:
setThemeColor("#663399")
QApplication.processEvents()
update.assert_called()
finally:
page.deleteLater()
QApplication.processEvents()
def _preset(name: str) -> PresetInfo:
return PresetInfo(
name=name,
description=f"desc {name}",
created="2026-08-20T00:00:00",
modified="2026-08-20T00:00:00",
arg_count=3,
file_path=Path(f"{name}.txt"),
)
class ZapretActivePresetAccentTest(_AccentRestoreMixin):
"""AC8: the active preset row is accent-styled, not success-green."""
def setUp(self) -> None:
super().setUp()
self.page = ZapretPage()
self.page.set_presets([_preset("alpha"), _preset("beta")])
def tearDown(self) -> None:
self.page.deleteLater()
QApplication.processEvents()
super().tearDown()
def _badge(self, row: int) -> str:
item = self.page.preset_list.item(row)
return str(item.data(Qt.ItemDataRole.UserRole + 2) or "")
def _row_colors(self, row: int) -> set[tuple[int, int, int]]:
"""Colors the delegate actually paints for one row."""
from PyQt6.QtCore import QRect
from PyQt6.QtGui import QImage, QPainter
from PyQt6.QtWidgets import QStyleOptionViewItem
view = self.page.preset_list
index = view.model().index(row, 0)
option = QStyleOptionViewItem()
option.rect = QRect(0, 0, 420, 44)
option.font = view.font()
option.palette = view.palette()
image = QImage(420, 44, QImage.Format.Format_ARGB32)
image.fill(0)
painter = QPainter(image)
view.itemDelegate().paint(painter, option, index)
painter.end()
colors = set()
for y in range(image.height()):
for x in range(image.width()):
pixel = image.pixelColor(x, y)
if pixel.alpha():
colors.add((pixel.red(), pixel.green(), pixel.blue()))
return colors
def test_active_row_is_badged_and_painted_with_the_accent(self) -> None:
self.page.set_running(True, "alpha")
self.assertEqual(self._badge(0), "Активен")
accent = theme.accent_color()
success = theme.success_color()
colors = self._row_colors(0)
self.assertIn((accent.red(), accent.green(), accent.blue()), colors)
self.assertNotIn((success.red(), success.green(), success.blue()), colors)
def test_inactive_rows_carry_no_active_badge(self) -> None:
self.page.set_running(True, "alpha")
self.assertEqual(self._badge(1), "")
def test_stopped_state_has_no_row_highlight(self) -> None:
self.page.set_running(True, "alpha")
self.page.set_running(False)
self.assertEqual(self._badge(0), "")
def test_accent_change_recolors_active_row(self) -> None:
setThemeColor("#123456")
self.page.set_running(True, "alpha")
self.assertIn((0x12, 0x34, 0x56), self._row_colors(0))
setThemeColor("#654321")
QApplication.processEvents()
self.assertIn((0x65, 0x43, 0x21), self._row_colors(0))
class SettingsAccentPresetsTest(_AccentRestoreMixin):
"""AC9: preset swatches in the settings page."""
def setUp(self) -> None:
super().setUp()
self.page = SettingsPage()
self.page.set_values(AppSettings(), SecuritySettings())
def tearDown(self) -> None:
self.page.deleteLater()
QApplication.processEvents()
super().tearDown()
def test_accent_presets_contract(self) -> None:
self.assertEqual(len(theme.ACCENT_PRESETS), 8)
self.assertEqual(theme.ACCENT_PRESETS[0][0], theme.DEFAULT_ACCENT)
hexes = [value for value, _name in theme.ACCENT_PRESETS]
self.assertEqual(len(set(hexes)), 8)
for value, name in theme.ACCENT_PRESETS:
self.assertRegex(value, r"^#[0-9A-F]{6}$")
self.assertTrue(name)
def test_swatch_click_emits_normalized_save(self) -> None:
saved: list[AppSettings] = []
self.page.save_requested.connect(saved.append)
target = self.page.accent_card.swatches[3]
target.click()
self.assertTrue(saved)
self.assertEqual(saved[-1].accent_color, theme.ACCENT_PRESETS[3][0])
self.assertTrue(target.isChecked())
self.assertEqual(
sum(1 for s in self.page.accent_card.swatches if s.isChecked()), 1
)
def test_default_accent_marks_first_swatch(self) -> None:
# Loaded with the default accent: the first swatch is checked, even if
# the stored hex uses lower case.
settings = AppSettings()
settings.accent_color = theme.DEFAULT_ACCENT.lower()
self.page.set_values(settings, SecuritySettings())
self.assertTrue(self.page.accent_card.swatches[0].isChecked())
def test_arbitrary_color_marks_no_swatch(self) -> None:
settings = AppSettings()
settings.accent_color = "#0A0B0C"
self.page.set_values(settings, SecuritySettings())
self.assertFalse(any(s.isChecked() for s in self.page.accent_card.swatches))
def test_collect_normalizes_picker_color(self) -> None:
saved: list[AppSettings] = []
self.page.save_requested.connect(saved.append)
self.page.accent_card.picker.setColor(QColor("#abcdef"))
self.page._auto_save()
self.assertTrue(saved)
self.assertEqual(saved[-1].accent_color, "#ABCDEF")
class AccentStaticGatesTest(unittest.TestCase):
"""AC6/AC9 static gates: subscriptions via theme helpers, hex only in theme.py."""
_SUBSCRIBED_FILES = (
"traffic_graph.py",
"zapret_page.py",
"subscriptions_page.py",
"nodes_page.py",
)
def _read(self, name: str) -> str:
return (UI_DIR / name).read_text(encoding="utf-8")
def test_accent_subscriptions_present(self) -> None:
pattern = re.compile(r"on_accent_changed|on_theme_or_accent_changed")
for name in self._SUBSCRIBED_FILES:
with self.subTest(module=name):
self.assertRegex(self._read(name), pattern)
def test_no_direct_theme_color_changed_subscriptions_outside_theme(self) -> None:
offenders = [
path.name
for path in sorted(UI_DIR.glob("*.py"))
if path.name != "theme.py"
and "themeColorChanged" in path.read_text(encoding="utf-8")
]
self.assertEqual(offenders, [])
def test_preset_hexes_only_in_theme_module(self) -> None:
preset_hexes = [value for value, _name in theme.ACCENT_PRESETS[1:]]
offenders: list[str] = []
for path in sorted(UI_DIR.glob("*.py")):
if path.name == "theme.py":
continue
source = path.read_text(encoding="utf-8")
offenders.extend(
f"{path.name}:{value}" for value in preset_hexes if value in source
)
self.assertEqual(offenders, [])
def test_zapret_page_does_not_use_success_color(self) -> None:
self.assertNotIn("success_color", self._read("zapret_page.py"))
def test_set_theme_color_called_only_from_theme_module(self) -> None:
offenders: list[str] = []
for path in sorted((REPO_ROOT / "xray_fluent").rglob("*.py")):
if path.name == "theme.py":
continue
if "setThemeColor(" in path.read_text(encoding="utf-8"):
offenders.append(str(path.relative_to(REPO_ROOT)))
self.assertEqual(offenders, [])
def test_set_theme_color_never_passes_save(self) -> None:
source = (UI_DIR / "theme.py").read_text(encoding="utf-8")
for line in source.splitlines():
if "setThemeColor(" in line and "def " not in line:
self.assertNotIn("save=", line)
if __name__ == "__main__":
unittest.main()