"""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())