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

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'])