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

504 lines
30 KiB
Python

"""Isolated reminder recipient/settings/UI checks; never send a real message."""
import contextlib
import json
from pathlib import Path
import sqlite3
import sys
import tempfile
from types import MethodType, SimpleNamespace, ModuleType
from unittest import TestCase, mock
import review_assistant_contacts as contacts
import wechat_gui_qt as qt
from PySide6.QtCore import QEventLoop, QTimer
from PySide6.QtWidgets import QApplication, QMainWindow, QDialog
ACCOUNT = '168800000000001'
OTHER = '168800000000002'
ACTIVE = {'ready': True, 'account_id': ACCOUNT, 'account_name': '合成托管账号', 'reason': ''}
RECIPIENT_ID = f'S:{ACCOUNT}_10001'
RECIPIENT = {'id': RECIPIENT_ID, 'peer_id': '10001', 'name': '合成医疗助理', 'kind': '企业微信客户',
'recipient_kind': 'wecom_customer', 'identityLabel': '企业微信客户 · 客户编号 10001'}
class ReminderContactTest(TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.folder = self.root / ACCOUNT
self.folder.mkdir()
self._seed(self.folder)
patch = mock.patch.object(contacts, 'active_context', return_value=ACTIVE)
self.context = patch.start(); self.addCleanup(patch.stop)
contacts._CACHE.clear()
def _seed(self, folder):
with contextlib.closing(sqlite3.connect(folder / 'message.db')) as db, db:
db.execute('CREATE TABLE message_table(conversation_id TEXT, send_time REAL, content TEXT)')
db.executemany('INSERT INTO message_table VALUES(?,?,?)', [
(RECIPIENT_ID, 1700000000, 'PRIVATE_BODY_NEVER_LOADED'),
('M:10002', 1700000100, 'PRIVATE_BODY_NEVER_LOADED'),
('R:100', 1700000200, 'group'), ('S:1_2', 1700000300, 'internal'),
('M:not-valid', 1700000400, 'invalid'), ('Y:100', 1700000500, 'system')])
with contextlib.closing(sqlite3.connect(folder / 'user.db')) as db, db:
db.execute('CREATE TABLE user_table(id TEXT,name TEXT)')
db.executemany('INSERT INTO user_table VALUES(?,?)', [(ACCOUNT, '合成账号'), ('10001','同名助理'), ('10002','同名助理')])
db.execute('CREATE TABLE external_user_relation_v3(user_id TEXT,status INTEGER,add_customer_time INTEGER,stranger_type INTEGER,remarks TEXT,real_remarks TEXT)')
db.executemany('INSERT INTO external_user_relation_v3 VALUES(?,?,?,?,?,?)', [
('10001',2049,1700000000,0,'',''), ('10002',2049,1700000000,0,'','')])
db.execute('CREATE TABLE wechat_contactV1(wxid TEXT,name TEXT)')
db.execute("INSERT INTO wechat_contactV1 VALUES('wxid_unrelated','不属于我的客户')")
with contextlib.closing(sqlite3.connect(folder / 'session.db')) as db, db:
db.execute('CREATE TABLE conversation_table(id TEXT,name TEXT)')
def listing(self, *args, **kwargs):
return contacts.list_recipients(ACCOUNT, *args, cache_roots=[self.root], **kwargs)
def test_lists_relation_customers_with_s_and_m_chats_without_message_content(self):
result = self.listing()
self.assertTrue(result['ready'])
self.assertEqual({row['id'] for row in result['items']}, {RECIPIENT_ID,'M:10002'})
self.assertNotIn('PRIVATE_BODY_NEVER_LOADED', json.dumps(result))
self.assertEqual(result['account_name'], '合成账号')
def test_duplicate_names_remain_separate_and_identifiable(self):
result = self.listing('同名助理')
self.assertEqual(result['total'], 2)
self.assertEqual(len({row['identityLabel'] for row in result['items']}), 2)
def test_search_and_pagination_are_independent_of_message_preview(self):
first = self.listing(limit=1)
second = self.listing(limit=1, offset=1)
self.assertTrue(first['has_more']); self.assertFalse(second['has_more'])
self.assertNotEqual(first['items'][0]['id'], second['items'][0]['id'])
self.assertEqual(self.listing('PRIVATE_BODY')['total'], 0)
def test_missing_account_does_not_fall_back_to_another_account(self):
self.context.return_value = {**ACTIVE, 'account_id': OTHER}
result = contacts.list_recipients(OTHER, cache_roots=[self.root])
self.assertFalse(result['ready']); self.assertEqual(result['items'], [])
self.assertEqual(result['account_id'], OTHER)
def test_account_mismatch_never_reads_the_old_accounts_database(self):
self.context.return_value = {**ACTIVE, 'account_id': OTHER}
with mock.patch.object(contacts, '_contacts') as read:
result = self.listing()
self.assertFalse(result['ready']); read.assert_not_called()
def test_recipient_validation_uses_id_and_rechecks_identity(self):
result = contacts.validate_recipient(ACCOUNT, RECIPIENT_ID, cache_roots=[self.root])
self.assertTrue(result['ok']); self.assertEqual(result['recipient']['id'], RECIPIENT_ID)
self.assertGreaterEqual(self.context.call_count, 2)
def test_account_switch_during_validation_rejects_selection(self):
self.context.side_effect = [ACTIVE, {**ACTIVE, 'account_id': OTHER}]
result = contacts.validate_recipient(ACCOUNT, RECIPIENT_ID, cache_roots=[self.root])
self.assertFalse(result['ok']); self.assertIsNone(result['recipient'])
def test_group_internal_or_unknown_recipient_cannot_be_saved(self):
for identity in ('R:100', 'S:1_2', '同名助理', 'M:99999', 'M:../../x'):
with self.subTest(identity=identity):
self.assertFalse(contacts.validate_recipient(ACCOUNT, identity, cache_roots=[self.root])['ok'])
def test_queries_are_read_only_and_do_not_initialize_or_modify_databases(self):
before = {path.name: path.read_bytes() for path in self.folder.glob('*.db')}
self.listing(); contacts.validate_recipient(ACCOUNT, RECIPIENT_ID, cache_roots=[self.root])
self.assertEqual(before, {path.name:path.read_bytes() for path in self.folder.glob('*.db')})
with contextlib.closing(contacts._open(self.folder / 'message.db')) as db:
with self.assertRaises(sqlite3.OperationalError):
db.execute("INSERT INTO message_table VALUES('M:42',0,'forbidden')")
def test_preview_is_static_and_does_not_load_real_customer_data(self):
with mock.patch.object(contacts, '_contacts') as read, mock.patch.object(contacts, 'active_context') as current:
preview = contacts.notification_preview('合成账号', RECIPIENT)
self.assertIn('示例客户', preview); self.assertIn('不会发送', preview)
read.assert_not_called(); current.assert_not_called()
class ImmediateThread:
def __init__(self, *, target, **_kwargs): self.target=target
def start(self): self.target()
def host_fixture():
host = SimpleNamespace(runtime_settings={'review_assistant_accounts': {}},
_review_assistant_state=dict(ACTIVE), _review_assistant_busy=False,
_review_assistant_available=lambda:(True,''), _schedule_console_push=mock.Mock(),
_push_console_state=mock.Mock(), append_log=mock.Mock())
for name in ('_review_assistant_console_state','_refresh_review_assistant','_save_review_assistant','_review_assistant_updated'):
setattr(host,name,MethodType(getattr(qt.MainWindow,name),host))
host.reviewAssistantUpdated=SimpleNamespace(emit=host._review_assistant_updated)
def persist(settings): host.runtime_settings.update(settings); return True
host.save_runtime_settings=mock.Mock(side_effect=persist)
return host
class ReminderSettingsTest(TestCase):
def setUp(self):
self.host=host_fixture()
self.thread=mock.patch.object(qt.threading,'Thread',ImmediateThread)
self.thread.start(); self.addCleanup(self.thread.stop)
self.validation=mock.patch.object(contacts,'validate_recipient',return_value={
'ok':True,'account_id':ACCOUNT,'account_name':'合成账号','recipient':RECIPIENT})
self.validate=self.validation.start(); self.addCleanup(self.validation.stop)
def test_default_disabled_and_cannot_enable_without_recipient(self):
self.assertFalse(self.host._review_assistant_console_state()['enabled'])
result=self.host._save_review_assistant(ACCOUNT,enabled=True)
self.assertFalse(result['ok']); self.validate.assert_not_called()
self.host.save_runtime_settings.assert_not_called()
def test_selection_persists_exact_verified_recipient_and_stays_off(self):
self.host._save_review_assistant(ACCOUNT,recipient={**RECIPIENT,'name':'forged name'})
config=self.host.runtime_settings['review_assistant_accounts'][ACCOUNT]
self.assertEqual(config['recipient_conv_id'],RECIPIENT_ID)
self.assertEqual(config['recipient_name'],RECIPIENT['name'])
self.assertFalse(config['enabled'])
def test_enable_revalidates_target_and_does_not_change_other_accounts(self):
self.host.runtime_settings['review_assistant_accounts']={
ACCOUNT:{'enabled':False,'recipient_conv_id':RECIPIENT_ID}, OTHER:{'enabled':False,'recipient_conv_id':'M:20002'}}
self.host._save_review_assistant(ACCOUNT,enabled=True)
self.validate.assert_called_once_with(ACCOUNT,RECIPIENT_ID)
self.assertTrue(self.host.runtime_settings['review_assistant_accounts'][ACCOUNT]['enabled'])
self.assertEqual(self.host.runtime_settings['review_assistant_accounts'][OTHER]['recipient_conv_id'],'M:20002')
def test_s_recipient_round_trip_preserves_raw_id_and_customer_kind(self):
raw = f'S:10001_{ACCOUNT}'
verified = {**RECIPIENT, 'id': raw, 'peer_id': '10001'}
self.validate.return_value = {'ok': True, 'account_id': ACCOUNT, 'recipient': verified}
self.host._save_review_assistant(ACCOUNT, recipient=verified)
self.validate.assert_called_once_with(ACCOUNT, raw)
saved = self.host.runtime_settings['review_assistant_accounts'][ACCOUNT]
self.assertEqual(saved['recipient_conv_id'], raw)
self.assertEqual(saved['recipient_kind'], '企业微信客户')
state = self.host._review_assistant_console_state()
self.assertEqual(state['accountId'], ACCOUNT)
self.assertEqual(state['recipient']['id'], raw)
self.assertIn('10001', state['recipient']['label'])
self.assertNotIn(ACCOUNT, state['recipient']['label'])
def test_rejected_legacy_m_recipient_is_not_silently_replaced_by_s_chat(self):
original = {'enabled': False, 'recipient_conv_id': 'M:10001', 'recipient_name': '旧助理'}
self.host.runtime_settings['review_assistant_accounts'][ACCOUNT] = dict(original)
self.validate.return_value = {'ok': False, 'reason': '旧会话不属于当前我的客户', 'recipient': None}
self.host._save_review_assistant(ACCOUNT, enabled=True)
self.validate.assert_called_once_with(ACCOUNT, 'M:10001')
self.host.save_runtime_settings.assert_not_called()
self.assertEqual(self.host.runtime_settings['review_assistant_accounts'][ACCOUNT], original)
def test_replacing_an_enabled_contact_requires_explicit_reenable(self):
self.host.runtime_settings['review_assistant_accounts']={ACCOUNT:{'enabled':True,'recipient_conv_id':'M:10002'}}
self.host._save_review_assistant(ACCOUNT,recipient=RECIPIENT)
self.assertFalse(self.host.runtime_settings['review_assistant_accounts'][ACCOUNT]['enabled'])
def test_expected_account_mismatch_or_validation_failure_never_saves(self):
self.assertFalse(self.host._save_review_assistant(OTHER,recipient=RECIPIENT)['ok'])
self.validate.return_value={'ok':False,'reason':'账号已变化','recipient':None}
self.host._save_review_assistant(ACCOUNT,recipient=RECIPIENT)
self.host.save_runtime_settings.assert_not_called()
self.assertFalse(self.host._review_assistant_state['ready'])
def test_explicit_disable_only_needs_current_account_not_deleted_contact(self):
self.host.runtime_settings['review_assistant_accounts']={ACCOUNT:{'enabled':True,'recipient_conv_id':'M:99999'}}
with mock.patch.object(contacts,'active_context',return_value=ACTIVE):
self.host._save_review_assistant(ACCOUNT,enabled=False)
self.validate.assert_not_called()
self.assertFalse(self.host.runtime_settings['review_assistant_accounts'][ACCOUNT]['enabled'])
def test_successful_reselection_clears_matching_warning_without_enabling(self):
self.host.runtime_settings['review_assistant_accounts'][ACCOUNT] = {
'enabled': False, 'recipient_conv_id': RECIPIENT_ID, 'recipient_name': RECIPIENT['name']}
self.host._review_assistant_state['recipient_check'] = {
'id': RECIPIENT_ID, 'ok': False, 'reason': '旧核验状态不可用'}
self.assertTrue(self.host._review_assistant_console_state()['recipientWarning'])
self.host._save_review_assistant(ACCOUNT, recipient=RECIPIENT)
self.assertEqual(self.host._review_assistant_console_state()['recipientWarning'], '')
self.assertFalse(self.host.runtime_settings['review_assistant_accounts'][ACCOUNT]['enabled'])
self.assertEqual(self.host.runtime_settings['review_assistant_accounts'][ACCOUNT]['recipient_conv_id'], RECIPIENT_ID)
def test_persist_failure_does_not_claim_config_is_enabled(self):
self.host.save_runtime_settings=mock.Mock(return_value=False)
self.host._save_review_assistant(ACCOUNT,recipient=RECIPIENT)
self.assertEqual(self.host.runtime_settings['review_assistant_accounts'],{})
self.assertIn('保存失败',self.host._review_assistant_state['message'])
def test_snapshot_does_not_probe_scan_or_query_databases(self):
with mock.patch.object(contacts,'active_context') as active, mock.patch.object(contacts,'_contacts') as read:
for _ in range(10): self.host._review_assistant_console_state()
active.assert_not_called(); read.assert_not_called()
def test_stale_async_reply_and_shutdown_do_not_save(self):
self.host._review_assistant_serial=2
self.host._review_assistant_updated({'_serial':1,'ok':True})
self.host._exit_requested=True
self.host._review_assistant_updated({'_serial':2,'ok':True})
self.host.save_runtime_settings.assert_not_called()
def test_runtime_save_preserves_other_account_mapping_and_rolls_back_failure(self):
with tempfile.TemporaryDirectory() as folder:
settings={**qt.MainWindow._runtime_defaults(),'review_assistant_accounts':{ACCOUNT:{'enabled':True,'recipient_conv_id':RECIPIENT_ID}}}
host=SimpleNamespace(runtime_settings=settings, _runtime_defaults=qt.MainWindow._runtime_defaults,
settings_page=SimpleNamespace(values=lambda:{}), _thread=None,
_broadcast_save_result=mock.Mock(), append_log=mock.Mock(), _sync_auto_launch=mock.Mock())
path=Path(folder)/'app_settings.json'
with mock.patch.object(qt,'APP_SETTINGS_FILE',path):
self.assertTrue(qt.MainWindow.save_runtime_settings(host,{'poll_interval':3}))
self.assertTrue(json.loads(path.read_text(encoding='utf-8'))['review_assistant_accounts'][ACCOUNT]['enabled'])
previous=host.runtime_settings
with mock.patch.object(qt.os,'replace',side_effect=OSError('test')):
self.assertFalse(qt.MainWindow.save_runtime_settings(host,{'review_assistant_accounts':{}}))
self.assertIs(host.runtime_settings,previous)
class ReminderWebTest(TestCase):
@classmethod
def setUpClass(cls): cls.app=QApplication.instance() or QApplication([])
def test_setting_panel_disables_until_verified_and_uses_exact_account_for_toggle(self):
owner=QMainWindow();owner._push_console_state=mock.Mock()
shell=qt.ConsoleShell(owner);owner.setCentralWidget(shell);owner.resize(1440,1000);owner.show()
self.addCleanup(owner.deleteLater);self.addCleanup(owner.close)
if not shell.page_ready:
results=[];loop=QEventLoop()
def ready(ok):results.append(ok);loop.quit()
shell.loadFinished.connect(ready);QTimer.singleShot(10000,loop.quit);loop.exec()
shell.loadFinished.disconnect(ready);self.assertEqual(results,[True])
def js(script):
values=[];loop=QEventLoop()
def done(value):values.append(value);loop.quit()
shell.page().runJavaScript(script,done);QTimer.singleShot(5000,loop.quit);loop.exec()
self.assertEqual(len(values),1);return values[0]
state={'view':'automation','ready':True,'demo':False,'automation':{'transport':'visual','reviewAssistant':{
'supported':False,'ready':False,'enabled':False,'reason':'当前版本不支持协议审核提醒'}}}
js('window.applyState('+json.dumps(state)+');render("automation");')
self.assertTrue(js('document.querySelector("[data-action=review-assistant-toggle]").disabled'))
self.assertTrue(js('document.querySelector("[data-action=review-assistant-choose]").disabled'))
self.assertIn('当前版本不支持',js('document.body.textContent'))
state['automation']['reviewAssistant']=self.make_state()
js('window.applyState('+json.dumps(state)+');render("automation");')
self.assertFalse(js('document.querySelector("[data-action=review-assistant-toggle]").disabled'))
self.assertEqual(js('document.querySelectorAll("img[data-injected]").length'),0)
js('window.captured=[]; window.qtBridge={setReviewAssistantEnabled:(payload,cb)=>{window.captured.push(JSON.parse(payload)); cb(JSON.stringify({ok:true,message:"test"}));}}; document.querySelector("[data-action=review-assistant-toggle]").click();')
captured=json.loads(js('JSON.stringify(window.captured)'))
self.assertEqual(captured,[{'expected_account':ACCOUNT,'enabled':True}])
self.assertNotIn('测试发送',js('document.querySelector("#main").textContent'))
# Let the real offscreen compositor paint after HTML and viewport changes.
js('window.fit(); document.querySelector("details").open=true;')
loop=QEventLoop(); QTimer.singleShot(700,loop.quit); loop.exec()
self.assertGreaterEqual(js('innerWidth'),1000)
self.assertGreater(js('document.querySelector("#stage").getBoundingClientRect().width'),1000)
suffix='production' if hasattr(qt,'PROTOCOL') else 'source'
self.assertTrue(shell.grab().save('C:/kefu/deploy/customer-picker-20260918/assistant-settings-'+suffix+'.png'))
def make_state(self):
return {'supported':True,'ready':True,'enabled':False,'busy':False,'accountId':ACCOUNT,'accountName':'合成账号',
'recipient':{'id':RECIPIENT_ID,'name':'同名助理 <img data-injected src=x>','label':'编号10001'},
'notification':{'message':'已发送提醒,等待人工直接回复客户'},
'preview':contacts.notification_preview('合成账号')}
class ReminderIdentityTest(TestCase):
def test_active_identity_only_uses_native_readonly_scope(self):
events=[]
native=ModuleType('wecom_native_sender')
@contextlib.contextmanager
def scope():
events.append('readonly-enter')
yield
events.append('readonly-exit')
def discover():
events.append('discover')
return {'accountId':ACCOUNT,'pid':123}
native.discover=discover;native.readonly_build_validation_scope=scope
with mock.patch.dict(sys.modules,{'wecom_native_sender':native}):
result=contacts.active_context()
self.assertTrue(result['ready']);self.assertEqual(result['account_id'],ACCOUNT)
self.assertEqual(events,['readonly-enter','discover','readonly-exit'])
def test_native_unavailable_is_clear_and_does_not_use_another_account(self):
with mock.patch.dict(sys.modules,{'wecom_native_sender':None}):
result=contacts.active_context()
self.assertFalse(result['ready']);self.assertEqual(result['account_id'],'')
self.assertIn('不支持',result['reason'])
class ReminderPickerTest(TestCase):
@classmethod
def setUpClass(cls): cls.app=QApplication.instance() or QApplication([])
def setUp(self):
# Exercise real Qt widgets but never schedule an unmocked native probe.
with mock.patch.object(qt.QTimer,'singleShot'):
self.dialog=qt.ReviewAssistantPicker(ACCOUNT)
self.addCleanup(self.dialog.deleteLater)
self.addCleanup(self.dialog.reject)
self.threads=mock.patch.object(qt.threading,'Thread',ImmediateThread)
self.threads.start();self.addCleanup(self.threads.stop)
self.listing=mock.patch.object(contacts,'list_recipients',return_value={
**ACTIVE,'items':[RECIPIENT,{**RECIPIENT,'id':'M:10002','identityLabel':'编号10002'}],
'total':2,'offset':0,'has_more':False})
self.list_mock=self.listing.start();self.addCleanup(self.listing.stop)
def test_same_name_rows_keep_exact_identity_and_validate_selected_one(self):
self.dialog._load()
self.assertEqual(self.dialog.contacts.count(),2)
self.dialog.contacts.setCurrentRow(1)
verified={**RECIPIENT,'id':'M:10002'}
with mock.patch.object(contacts,'validate_recipient',return_value={
'ok':True,'account_id':ACCOUNT,'recipient':verified}) as validate:
self.dialog._choose()
validate.assert_called_once_with(ACCOUNT,'M:10002')
self.assertEqual(self.dialog.result(),QDialog.DialogCode.Accepted)
self.assertEqual(self.dialog.recipient['id'],'M:10002')
def test_stale_list_and_late_validation_after_cancel_are_ignored(self):
self.dialog._serial=5
self.dialog._loaded({**ACTIVE,'_serial':4,'items':[RECIPIENT]})
self.assertEqual(self.dialog.contacts.count(),0)
self.dialog.reject()
self.dialog._validated({'ok':True,'account_id':ACCOUNT,'recipient':RECIPIENT})
self.assertIsNone(self.dialog.recipient)
self.assertEqual(self.dialog.result(),QDialog.DialogCode.Rejected)
def test_wrong_account_cannot_load_or_accept_and_retry_stays_available(self):
self.dialog._serial=1
self.dialog._loaded({**ACTIVE,'account_id':OTHER,'_serial':1,'items':[RECIPIENT]})
self.assertEqual(self.dialog.contacts.count(),0)
self.dialog._load();self.dialog.contacts.setCurrentRow(0)
with mock.patch.object(contacts,'validate_recipient',return_value={
'ok':False,'reason':'当前账号已变化','recipient':None}):
self.dialog._choose()
self.assertIsNone(self.dialog.recipient)
self.assertTrue(self.dialog.choose.isEnabled())
self.assertIn('账号已变化',self.dialog.status.text())
def test_unmapped_contact_is_visible_but_cannot_be_accepted(self):
reason = '此客户尚无可核验的单聊会话,请刷新我的客户和本机缓存。'
unavailable = {**RECIPIENT, 'id': 'customer:123456789012345', 'peer_id': '123456789012345', 'kind': '企业微信客户',
'identityLabel': '企业微信客户 · 客户编号 123456789012345', 'selectable': False,
'unavailableReason': reason, 'lastTime': ''}
self.list_mock.return_value = {**ACTIVE, 'items': [unavailable, RECIPIENT],
'total': 2, 'selectable_total': 1, 'contact_total': 1}
self.dialog._load()
item = self.dialog.contacts.item(0)
self.assertNotIn('123456789012345', item.text())
self.assertNotIn(reason, item.text())
self.assertTrue(item.flags() & qt.Qt.ItemFlag.ItemIsSelectable)
self.assertTrue(item.flags() & qt.Qt.ItemFlag.ItemIsEnabled)
self.assertIn('(1)', self.dialog.filter_buttons['sendable'].text())
self.assertIn('(1)', self.dialog.filter_buttons['directory'].text())
# Unlinked entries can be highlighted and inspected but cannot be saved.
self.dialog.contacts.setCurrentItem(item)
self.dialog._selection_changed(item)
self.assertIn('123456789012345', self.dialog.details_identity.text())
self.assertIn(reason, self.dialog.details_reason.text())
self.assertTrue(item.isSelected())
self.assertFalse(self.dialog.choose.isEnabled())
with mock.patch.object(contacts, 'validate_recipient') as validate:
self.dialog._choose()
validate.assert_not_called()
self.assertIsNone(self.dialog.recipient)
self.assertNotEqual(self.dialog.result(), QDialog.DialogCode.Accepted)
def test_refresh_rereads_cached_list_and_preserves_query_from_first_page(self):
self.dialog._load()
self.dialog.query.setText('备注或联系编号')
self.dialog._offset = 50
self.list_mock.reset_mock()
self.dialog.refresh.click()
self.list_mock.assert_called_once_with(ACCOUNT, '备注或联系编号', 50, 0, status='all', order='recent')
self.assertEqual(self.dialog._offset, 0)
self.assertFalse(self.dialog._search_timer.isActive())
self.assertTrue(self.dialog.refresh.isEnabled())
self.assertIn('备注', self.dialog.query.placeholderText())
self.assertIn('编号', self.dialog.query.placeholderText())
def test_refresh_remains_available_after_account_or_cache_error(self):
self.list_mock.return_value = {**ACTIVE, 'ready': False, 'reason': '请刷新消息库', 'items': []}
self.dialog._load()
self.assertTrue(self.dialog.refresh.isEnabled())
self.assertFalse(self.dialog.choose.isEnabled())
self.list_mock.return_value = {**ACTIVE, 'items': [RECIPIENT], 'total': 1}
self.dialog.refresh.click()
self.assertEqual(self.dialog.contacts.count(), 1)
self.assertFalse(self.dialog.choose.isEnabled())
def test_refresh_cannot_race_pending_recipient_validation(self):
self.dialog._load()
self.dialog.contacts.setCurrentRow(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()
self.assertFalse(self.dialog.refresh.isEnabled())
serial = self.dialog._serial
self.list_mock.reset_mock()
self.dialog._load(reset=True)
self.dialog.refresh.click()
self.list_mock.assert_not_called()
self.assertEqual(self.dialog._serial, serial)
with mock.patch.object(contacts, 'validate_recipient', return_value={
'ok': False, 'reason': '请重新核对联系人'}):
queued[0]()
self.assertTrue(self.dialog.refresh.isEnabled())
self.assertTrue(self.dialog.choose.isEnabled())
def test_stale_validation_result_cannot_accept_after_a_newer_request(self):
self.dialog._serial = 8
self.dialog._validated({'_serial': 7, 'ok': True, 'account_id': ACCOUNT, 'recipient': RECIPIENT})
self.assertIsNone(self.dialog.recipient)
self.assertNotEqual(self.dialog.result(), QDialog.DialogCode.Accepted)
class ReminderRefreshTest(TestCase):
def test_refresh_warns_for_invalid_saved_m_without_rewriting_or_disabling_it(self):
import review_assistant
host = host_fixture()
old = {'enabled': True, 'recipient_conv_id': 'M:10001', 'recipient_name': '旧助理'}
host.runtime_settings['review_assistant_accounts'][ACCOUNT] = dict(old)
with mock.patch.object(qt.threading, 'Thread', ImmediateThread), \
mock.patch.object(contacts, 'active_context', return_value=ACTIVE), \
mock.patch.object(contacts, 'list_recipients', return_value={**ACTIVE, 'items': []}), \
mock.patch.object(contacts, 'validate_recipient', return_value={
'ok': False, 'reason': '已保存联系人不在我的客户中', 'recipient': None}) as validate, \
mock.patch.object(review_assistant, 'notification_summary', return_value={}):
host._refresh_review_assistant()
validate.assert_called_once_with(ACCOUNT, 'M:10001')
state = host._review_assistant_console_state()
self.assertIn('不在我的客户', state['recipientWarning'])
self.assertEqual(state['recipient']['id'], 'M:10001')
self.assertEqual(host.runtime_settings['review_assistant_accounts'][ACCOUNT], old)
host.save_runtime_settings.assert_not_called()
def test_warning_for_old_target_is_not_applied_to_new_target_or_other_account(self):
host = host_fixture()
host.runtime_settings['review_assistant_accounts'] = {
ACCOUNT: {'enabled': False, 'recipient_conv_id': RECIPIENT_ID},
OTHER: {'enabled': False, 'recipient_conv_id': f'S:{OTHER}_10001'}}
host._review_assistant_state['recipient_check'] = {'id': 'M:10001', 'ok': False, 'reason': '旧目标无效'}
self.assertEqual(host._review_assistant_console_state()['recipientWarning'], '')
host._review_assistant_state['account_id'] = OTHER
self.assertEqual(host._review_assistant_console_state()['recipientWarning'], '')
self.assertEqual(host._review_assistant_console_state()['recipient']['id'], f'S:{OTHER}_10001')
def test_only_explicit_refresh_reads_notification_summary(self):
import review_assistant
host=host_fixture()
with mock.patch.object(qt.threading,'Thread',ImmediateThread), \
mock.patch.object(contacts,'active_context',return_value=ACTIVE), \
mock.patch.object(contacts,'list_recipients',return_value={**ACTIVE,'items':[]}), \
mock.patch.object(review_assistant,'notification_summary',return_value={
'status':'sent','message':'已发送提醒','updated_at':123}) as summary:
host._refresh_review_assistant()
summary.assert_called_once_with(qt.SCRIPT_DIR,ACCOUNT)
for _ in range(5):
state=host._review_assistant_console_state()
self.assertEqual(summary.call_count,1)
self.assertEqual(state['notification']['status'],'sent')