# -*- 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()) def test_starting_the_listener_switches_to_capsule_by_itself(self): """开始监听就收进胶囊:控制台不该挡在企业微信前面。""" self.assertFalse(self.window.capsule.isVisible()) self.window._running = True self.window.enter_capsule_mode() self.app.processEvents() self.assertTrue(self.window.capsule.isVisible()) self.assertFalse(self.window.isVisible()) def test_entering_capsule_twice_is_harmless(self): self.window._running = True self.window.enter_capsule_mode() self.app.processEvents() geometry = self.window._saved_geometry self.window.enter_capsule_mode() self.app.processEvents() # 第二次不能把"已经藏起来"的几何当成要恢复的几何存进去 self.assertIs(self.window._saved_geometry, geometry) self.assertTrue(self.window.capsule.isVisible()) @skipUnless(QApplication is not None, "当前测试环境未安装 PySide6") class SidebarMonitorToggleTest(TestCase): """侧栏底部整块状态区都要能启动/暂停,不能只有那颗球能点。""" @classmethod def setUpClass(cls): cls.app = QApplication.instance() or QApplication([]) cls.app.setStyleSheet(APP_QSS) def setUp(self): from wechat_gui_qt import Sidebar self.sidebar = Sidebar() self.sidebar.show() self.app.processEvents() self.starts, self.stops = [], [] self.sidebar.startRequested.connect(lambda: self.starts.append(1)) self.sidebar.stopRequested.connect(lambda: self.stops.append(1)) def tearDown(self): self.sidebar.close() self.app.processEvents() def _click(self, widget): from PySide6.QtCore import QPointF from PySide6.QtGui import QMouseEvent pos = QPointF(widget.width() / 2, widget.height() / 2) event = QMouseEvent( QMouseEvent.Type.MouseButtonPress, pos, widget.mapToGlobal(QPoint(int(pos.x()), int(pos.y()))), Qt.LeftButton, Qt.LeftButton, Qt.NoModifier, ) self.app.sendEvent(widget, event) self.app.processEvents() def test_clicking_the_status_text_starts_the_listener(self): # 用户点的往往正是"已停止"这几个字,过去它完全没反应 self._click(self.sidebar.status_title) self.assertEqual((len(self.starts), len(self.stops)), (1, 0)) def test_clicking_the_status_text_while_running_pauses(self): self.sidebar.set_status("running", "监听中", "后台监控中") self._click(self.sidebar.status_title) self.assertEqual((len(self.starts), len(self.stops)), (0, 1)) def test_clicking_the_orb_toggles_the_same_way(self): self.sidebar.monitor_button.click() self.sidebar.set_status("running", "监听中", "后台监控中") self.sidebar.monitor_button.click() self.sidebar.set_status("stopped", "已停止", "等待连接") self.sidebar.monitor_button.click() self.assertEqual((len(self.starts), len(self.stops)), (2, 1)) def test_one_click_never_fires_both_signals(self): """旧实现每次都 disconnect 再 connect;少断一次就会自相抵消。""" for state, text in ( ("connecting", "连接中"), ("running", "监听中"), ("waiting", "等待企业微信"), ("stopping", "正在停止"), ("stopped", "已停止"), ("error", "连接失败"), ): self.sidebar.set_status(state, text, text) self.sidebar.monitor_button.click() self.assertEqual(len(self.starts) + len(self.stops), 1) def test_the_tooltip_says_what_the_click_will_do(self): self.assertEqual(self.sidebar.status_block.toolTip(), "点击开始监听") self.sidebar.set_status("running", "监听中", "后台监控中") self.assertEqual(self.sidebar.status_block.toolTip(), "点击暂停监听") def test_intermediate_states_count_as_running(self): for state in ("connecting", "waiting", "stopping"): self.sidebar.set_status(state, state, state) self.sidebar.monitor_button.click() self.assertEqual((len(self.starts), len(self.stops)), (0, 3)) if __name__ == "__main__": main()