176 lines
8.9 KiB
Python
176 lines
8.9 KiB
Python
"""Exercise production Qt loader methods without a desktop or real services."""
|
|
import ast
|
|
import json
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
from pathlib import Path
|
|
from types import MethodType, SimpleNamespace
|
|
from unittest import mock
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
import wxwork_message_browser as browser
|
|
import wxwork_local_setup as setup
|
|
|
|
|
|
class LocalFirstLoaderTest(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
module = ast.parse(Path(__file__).with_name('wechat_gui_qt.py').read_text(encoding='utf-8'))
|
|
window = next(item for item in module.body if isinstance(item, ast.ClassDef) and item.name == 'MainWindow')
|
|
names = {'_refresh_wechat_database', '_request_wechat_patient_context', '_wechat_database_loaded'}
|
|
methods = [item for item in window.body if isinstance(item, ast.FunctionDef) and item.name in names]
|
|
for method in methods:
|
|
method.decorator_list = []
|
|
namespace = {'threading': threading}
|
|
exec(compile(ast.Module(body=methods, type_ignores=[]), 'wechat_gui_qt.py', 'exec'), namespace)
|
|
cls.methods = {name: namespace[name] for name in names}
|
|
|
|
def setUp(self):
|
|
self.jobs = []
|
|
self.events = []
|
|
self.host = SimpleNamespace(
|
|
_wechat_db_state={}, _wechat_db_selected_account='', _wechat_db_selected_conversation='',
|
|
_wechat_db_query='', _wechat_db_load_serial=0, _wechat_db_pending_request=None,
|
|
_wechat_patient_loading=False, _wechat_patient_pending=None,
|
|
_schedule_console_push=mock.Mock(), _push_console_state=mock.Mock(), append_log=mock.Mock(),
|
|
)
|
|
for name, method in self.methods.items():
|
|
setattr(self.host, name, MethodType(method, self.host))
|
|
def emit(result):
|
|
self.events.append(dict(result))
|
|
self.host._wechat_database_loaded(result)
|
|
self.host.wechatDatabaseLoaded = SimpleNamespace(emit=emit)
|
|
def thread(**kwargs):
|
|
return SimpleNamespace(start=lambda:self.jobs.append((kwargs['name'], kwargs['target'])))
|
|
patcher = mock.patch.object(threading, 'Thread', side_effect=thread)
|
|
patcher.start();self.addCleanup(patcher.stop)
|
|
patcher = mock.patch.object(browser, 'load_browser_snapshot', side_effect=self.snapshot)
|
|
self.load = patcher.start();self.addCleanup(patcher.stop)
|
|
patcher = mock.patch('backend_client.desktop_patient_context', return_value={'available':True, 'people':[]})
|
|
self.patient = patcher.start();self.addCleanup(patcher.stop)
|
|
|
|
@staticmethod
|
|
def snapshot(**kwargs):
|
|
return {'loading':False, 'error':'', 'selectedAccount':kwargs['selected_account'] or 'a',
|
|
'selectedConversation':kwargs['selected_conversation'] or 'first', 'query':kwargs['query'],
|
|
'messages':[{'content':'synthetic local message'}], 'accounts':[]}
|
|
|
|
def run_job(self, name):
|
|
index = next(i for i, item in enumerate(self.jobs) if item[0] == name)
|
|
self.jobs.pop(index)[1]()
|
|
|
|
def test_local_chat_arrives_before_remote_lookup_begins(self):
|
|
self.host._refresh_wechat_database()
|
|
self.run_job('wechat-database-browser')
|
|
self.assertEqual(self.host._wechat_db_state['messages'][0]['content'], 'synthetic local message')
|
|
self.assertFalse(self.host._wechat_db_state['loading'])
|
|
self.assertTrue(self.host._wechat_db_state['patientContext']['loading'])
|
|
self.patient.assert_not_called()
|
|
self.run_job('wechat-patient-context')
|
|
self.assertTrue(self.host._wechat_db_state['patientContext']['available'])
|
|
|
|
def test_remote_timeout_keeps_local_chat_visible_and_not_setup_error(self):
|
|
self.patient.side_effect = TimeoutError('synthetic timeout')
|
|
self.host._refresh_wechat_database();self.run_job('wechat-database-browser')
|
|
self.run_job('wechat-patient-context')
|
|
state = self.host._wechat_db_state
|
|
self.assertFalse(state['loading']);self.assertFalse(state['error'])
|
|
self.assertEqual(len(state['messages']), 1)
|
|
self.assertIn('synthetic timeout', state['patientContext']['error'])
|
|
self.assertFalse(state.get('manualSetupRequired', False))
|
|
|
|
def test_busy_navigation_is_coalesced_to_latest_account_conversation_and_query(self):
|
|
self.host._refresh_wechat_database()
|
|
self.host._refresh_wechat_database(selected_account='b',selected_conversation='')
|
|
self.host._refresh_wechat_database(query='new',selected_conversation='')
|
|
self.host._refresh_wechat_database(selected_conversation='chosen')
|
|
self.assertEqual(len(self.jobs),1)
|
|
self.run_job('wechat-database-browser');self.run_job('wechat-database-browser')
|
|
self.assertEqual(self.load.call_count,2)
|
|
self.assertEqual(self.load.call_args.kwargs['selected_account'],'b')
|
|
self.assertEqual(self.load.call_args.kwargs['selected_conversation'],'chosen')
|
|
self.assertEqual(self.load.call_args.kwargs['query'],'new')
|
|
self.assertEqual(len([item for item in self.jobs if item[0]=='wechat-patient-context']),1)
|
|
|
|
def test_old_patient_result_never_replaces_new_selection_and_only_latest_is_queued(self):
|
|
self.host._refresh_wechat_database();self.run_job('wechat-database-browser')
|
|
for conversation in ('second','third'):
|
|
self.host._refresh_wechat_database(selected_conversation=conversation)
|
|
self.run_job('wechat-database-browser')
|
|
self.assertEqual(len([item for item in self.jobs if item[0]=='wechat-patient-context']),1)
|
|
self.run_job('wechat-patient-context')
|
|
self.assertTrue(self.host._wechat_db_state['patientContext']['loading'])
|
|
self.assertEqual(self.host._wechat_db_state['selectedConversation'],'third')
|
|
self.run_job('wechat-patient-context')
|
|
self.assertEqual(self.patient.call_args_list,[mock.call('a','first'),mock.call('a','third')])
|
|
|
|
def test_repeated_refresh_during_load_does_not_queue_another_initialization(self):
|
|
self.host._refresh_wechat_database(refresh_cache=True)
|
|
for _ in range(5):
|
|
self.host._refresh_wechat_database(refresh_cache=True)
|
|
self.run_job('wechat-database-browser')
|
|
self.assertEqual(self.load.call_count,1)
|
|
self.assertIsNone(self.host._wechat_db_pending_request)
|
|
|
|
def test_stale_database_result_is_ignored(self):
|
|
self.host._wechat_db_load_serial=5
|
|
self.host._wechat_database_loaded({'_serial':4,'selectedAccount':'wrong','loading':False})
|
|
self.assertEqual(self.host._wechat_db_state,{})
|
|
|
|
|
|
class KeyAcquisitionSingleFlightTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
|
|
patcher=mock.patch.object(setup,'application_data_dir',return_value=Path(self.temp.name))
|
|
patcher.start();self.addCleanup(patcher.stop)
|
|
self.assertFalse(setup._key_acquisition_inflight)
|
|
|
|
def concurrent_run(self, failure=None):
|
|
entered=threading.Event();release=threading.Event();waiting=threading.Event()
|
|
def once(timeout):
|
|
entered.set()
|
|
if not release.wait(3):
|
|
raise AssertionError('test coordination timed out')
|
|
if failure:
|
|
raise failure
|
|
with mock.patch.object(setup,'_acquire_local_keys_once',side_effect=once) as acquire, ThreadPoolExecutor(2) as pool:
|
|
first=pool.submit(setup.acquire_local_keys)
|
|
self.assertTrue(entered.wait(2))
|
|
future=next(iter(setup._key_acquisition_inflight.values()))
|
|
original=future.result
|
|
def joined(*args,**kwargs):
|
|
waiting.set();return original(*args,**kwargs)
|
|
with mock.patch.object(future,'result',side_effect=joined):
|
|
second=pool.submit(setup.acquire_local_keys)
|
|
try:
|
|
self.assertTrue(waiting.wait(2))
|
|
finally:
|
|
release.set()
|
|
if failure:
|
|
for result in (first,second):
|
|
with self.assertRaisesRegex(ValueError,'synthetic'):
|
|
result.result(2)
|
|
else:
|
|
first.result(2);second.result(2)
|
|
self.assertEqual(acquire.call_count,1)
|
|
self.assertFalse(setup._key_acquisition_inflight)
|
|
|
|
def test_simultaneous_browser_and_protocol_initialization_start_one_worker(self):
|
|
self.concurrent_run()
|
|
|
|
def test_failure_is_shared_and_does_not_poison_later_retry(self):
|
|
self.concurrent_run(ValueError('synthetic failure'))
|
|
with mock.patch.object(setup,'_acquire_local_keys_once') as acquire:
|
|
setup.acquire_local_keys()
|
|
acquire.assert_called_once()
|
|
|
|
def test_completed_result_is_not_reused_for_later_explicit_refresh(self):
|
|
with mock.patch.object(setup,'_acquire_local_keys_once') as acquire:
|
|
setup.acquire_local_keys();setup.acquire_local_keys()
|
|
self.assertEqual(acquire.call_count,2)
|
|
|
|
|
|
if __name__=='__main__':
|
|
unittest.main()
|