"""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':'同名助理 ','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')