185 lines
12 KiB
Python
185 lines
12 KiB
Python
"""Native Qt queue -> web snapshot -> explicit operator action regressions."""
|
|
import json
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from types import SimpleNamespace, MethodType
|
|
from unittest import TestCase, mock
|
|
from PySide6.QtWidgets import QApplication, QMessageBox
|
|
import wechat_gui_qt as qt
|
|
from review_state import task_review_state
|
|
|
|
class ReviewStateTest(TestCase):
|
|
def test_protocol_and_visual_reviews_have_the_same_action_contract(self):
|
|
for stage in ('awaiting_review','manual_review'):
|
|
with self.subTest(stage=stage):
|
|
result=task_review_state({'stage':stage,'reply_text':'合成草稿','stage_detail':'需要核对测试规则'})
|
|
self.assertTrue(result['awaitingReview']);self.assertTrue(result['canApprove'])
|
|
self.assertFalse(result['needsHuman']);self.assertIn('测试规则',result['reviewReason'])
|
|
|
|
def test_media_without_draft_cannot_be_approved(self):
|
|
result=task_review_state({'stage':'error','manual_required':True,'manual_reason':'图片需要人工处理'})
|
|
self.assertTrue(result['needsHuman']);self.assertFalse(result['canApprove']);self.assertFalse(result['canRetry'])
|
|
|
|
def test_uncertain_delivery_cannot_be_approved_or_retried_even_with_stale_review_flag(self):
|
|
result=task_review_state({'stage':'receipt_check','send_state':'uncertain','reply_text':'可能已经发出','awaiting_review':True})
|
|
self.assertTrue(result['deliveryUncertain']);self.assertTrue(result['needsHuman'])
|
|
self.assertFalse(result['canApprove']);self.assertFalse(result['canRetry'])
|
|
|
|
def test_generation_failure_is_retryable_but_not_sendable(self):
|
|
result=task_review_state({'stage':'error','stage_error':'模型调用失败'})
|
|
self.assertTrue(result['canRetry']);self.assertFalse(result['canApprove'])
|
|
|
|
class DesktopReviewTest(TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.app=QApplication.instance() or QApplication([])
|
|
|
|
def setUp(self):
|
|
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
|
|
self.path=Path(self.temp.name)/'pending.json'
|
|
self.path.write_text('{}',encoding='utf-8')
|
|
if hasattr(qt,'queue_path_for_transport'):
|
|
patch=mock.patch.object(qt,'queue_path_for_transport',return_value=self.path)
|
|
else:
|
|
patch=mock.patch.object(qt,'SCRIPT_DIR',Path(self.temp.name))
|
|
self.path=Path(self.temp.name)/'pending_replies.json';self.path.write_text('{}',encoding='utf-8')
|
|
patch.start();self.addCleanup(patch.stop)
|
|
self.page=qt.QueuePage();self.addCleanup(self.page.deleteLater)
|
|
self.host=SimpleNamespace(queue_page=self.page,runtime_settings={'mask_sensitive':False,'reply_transport':'protocol'},
|
|
_console_selected_key='',_running=False,_thread=None,log_page=SimpleNamespace(),
|
|
_console_wecom_info=lambda:{'connected':False},append_log=mock.Mock(),_schedule_console_push=mock.Mock())
|
|
for name in ('_console_people','_console_task','_console_metrics','_console_stage_durations'):
|
|
setattr(self.host,name,MethodType(getattr(qt.MainWindow,name),self.host))
|
|
for name in ('_console_clock_label','_console_seconds_label','_console_wait_seconds','_console_queue_wait_label'):
|
|
setattr(self.host,name,getattr(qt.MainWindow,name))
|
|
|
|
def seed(self):
|
|
common={'created_at':time.time(),'display_name':'合成客户','confirmed_unread':True,'staged_user_text':'合成问题'}
|
|
states={'ordinary':{**common,'stage':'queued'},
|
|
'review':{**common,'stage':'awaiting_review','reply_text':'待审核合成回复','review_reason':'命中测试规则','detected_by':'protocol_db'},
|
|
'human':{**common,'stage':'error','manual_required':True,'manual_reason':'需人工看图'},
|
|
'uncertain':{**common,'stage':'receipt_check','send_state':'uncertain','reply_text':'待核对合成回复'}}
|
|
self.path.write_text(json.dumps({'pending':states}),encoding='utf-8')
|
|
self.page.refresh_data()
|
|
|
|
def test_review_filter_surfaces_protocol_draft_and_reason(self):
|
|
self.seed();self.page._set_task_filter('待审核')
|
|
people=self.host._console_people()
|
|
self.assertEqual([row['key'] for row in people],['review'])
|
|
self.assertEqual(people[0]['status'],'待审核')
|
|
task=self.host._console_task(people)
|
|
self.assertEqual(task['answer'],'待审核合成回复');self.assertIn('测试规则',task['reviewReason'])
|
|
self.assertTrue(task['canApprove']);self.assertTrue(task['isProtocol'])
|
|
|
|
def test_human_filter_includes_unknown_receipt_and_no_empty_send(self):
|
|
self.seed();self.page._set_task_filter('需人工');people=self.host._console_people()
|
|
self.assertEqual({row['key'] for row in people},{'human','uncertain'})
|
|
self.host._console_selected_key='uncertain';task=self.host._console_task(people)
|
|
self.assertTrue(task['deliveryUncertain']);self.assertFalse(task['canApprove']);self.assertFalse(task['canRetry'])
|
|
self.assertIn('不会自动重发',task['reviewReason'])
|
|
|
|
def test_review_counts_remain_visible_under_an_unrelated_filter(self):
|
|
self.seed();self.page._set_task_filter('等待中')
|
|
metrics=self.host._console_metrics(self.host._console_people(),[])
|
|
self.assertEqual(metrics['review'],'1');self.assertEqual(metrics['human'],'2')
|
|
|
|
def test_explicit_approval_reaches_thread_with_exact_key_and_refreshes(self):
|
|
self.seed();self.host._thread=SimpleNamespace(is_alive=lambda:True,approve_pending_tasks=mock.Mock(return_value={'approved':['review']}))
|
|
with mock.patch.object(self.page,'refresh_data',wraps=self.page.refresh_data) as refresh:
|
|
qt.MainWindow._approve_queue_tasks(self.host,['review'])
|
|
self.host._thread.approve_pending_tasks.assert_called_once_with(['review']);refresh.assert_called_once()
|
|
|
|
def test_unknown_receipt_handoff_requires_confirmation_and_never_calls_approve(self):
|
|
self.seed();thread=SimpleNamespace(is_alive=lambda:True,handoff_pending_tasks=mock.Mock(return_value={'handed_off':['uncertain']}),approve_pending_tasks=mock.Mock())
|
|
self.host._thread=thread
|
|
with mock.patch.object(QMessageBox,'question',return_value=QMessageBox.StandardButton.No):
|
|
qt.MainWindow._handoff_queue_tasks(self.host,['uncertain'])
|
|
thread.handoff_pending_tasks.assert_not_called()
|
|
with mock.patch.object(QMessageBox,'question',return_value=QMessageBox.StandardButton.Yes):
|
|
qt.MainWindow._handoff_queue_tasks(self.host,['uncertain'])
|
|
thread.handoff_pending_tasks.assert_called_once_with(['uncertain'], confirm_uncertain=True);thread.approve_pending_tasks.assert_not_called()
|
|
|
|
def test_stopped_handoff_uses_offline_resolution_and_preserves_seen(self):
|
|
self.seed();raw=json.loads(self.path.read_text(encoding='utf-8'));raw['seen']={'synthetic-dedup':123};self.path.write_text(json.dumps(raw),encoding='utf-8')
|
|
with mock.patch.object(QMessageBox,'question',return_value=QMessageBox.StandardButton.Yes):
|
|
qt.MainWindow._handoff_queue_tasks(self.host,['uncertain'])
|
|
result=json.loads(self.path.read_text(encoding='utf-8'))
|
|
self.assertNotIn('uncertain',result['pending']);self.assertEqual(result['seen'],{'synthetic-dedup':123})
|
|
self.assertIn('review',result['pending'])
|
|
|
|
def test_async_protocol_check_retains_explicit_approval_request(self):
|
|
if not hasattr(qt,'protocol_ready'):
|
|
self.skipTest('This directory has no protocol transport')
|
|
self.host.start_monitoring=mock.Mock(side_effect=lambda:setattr(self.host,'_protocol_start_pending',True))
|
|
with mock.patch.object(QMessageBox,'warning') as warning:
|
|
qt.MainWindow._approve_queue_tasks(self.host,['review'])
|
|
self.assertEqual(self.host._deferred_review_approvals,['review']);warning.assert_not_called()
|
|
|
|
def test_stale_snapshot_requires_explicit_confirmation_before_backend_handoff(self):
|
|
self.seed()
|
|
thread=SimpleNamespace(is_alive=lambda:True, handoff_pending_tasks=mock.Mock(side_effect=[
|
|
{'handed_off':[], 'protected':['ordinary'], 'requires_confirmation':['ordinary']},
|
|
{'handed_off':['ordinary'], 'protected':[]},
|
|
]))
|
|
self.host._thread=thread
|
|
with mock.patch.object(QMessageBox,'question',return_value=QMessageBox.StandardButton.Yes) as question:
|
|
qt.MainWindow._handoff_queue_tasks(self.host,['ordinary'])
|
|
self.assertEqual(thread.handoff_pending_tasks.call_args_list,[
|
|
mock.call(['ordinary'],confirm_uncertain=False), mock.call(['ordinary'],confirm_uncertain=True)])
|
|
question.assert_called_once()
|
|
|
|
def test_legacy_receipt_states_are_visible_but_not_sendable(self):
|
|
for state in ({'stage':'receipt_check'}, {'send_state':'unknown'}, {'send_state':'sent_uncommitted'}):
|
|
with self.subTest(state=state):
|
|
flags=task_review_state(state)
|
|
self.assertTrue(flags['deliveryUncertain']);self.assertTrue(flags['needsHuman'])
|
|
self.assertFalse(flags['canApprove']);self.assertFalse(flags['canRetry'])
|
|
self.assertEqual(self.page._display_task_status(self.page._state_text(state)),'需人工')
|
|
|
|
def test_real_queue_snapshot_renders_review_and_handoff_in_embedded_browser(self):
|
|
from PySide6.QtCore import QEventLoop, QTimer
|
|
from PySide6.QtWidgets import QMainWindow
|
|
self.seed()
|
|
owner=QMainWindow();owner._push_console_state=mock.Mock()
|
|
shell=qt.ConsoleShell(owner);shell.resize(1440,900);shell.show()
|
|
self.addCleanup(owner.deleteLater);self.addCleanup(shell.deleteLater);self.addCleanup(shell.close)
|
|
if not shell.page_ready:
|
|
loaded=[];loop=QEventLoop()
|
|
def finished(ok):
|
|
loaded.append(bool(ok));loop.quit()
|
|
shell.loadFinished.connect(finished);QTimer.singleShot(10000,loop.quit);loop.exec()
|
|
shell.loadFinished.disconnect(finished);self.assertEqual(loaded,[True])
|
|
|
|
def evaluate(script):
|
|
values=[];loop=QEventLoop()
|
|
def completed(value):
|
|
values.append(value);loop.quit()
|
|
shell.page().runJavaScript(script,completed);QTimer.singleShot(5000,loop.quit);loop.exec()
|
|
self.assertEqual(len(values),1)
|
|
return values[0]
|
|
|
|
for key,filter_name in (('review','待审核'),('uncertain','需人工')):
|
|
self.page._set_task_filter(filter_name);self.host._console_selected_key=key
|
|
people=self.host._console_people();task=self.host._console_task(people)
|
|
payload={'view':'queue','demo':False,'ready':True,'people':people,'task':task,
|
|
'selectedKey':key,'queueFilter':filter_name,'system':{'scale':'自动'},
|
|
'metrics':self.host._console_metrics(people,[])}
|
|
encoded=json.dumps(payload,ensure_ascii=False)
|
|
rendered=json.loads(evaluate(f"""
|
|
window.applyState({encoded});
|
|
JSON.stringify({{
|
|
approve:!!document.querySelector('[data-action="approve-task"]'),
|
|
cancel:!!document.querySelector('[data-action="cancel-task"]'),
|
|
handoff:document.querySelector('[data-action="handoff-task"]')?.textContent||'',
|
|
detail:document.querySelector('.queue-task-detail')?.textContent||''
|
|
}});
|
|
"""))
|
|
if key=='review':
|
|
self.assertTrue(rendered['approve']);self.assertIn('测试规则',rendered['detail'])
|
|
self.assertIn('待审核合成回复',rendered['detail'])
|
|
else:
|
|
self.assertFalse(rendered['approve']);self.assertFalse(rendered['cancel'])
|
|
self.assertIn('核对后转人工',rendered['handoff'])
|
|
self.assertIn('不会自动重发',rendered['detail'])
|