Files
kefu/wechat_rpa/test_qt_capsule.py
T
2026-08-19 17:35:59 +08:00

400 lines
16 KiB
Python

# -*- coding: utf-8 -*-
"""Qt 悬浮监听条的布局与交互回归测试。"""
import json
import os
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest import TestCase, main, mock, skipUnless
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
try:
from PySide6.QtCore import QPoint, Qt
from PySide6.QtGui import QFontMetrics
from PySide6.QtWidgets import (
QApplication,
QMainWindow,
QMessageBox,
QStackedWidget,
QVBoxLayout,
QWidget,
)
except ImportError:
QApplication = None
else:
from wechat_gui_qt import (
APP_QSS,
CapsuleWindow,
MainWindow,
QueuePage,
SettingsPage,
)
@skipUnless(QApplication is not None, "当前测试环境未安装 PySide6")
class CapsuleWindowTest(TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
cls.app.setStyleSheet(APP_QSS)
def setUp(self):
self.capsule = CapsuleWindow()
self.capsule.show()
self.app.processEvents()
def tearDown(self):
self.capsule.close()
self.app.processEvents()
def test_compact_layout_has_no_horizontal_overlap(self):
self.assertEqual((self.capsule.width(), self.capsule.height()), (436, 112))
self.assertEqual((self.capsule.panel.width(), self.capsule.panel.height()), (420, 94))
widgets = (
self.capsule.status_group,
self.capsule.divider,
self.capsule.stop_button,
self.capsule.expand_button,
)
bounds = []
for widget in widgets:
origin = widget.mapTo(self.capsule.panel, QPoint(0, 0))
bounds.append((origin.x(), origin.x() + widget.width() - 1))
for previous, current in zip(bounds, bounds[1:]):
self.assertLess(previous[1], current[0])
# 当前进程独占底部整行,不再被压在计时器下面的 150px 小区域里。
self.assertGreaterEqual(self.capsule.progress.width(), 340)
self.assertLess(
self.capsule.status_group.mapTo(self.capsule.panel, QPoint(0, 0)).y()
+ self.capsule.status_group.height(),
self.capsule.progress.mapTo(self.capsule.panel, QPoint(0, 0)).y() + 1,
)
def test_timer_and_longest_copy_fit_their_widgets(self):
timer_width = QFontMetrics(self.capsule.timer.font()).horizontalAdvance("00:00:00")
self.assertLessEqual(timer_width, self.capsule.timer.contentsRect().width())
states = {
"running": ("监听中", "安全运行中"),
"waiting": ("等待企业微信", "等待企业微信"),
"connecting": ("连接中", "正在连接窗口"),
"stopping": ("正在停止", "正在结束任务"),
"error": ("连接失败", "请查看运行日志"),
"verification": ("需要扫码验证", "请先用手机扫码验证"),
"stopped": ("已停止", "监听已停止"),
}
for state, (status, hint) in states.items():
with self.subTest(state=state):
self.capsule.set_status(state, status)
self.app.processEvents()
self.assertEqual(self.capsule.hint.text(), hint)
status_width = QFontMetrics(
self.capsule.status.font()
).horizontalAdvance(self.capsule.status.text())
hint_width = QFontMetrics(
self.capsule.hint.font()
).horizontalAdvance(self.capsule.hint.text())
self.assertLessEqual(status_width, self.capsule.status.width())
self.assertLessEqual(hint_width, self.capsule.hint.width())
self.capsule.timer.setText("99:59:59")
timer_width = QFontMetrics(self.capsule.timer.font()).horizontalAdvance(
self.capsule.timer.text()
)
self.assertLessEqual(timer_width, self.capsule.timer.contentsRect().width())
def test_the_timer_sits_above_the_full_width_progress_line(self):
# 缩成胶囊后仍能同时看到时长和完整的当前执行步骤。
timer_top = self.capsule.timer.mapTo(self.capsule.panel, QPoint(0, 0)).y()
progress_top = self.capsule.progress.mapTo(self.capsule.panel, QPoint(0, 0)).y()
self.assertLess(timer_top + self.capsule.timer.height(), progress_top + 1)
def test_the_progress_line_says_who_is_being_answered(self):
self.capsule.set_progress("正在回复 高瑞@微信")
self.app.processEvents()
self.assertEqual(self.capsule.progress.text(), "当前进程 · 正在回复 高瑞@微信")
def test_running_without_a_task_still_shows_a_real_process(self):
self.capsule.set_status("running", "监听中")
self.app.processEvents()
self.assertEqual(self.capsule.progress.text(), "当前进程 · 等待新消息")
def test_a_long_nickname_is_shortened_instead_of_overflowing(self):
long_name = "正在回复 甄养堂医疗助理-盛灵莉客户服务专员@微信"
self.capsule.set_progress(long_name)
self.app.processEvents()
shown = self.capsule.progress.text()
self.assertNotEqual(shown, long_name)
width = QFontMetrics(self.capsule.progress.font()).horizontalAdvance(shown)
self.assertLessEqual(width, self.capsule.progress.width())
# 省略之后还得能查到全名,否则不知道回的是谁
self.assertEqual(self.capsule.progress.toolTip(), f"当前进程 · {long_name}")
def test_stopping_clears_a_stale_progress_line(self):
# 停下来还挂着"正在回复 XXX"会让人以为它还在干活
self.capsule.set_progress("正在回复 高瑞@微信")
self.capsule.set_status("stopped", "已停止")
self.app.processEvents()
self.assertEqual(self.capsule.progress.text(), "")
def test_running_keeps_the_progress_line_alone(self):
self.capsule.set_progress("正在读取 高瑞@微信 的消息")
self.capsule.set_status("running", "监听中")
self.app.processEvents()
self.assertEqual(
self.capsule.progress.text(), "当前进程 · 正在读取 高瑞@微信 的消息"
)
def test_status_copy_and_actions_remain_connected(self):
self.capsule.set_status("waiting", "等待企业微信")
self.assertEqual(self.capsule.status.text(), "等待企业微信")
self.assertEqual(self.capsule.hint.text(), "等待企业微信")
events = []
self.capsule.expandRequested.connect(lambda: events.append("expand"))
self.capsule.stopRequested.connect(lambda: events.append("stop"))
self.capsule.expand_button.click()
self.capsule.stop_button.click()
self.assertEqual(events, ["expand", "stop"])
self.assertEqual(self.capsule.stop_button.icon_kind, "pause")
self.assertEqual(self.capsule.expand_button.icon_kind, "fullscreen")
self.assertEqual(self.capsule.stop_button.toolTip(), "停止监听")
self.assertEqual(self.capsule.expand_button.toolTip(), "展开控制台")
self.assertLess(
self.capsule.stop_button.geometry().left(),
self.capsule.expand_button.geometry().left(),
)
def test_window_flags_and_position_keep_capsule_non_intrusive(self):
flags = self.capsule.windowFlags()
self.assertTrue(flags & Qt.Tool)
self.assertTrue(flags & Qt.FramelessWindowHint)
self.assertTrue(flags & Qt.WindowStaysOnTopHint)
self.assertTrue(flags & Qt.WindowDoesNotAcceptFocus)
self.capsule.show_near(self.capsule)
self.app.processEvents()
area = self.capsule.screen().availableGeometry()
self.assertEqual(self.capsule.y(), area.y() + 28)
self.assertEqual(
self.capsule.x(),
area.x() + (area.width() - self.capsule.width()) // 2,
)
@skipUnless(QApplication is not None, "当前测试环境未安装 PySide6")
class QueuePageDeleteTest(TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
cls.app.setStyleSheet(APP_QSS)
def test_delete_action_emits_the_full_untruncated_task_key(self):
full_key = "d6904620be23fafcd8774af557daef1118b2b208ceb0655f62bb8d032dd8d0a072ca7bc26546f5e6"
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
(root / "pending_replies.json").write_text(
json.dumps({full_key: {"display_name": "一个小迷糊@微信", "created_at": 1}}),
encoding="utf-8",
)
with mock.patch("wechat_gui_qt.SCRIPT_DIR", root):
page = QueuePage()
emitted = []
page.deleteTasksRequested.connect(emitted.append)
page.queue_table.selectRow(0)
with mock.patch(
"wechat_gui_qt.QMessageBox.question",
return_value=QMessageBox.Yes,
):
page._request_delete_selected()
page.deleteLater()
self.app.processEvents()
self.assertEqual(emitted, [[full_key]])
def test_retry_action_emits_only_a_real_retry_wait_task(self):
full_key = "72" * 40
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
(root / "pending_replies.json").write_text(
json.dumps({full_key: {
"display_name": "陈女士@微信",
"created_at": 1,
"stage": "retry_wait",
"resume_failures": 1,
}}),
encoding="utf-8",
)
with mock.patch("wechat_gui_qt.SCRIPT_DIR", root):
page = QueuePage()
emitted = []
page.retryTasksRequested.connect(emitted.append)
page.queue_table.selectRow(0)
page._select_visible_task(0)
self.assertTrue(page.retry_queue_button.isEnabled())
page._request_retry_selected()
page.deleteLater()
self.app.processEvents()
self.assertEqual(emitted, [[full_key]])
@skipUnless(QApplication is not None, "当前测试环境未安装 PySide6")
class SettingsSendModeTest(TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
cls.app.setStyleSheet(APP_QSS)
def test_mode_buttons_are_exclusive_reflect_settings_and_emit_values(self):
settings = MainWindow._runtime_defaults()
settings["send_mode"] = "review"
page = SettingsPage(settings)
self.assertFalse(page.auto_send_mode.isChecked())
self.assertTrue(page.review_send_mode.isChecked())
self.assertEqual(page.values()["send_mode"], "review")
page.auto_send_mode.click()
self.app.processEvents()
self.assertTrue(page.auto_send_mode.isChecked())
self.assertFalse(page.review_send_mode.isChecked())
self.assertEqual(page.values()["send_mode"], "auto")
page.deleteLater()
self.app.processEvents()
def test_window_redetection_reports_the_real_detected_handle(self):
page = SettingsPage(MainWindow._runtime_defaults())
with (
mock.patch("wechat_bot.find_wx_hwnd", return_value=0x1234),
mock.patch("wechat_gui_qt.QMessageBox.information") as information,
):
page._redetect_wecom_window()
self.assertIn("0x00001234", information.call_args.args[2])
page.deleteLater()
self.app.processEvents()
def test_builtin_risk_categories_are_read_only_and_rule_management_emits(self):
page = SettingsPage(MainWindow._runtime_defaults())
self.assertTrue(page.risk_rule_buttons)
self.assertTrue(all(not button.isEnabled() for button in page.risk_rule_buttons))
emitted = []
page.securityRulesRequested.connect(lambda: emitted.append(True))
page.manage_rules_button.click()
self.app.processEvents()
self.assertEqual(emitted, [True])
page.deleteLater()
self.app.processEvents()
def test_rule_management_navigation_targets_ai_security_policy(self):
window = MainWindow.__new__(MainWindow)
QMainWindow.__init__(window)
tab_buttons = [mock.Mock(), mock.Mock(), mock.Mock()]
window.persona_page = SimpleNamespace(
persona_tab_buttons=tab_buttons,
_focus_persona_tab=mock.Mock(),
)
window.show_page = mock.Mock()
with mock.patch(
"wechat_gui_qt.QTimer.singleShot",
side_effect=lambda _delay, callback: callback(),
):
window._show_security_rules()
window.show_page.assert_called_once_with(3)
tab_buttons[2].setChecked.assert_called_once_with(True)
window.persona_page._focus_persona_tab.assert_called_once_with("安全策略")
window.deleteLater()
self.app.processEvents()
@skipUnless(QApplication is not None, "当前测试环境未安装 PySide6")
class MainWindowMinimizeTest(TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
cls.app.setStyleSheet(APP_QSS)
def setUp(self):
self.window = MainWindow.__new__(MainWindow)
QMainWindow.__init__(self.window)
self.window.resize(900, 700)
self.window._running = False
self.window._capsule_minimize_pending = False
self.window._saved_geometry = None
self.window._was_maximized = False
self.window._portal_was_visible = False
self.window.runtime_settings = {"minimize_on_close": True}
self.window.capsule = CapsuleWindow()
self.window.stack = QStackedWidget()
page = QWidget()
page_layout = QVBoxLayout(page)
portal_view = QWidget()
page_layout.addWidget(portal_view)
self.window.stack.addWidget(page)
self.window.setCentralWidget(self.window.stack)
self.window.portal_page = SimpleNamespace(view=portal_view)
self.window.show()
self.app.processEvents()
def tearDown(self):
self.window.capsule.close()
self.window.hide()
self.window.deleteLater()
self.app.processEvents()
def test_minimize_while_running_switches_to_capsule_and_can_expand(self):
self.window._running = True
self.window.showMinimized()
self.app.processEvents()
self.app.processEvents()
self.assertTrue(self.window.capsule.isVisible())
self.assertFalse(self.window.isVisible())
self.assertIsNotNone(self.window._saved_geometry)
self.window.expand_console()
self.app.processEvents()
self.assertFalse(self.window.capsule.isVisible())
self.assertTrue(self.window.isVisible())
self.assertFalse(self.window.isMinimized())
def test_minimize_while_stopped_keeps_normal_taskbar_behavior(self):
self.window._running = False
self.window.showMinimized()
self.app.processEvents()
self.app.processEvents()
self.assertTrue(self.window.isVisible())
self.assertTrue(self.window.isMinimized())
self.assertFalse(self.window.capsule.isVisible())
def test_minimize_from_maximized_restores_maximized_console(self):
self.window._running = True
self.window.showMaximized()
self.app.processEvents()
self.assertTrue(self.window.isMaximized())
self.window.showMinimized()
self.app.processEvents()
self.app.processEvents()
self.assertTrue(self.window.capsule.isVisible())
self.assertTrue(self.window._was_maximized)
self.window.expand_console()
self.app.processEvents()
self.assertTrue(self.window.isVisible())
self.assertTrue(self.window.isMaximized())
if __name__ == "__main__":
main()