256 lines
14 KiB
Python
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())
|