Files
kefu/wechat_rpa/test_review_assistant_picker_layout.py
T
2026-09-21 10:34:06 +08:00

256 lines
14 KiB
Python

"""Synthetic Qt checks for review-recipient navigation; no native probes or sends."""
from unittest import TestCase, mock
from PySide6.QtCore import Qt, QPoint, QRect, QSize
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QDialog
import review_assistant_contacts as contacts
import wechat_gui_qt as qt
ACCOUNT = '168800000000001'
OTHER = '168800000000002'
NAMED = {'id': f'S:{ACCOUNT}_10001', 'peer_id': '10001', 'kind': '企业微信客户', 'name': '王助理', 'displayName': '王助理', 'nameKnown': True,
'identityLabel': '企业微信客户 · 客户编号 10001', 'lastTime': '2026-09-18 09:08', 'selectable': True}
ANONYMOUS = {'id': f'S:13102691405879689_{ACCOUNT}', 'peer_id': '13102691405879689', 'kind': '企业微信客户', 'name': '微信联系人', 'nameKnown': False,
'displayName': '未读取昵称 · 尾号 879689', 'identityLabel': '企业微信客户 · 客户编号 13102691405879689',
'lastTime': '2026-09-18 08:08', 'selectable': True}
DIRECTORY = {'id': 'customer:987654321012345', 'name': '李助理(夜班)', 'nameKnown': True,
'identityLabel': '企业微信客户 · 客户编号 987654321012345', 'lastTime': '', 'selectable': False,
'unavailableReason': '客户状态或单聊会话尚未核验,请刷新我的客户和本机缓存。'}
class ImmediateThread:
def __init__(self, *, target, **kwargs): self.target = target
def start(self): self.target()
class PickerLayoutTest(TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
qt._register_bundled_fonts()
cls.app.setStyle('Fusion')
cls.app.setStyleSheet(qt.APP_QSS)
cls.app.setFont(qt._ui_font(9))
def setUp(self):
with mock.patch.object(qt.QTimer, 'singleShot'):
self.dialog = qt.ReviewAssistantPicker(ACCOUNT, current_recipient_id=NAMED['id'])
self.addCleanup(self.dialog.deleteLater)
self.addCleanup(self.dialog.reject)
self.thread_patch = mock.patch.object(qt.threading, 'Thread', ImmediateThread)
self.thread_patch.start(); self.addCleanup(self.thread_patch.stop)
self.list_patch = mock.patch.object(contacts, 'list_recipients', side_effect=self.list_result)
self.list_mock = self.list_patch.start(); self.addCleanup(self.list_patch.stop)
self.dialog.show()
self.app.processEvents()
self.dialog._load()
self.app.processEvents()
def list_result(self, account, query='', limit=50, offset=0, *, status='all', order='recent'):
rows = {'sendable': [NAMED, ANONYMOUS], 'directory': [DIRECTORY], 'all': [NAMED, ANONYMOUS, DIRECTORY]}[status]
return {'ready': True, 'account_id': account, 'items': [dict(row) for row in rows],
'total': len(rows), 'offset': offset, 'has_more': False,
'all_total': 3, 'all_selectable_total': 2, 'all_contact_total': 1}
def click_row(self, index):
item = self.dialog.contacts.item(index)
rect = self.dialog.contacts.visualItemRect(item)
QTest.mouseClick(self.dialog.contacts.viewport(), Qt.MouseButton.LeftButton, pos=rect.center())
self.app.processEvents()
return item
def icon_pixels(self, item):
image = item.icon().pixmap(QSize(18, 18)).toImage()
return sum(image.pixelColor(x, y).alpha() > 0
for x in range(image.width()) for y in range(image.height()))
def blue_pixels(self):
image = self.dialog.contacts.viewport().grab().toImage()
return sum(image.pixelColor(x, y).name() == '#2563cb'
for x in range(0, image.width(), 4) for y in range(0, image.height(), 4))
def test_defaults_to_all_customers_and_queries_backend_sort_before_pagination(self):
self.list_mock.assert_called_with(ACCOUNT, '', 50, 0, status='all', order='recent')
self.assertTrue(self.dialog.filter_buttons['all'].isChecked())
self.assertEqual(self.dialog.contacts.count(), 3)
self.assertIn('我的客户', self.dialog.filter_buttons['all'].text())
self.assertIn('待核验', self.dialog.filter_buttons['directory'].text())
self.assertIn('(3)', self.dialog.filter_buttons['all'].text())
self.assertIn('(1)', self.dialog.filter_buttons['directory'].text())
self.dialog.order.setCurrentIndex(1)
self.list_mock.assert_called_with(ACCOUNT, '', 50, 0, status='all', order='name')
def test_directory_click_previews_complete_identity_and_reason_but_cannot_confirm(self):
self.dialog.filter_buttons['directory'].click()
self.list_mock.assert_called_with(ACCOUNT, '', 50, 0, status='directory', order='recent')
item = self.click_row(0)
self.assertTrue(item.isSelected())
self.assertTrue(item.flags() & Qt.ItemFlag.ItemIsSelectable)
self.assertIn('李助理', self.dialog.details_name.text())
self.assertIn('987654321012345', self.dialog.details_identity.text())
self.assertIn(DIRECTORY['unavailableReason'], self.dialog.details_reason.text())
self.assertFalse(self.dialog.choose.isEnabled())
self.assertNotIn(DIRECTORY['unavailableReason'], item.text())
self.assertNotIn('987654321012345', item.text())
with mock.patch.object(contacts, 'validate_recipient') as validate:
self.dialog._choose()
validate.assert_not_called()
self.assertIsNone(self.dialog.recipient)
def test_named_selection_is_blue_and_checked_after_focus_moves(self):
item = self.click_row(0)
self.assertTrue(item.text().startswith('王助理'))
self.assertGreater(self.icon_pixels(item), 10)
self.assertGreater(self.blue_pixels(), 100)
self.dialog.query.setFocus()
self.app.processEvents()
self.assertTrue(item.isSelected())
self.assertTrue(item.text().startswith('王助理'))
self.assertGreater(self.icon_pixels(item), 10)
self.assertGreater(self.blue_pixels(), 100)
self.assertIn('王助理', self.dialog.details_name.text())
self.assertTrue(self.dialog.choose.isEnabled())
def test_s_details_show_customer_peer_and_confirmation_preserves_raw_id(self):
self.click_row(0)
self.assertIn('企业微信客户', self.dialog.details_identity.text())
self.assertIn('10001', self.dialog.details_identity.text())
with mock.patch.object(contacts, 'validate_recipient', return_value={
'ok': True, 'account_id': ACCOUNT, 'recipient': NAMED}) as validate:
self.dialog.choose.click()
validate.assert_called_once_with(ACCOUNT, f'S:{ACCOUNT}_10001')
self.assertEqual(self.dialog.recipient['id'], f'S:{ACCOUNT}_10001')
self.assertEqual(self.dialog.result(), QDialog.DialogCode.Accepted)
def test_unknown_name_is_explicit_and_gets_same_visible_selection(self):
named = self.click_row(0)
item = self.click_row(1)
self.assertEqual(self.icon_pixels(named), 0)
self.assertTrue(item.text().startswith('未读取昵称 · 尾号 879689'))
self.assertGreater(self.icon_pixels(item), 10)
self.assertIn('未读取昵称', self.dialog.details_name.text())
self.assertNotIn('当前查看:微信联系人', self.dialog.details_name.text())
self.assertIn('13102691405879689', self.dialog.details_identity.text())
self.assertGreater(self.blue_pixels(), 100)
self.assertTrue(self.dialog.choose.isEnabled())
def test_configured_recipient_mark_survives_selection_and_group_change(self):
self.assertIn('已设为助理', self.dialog.contacts.item(0).text())
self.assertIn('王助理', self.dialog.configured.text())
self.click_row(1)
self.assertIn('已设为助理', self.dialog.contacts.item(0).text())
self.dialog.filter_buttons['directory'].click()
self.assertIn('王助理', self.dialog.configured.text())
self.dialog.filter_buttons['all'].click()
self.assertEqual(self.dialog.contacts.count(), 3)
self.assertIn('已设为助理', self.dialog.contacts.item(0).text())
self.assertIsNone(self.dialog.contacts.currentItem())
def test_refresh_clears_old_selection_and_preserves_search_and_sort(self):
self.click_row(1)
self.dialog.query.setText('助理')
self.dialog.order.setCurrentIndex(1)
self.click_row(0)
self.dialog._offset = 50
self.dialog.refresh.click()
self.list_mock.assert_called_with(ACCOUNT, '助理', 50, 0, status='all', order='name')
self.assertFalse(self.dialog.choose.isEnabled())
self.assertIsNone(self.dialog.contacts.currentItem())
self.assertEqual(self.dialog.details_name.text(), '尚未选择联系人')
def test_query_change_invalidates_inflight_list_before_debounce(self):
self.click_row(0)
old_serial = self.dialog._serial
self.dialog.query.setText('另一人')
self.dialog._loaded({**self.list_result(ACCOUNT), '_serial': old_serial})
self.assertEqual(self.dialog.contacts.count(), 0)
self.assertFalse(self.dialog.choose.isEnabled())
self.assertTrue(self.dialog._search_timer.isActive())
def test_confirmation_validates_exact_selected_identity(self):
self.click_row(1)
with mock.patch.object(contacts, 'validate_recipient', return_value={
'ok': True, 'account_id': ACCOUNT, 'recipient': ANONYMOUS}) as validate:
self.dialog.choose.click()
validate.assert_called_once_with(ACCOUNT, ANONYMOUS['id'])
self.assertEqual(self.dialog.result(), QDialog.DialogCode.Accepted)
self.assertEqual(self.dialog.recipient, ANONYMOUS)
def test_wrong_account_or_returned_identity_never_accepts(self):
self.click_row(0)
for account, recipient in ((OTHER, NAMED), (ACCOUNT, ANONYMOUS)):
with self.subTest(account=account, recipient=recipient['id']):
with mock.patch.object(contacts, 'validate_recipient', return_value={
'ok': True, 'account_id': account, 'recipient': recipient}):
self.dialog.choose.click()
self.assertNotEqual(self.dialog.result(), QDialog.DialogCode.Accepted)
self.assertIsNone(self.dialog.recipient)
self.assertTrue(self.dialog.choose.isEnabled())
def test_pending_validation_blocks_navigation_and_cancel_ignores_late_result(self):
self.click_row(0)
queued = []
class DeferredThread:
def __init__(self, *, target, **kwargs): queued.append(target)
def start(self): pass
with mock.patch.object(qt.threading, 'Thread', DeferredThread):
self.dialog.choose.click()
serial = self.dialog._serial
self.assertFalse(self.dialog.filter_buttons['all'].isEnabled())
self.assertFalse(self.dialog.order.isEnabled())
self.assertFalse(self.dialog.refresh.isEnabled())
self.dialog._set_filter('all')
self.dialog._load(reset=True)
self.assertEqual(self.dialog._serial, serial)
self.dialog.reject()
with mock.patch.object(contacts, 'validate_recipient', return_value={
'ok': True, 'account_id': ACCOUNT, 'recipient': NAMED}):
queued[0]()
self.assertIsNone(self.dialog.recipient)
self.assertEqual(self.dialog.result(), QDialog.DialogCode.Rejected)
def test_stale_validation_and_list_from_other_account_are_ignored(self):
self.dialog._load()
self.dialog._validated({'_serial': self.dialog._serial - 1, 'ok': True,
'account_id': ACCOUNT, 'recipient': NAMED})
self.assertIsNone(self.dialog.recipient)
self.dialog.contacts.clear()
self.dialog._loaded({'_serial': self.dialog._serial, 'ready': True,
'account_id': OTHER, 'items': [NAMED]})
self.assertEqual(self.dialog.contacts.count(), 0)
def test_no_sendable_matches_shows_how_to_open_directory(self):
self.list_mock.side_effect = None
self.list_mock.return_value = {'ready': True, 'account_id': ACCOUNT, 'items': [], 'total': 0,
'all_total': 1, 'all_selectable_total': 0, 'all_contact_total': 1}
self.dialog._set_filter('sendable')
self.assertIn('待核验', self.dialog.status.text())
self.assertFalse(self.dialog.choose.isEnabled())
self.assertTrue(self.dialog.filter_buttons['directory'].isEnabled())
def test_same_name_rows_have_distinct_tail_numbers_before_selection(self):
first = self.dialog._item_text({**NAMED, 'id': f'S:{ACCOUNT}_123456789', 'peer_id': '123456789'})
second = self.dialog._item_text({**NAMED, 'id': f'S:123987654_{ACCOUNT}', 'peer_id': '123987654'})
self.assertIn('尾号 456789', first)
self.assertIn('尾号 987654', second)
self.assertNotIn('123456789', first)
def test_compact_screen_keeps_footer_visible_and_details_scrollable(self):
with mock.patch.object(qt.QTimer, 'singleShot'), \
mock.patch.object(qt.ReviewAssistantPicker, '_available_geometry', lambda _self: QRect(0, 0, 640, 360)):
compact = qt.ReviewAssistantPicker(ACCOUNT)
self.addCleanup(compact.deleteLater)
self.addCleanup(compact.reject)
compact._serial = 1
compact._loaded({**self.list_result(ACCOUNT, status='directory'), '_serial': 1})
compact.show(); self.app.processEvents()
self.assertLessEqual(compact.width(), 616)
self.assertLessEqual(compact.height(), 320)
button_bottom = compact.choose.mapTo(compact, compact.choose.rect().bottomRight()).y()
self.assertLess(button_bottom, compact.height())
compact.contacts.setCurrentRow(0)
self.app.processEvents()
self.assertIn('李助理', compact.details_name.text())
self.assertGreater(compact.body_scroll.verticalScrollBar().maximum(), 0)
self.assertFalse(compact.choose.isEnabled())