"""Synthetic review persistence: no desktop input, network or live queue access.""" import json from pathlib import Path import tempfile import threading import time import unittest from unittest import mock from wechat_bot import WeChatBot class VisualReviewVisibilityTest(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) self.path = Path(temporary.name) / 'pending.json' self.fp = b'v' * 40 self.key = self.fp.hex() self.bot = WeChatBot.__new__(WeChatBot) self.bot._pending_lock = threading.RLock() self.bot._pending_reply_path = str(self.path) self.bot._queue_log_instance = mock.Mock() self.bot._forget_uncertain_tracking = mock.Mock() self.bot.send_mode = 'auto' self.enterContext(mock.patch('review_policy.current_judge_mode', return_value='score_only')) self.state = { 'created_at': time.time(), 'updated_at': time.time(), 'display_name': '审核测试客户', 'stage': 'manual_review', 'awaiting_review': True, 'review_origin': 'rule', 'review_reason': '命中审核规则「测试规则」', 'staged_user_text': '合成测试问题', 'staged_reply_text': '合成待审草稿', 'reply_text': '合成待审草稿', 'judge_risk': 'high', 'judge_mode': 'score_only', 'judge_reason': '合成裁判原因', } self.bot._pending_reply_sessions = {self.key: self.state} for function in ('press', 'hotkey', 'click', 'write'): patcher = mock.patch('wechat_bot.pyautogui.' + function, side_effect=AssertionError('No real desktop action')) patcher.start() self.addCleanup(patcher.stop) def restart(self): self.assertTrue(self.bot._persist_pending_replies()) self.bot._pending_reply_sessions = self.bot._load_pending_replies() self.state = self.bot._pending_reply_sessions[self.key] return self.state def test_rule_reason_and_judge_survive_restart_and_auto_mode(self): restored = self.restart() self.assertEqual(restored['review_reason'], '命中审核规则「测试规则」') self.assertEqual(restored['judge_mode'], 'score_only') self.bot._drop_stale_review_flags() self.assertTrue(restored['awaiting_review']) self.assertEqual(restored['review_reason'], '模型裁判评估为高风险') self.assertEqual(restored['judge_risk'], 'high') self.assertEqual(restored['judge_reason'], '合成裁判原因') self.assertEqual(restored['staged_user_text'], '合成测试问题') self.assertEqual(restored['staged_reply_text'], '合成待审草稿') def test_review_without_unread_badge_does_not_expire_during_downtime(self): self.state['created_at'] = self.state['updated_at'] = time.time() - 3 * 86400 self.state['confirmed_unread'] = False self.assertTrue(self.restart()['awaiting_review']) def test_legacy_review_with_lost_reason_stays_actionable(self): self.state.pop('review_reason') self.state.pop('review_origin') self.state['stage_detail'] = 'AI 回复已填入输入框,等待人工核对并发送' self.path.write_text(json.dumps({self.key: self.state}), encoding='utf-8') self.bot._pending_reply_sessions = self.bot._load_pending_replies() restored = self.bot._pending_reply_sessions[self.key] self.bot._drop_stale_review_flags() self.assertTrue(restored['awaiting_review']) self.assertIn('历史审核原因', restored['review_reason']) def test_legacy_review_recovers_rule_reason_from_stage_detail(self): self.state.pop('review_reason') self.state.pop('review_origin') self.state['stage_detail'] = '命中审核规则「测试规则」,已停止自动发送' self.assertIn('测试规则', self.restart()['review_reason']) def test_legacy_manual_review_stage_without_boolean_is_restored(self): self.state.pop('awaiting_review') restored = self.restart() self.assertTrue(restored['awaiting_review']) self.assertEqual(restored['stage'], 'manual_review') def test_known_global_review_still_honors_explicit_mode_change(self): self.state.update(review_origin='global', review_reason='', judge_risk='') restored = self.restart() self.bot._drop_stale_review_flags() self.assertFalse(restored.get('awaiting_review')) def test_explicit_approval_survives_restart_but_is_consumed_on_next_turn(self): result = self.bot.approve_pending_replies([self.key]) self.assertEqual(result['approved'], [self.key]) restored = self.restart() self.assertFalse(restored['awaiting_review']) self.assertTrue(restored['review_approved']) self.assertGreater(restored['review_approved_at'], 0) self.assertEqual(restored['stage'], 'queued') self.bot._reset_pending_batch(self.fp) self.assertFalse(restored.get('review_approved')) self.assertNotIn('judge_risk', restored) self.assertNotIn('review_origin', restored) def test_next_customer_turn_does_not_inherit_previous_judge_risk(self): self.bot._reset_pending_batch(self.fp) with mock.patch('ai_config.AI_REVIEW_RULES', []): self.assertEqual(self.bot._reply_needs_review(self.state, '新问题', '新回答'), (False, '')) def test_review_outcome_preserves_specific_reason_for_desktop_and_log(self): self.bot._log_send_outcome(self.fp, False, '合成待审草稿') self.assertIn('测试规则', self.state['stage_detail']) self.assertEqual(self.state['stage'], 'manual_review') event = self.bot._queue_log_instance.append.call_args self.assertIn('测试规则', event.args[-1]) def test_user_can_cancel_restored_review_without_sending(self): self.restart() result = self.bot.cancel_pending_replies([self.key]) self.assertEqual(result['deleted'], [self.key]) self.assertNotIn(self.key, self.bot._pending_reply_sessions) self.assertEqual(json.loads(self.path.read_text(encoding='utf-8')), {}) def test_failed_approval_save_keeps_review_visible_and_unapproved(self): with mock.patch.object(self.bot, '_persist_pending_replies_unlocked', return_value=False): result = self.bot.approve_pending_replies([self.key]) self.assertFalse(result['approved']) self.assertIn('error', result) restored = self.bot._pending_reply_sessions[self.key] self.assertTrue(restored['awaiting_review']) self.assertFalse(restored.get('review_approved')) if __name__ == '__main__': unittest.main()