504 lines
30 KiB
Python
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')
|