503 lines
20 KiB
Python
503 lines
20 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())
|
|
|
|
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()
|