"""State-only receipt records and stale UI snapshots must fail closed.""" import json from pathlib import Path import queue import tempfile from types import SimpleNamespace import unittest from unittest import mock from gui_runtime import BotThread, delete_pending_reply_file, handoff_pending_reply_file class OfflineHandoffSafetyTests(unittest.TestCase): def test_sending_stage_cannot_be_removed_even_after_confirmation(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) path = root/'queue.json' original = json.dumps({'pending': {'task': {'stage': 'sending'}}}) path.write_text(original, encoding='utf-8') for action in (delete_pending_reply_file, handoff_pending_reply_file): with self.subTest(action=action.__name__), mock.patch('queue_log.application_data_dir', return_value=root): kwargs = {'confirm_uncertain': True} if action is handoff_pending_reply_file else {} self.assertEqual(action(['task'], str(path), **kwargs)['protected'], ['task']) self.assertEqual(path.read_text(encoding='utf-8'), original) def test_all_receipt_variants_require_explicit_confirmation_at_mutation_time(self): variants = [{'stage': state} for state in ('receipt_check', 'uncertain', 'unknown')] variants += [{'send_state': state} for state in ('uncertain', 'sent_uncommitted', 'unknown')] for state in variants: with self.subTest(state=state), tempfile.TemporaryDirectory() as tmp: root = Path(tmp) path = root/'queue.json' original = json.dumps({'transport': 'protocol', 'pending': {'task': dict(state, dedup_key='event')}, 'seen': {}}) path.write_text(original, encoding='utf-8') with mock.patch('queue_log.application_data_dir', return_value=root): ordinary = delete_pending_reply_file(['task'], str(path)) self.assertEqual(ordinary['protected'], ['task']) self.assertEqual(path.read_text(encoding='utf-8'), original) blocked = handoff_pending_reply_file(['task'], str(path)) self.assertEqual(blocked['requires_confirmation'], ['task']) self.assertEqual(path.read_text(encoding='utf-8'), original) accepted = handoff_pending_reply_file(['task'], str(path), confirm_uncertain=True) self.assertEqual(accepted['handed_off'], ['task']) self.assertEqual(json.loads(path.read_text(encoding='utf-8'))['seen'], {'task': 'event'}) def test_startup_confirmation_is_per_task_not_batch_wide(self): thread = BotThread(queue.Queue(), '合成回复', .2) thread.handoff_pending_tasks(['normal']) thread.handoff_pending_tasks(['uncertain'], confirm_uncertain=True) bot = SimpleNamespace(handoff_pending_replies=mock.Mock(return_value={})) thread._drain_queued_handoffs(bot) self.assertEqual(bot.handoff_pending_replies.call_args_list, [ mock.call({'normal'}, confirm_uncertain=False), mock.call({'uncertain'}, confirm_uncertain=True)]) thread._drain_queued_handoffs(bot) self.assertEqual(bot.handoff_pending_replies.call_count, 2) try: from protocol_engine import ProtocolBot, session_key from test_native_protocol_pipeline import Database, Sender, message, ACCOUNT, CONV except ImportError: ProtocolBot = None @unittest.skipIf(ProtocolBot is None, 'visual-only distribution has no protocol engine') class ProtocolHandoffSafetyTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) self.db = Database() self.sender = Sender(self.db) self.generate = mock.Mock(return_value='合成回复') for patcher in (mock.patch('protocol_engine.application_data_dir', return_value=self.root), mock.patch('queue_log.application_data_dir', return_value=self.root)): patcher.start() self.addCleanup(patcher.stop) self.key = session_key(ACCOUNT, CONV) self.bot = self.make_bot() def make_bot(self): return ProtocolBot(db=self.db, sender=self.sender, identity={'accountId': ACCOUNT, 'pid': 1}, path=self.root/'queue.json', generate=self.generate, log=lambda _: None) def set_state(self, **state): self.bot._pending[self.key] = dict(account=ACCOUNT, conv_id=CONV, dedup_key=message()['dedup_key'], **state) self.bot._save() def test_stale_ui_snapshot_cannot_handoff_newly_uncertain_response(self): self.set_state(stage='receipt_check', send_state='uncertain') self.bot.needs_attention = True thread = BotThread(queue.Queue(), '合成回复', .2) thread.bot = self.bot blocked = thread.handoff_pending_tasks([self.key]) self.assertEqual(blocked['requires_confirmation'], [self.key]) self.assertIn(self.key, self.bot._pending) self.assertTrue(self.bot.needs_attention) accepted = thread.handoff_pending_tasks([self.key], confirm_uncertain=True) self.assertEqual(accepted['handed_off'], [self.key]) self.assertFalse(self.bot.needs_attention) self.assertFalse(self.sender.calls) def test_stage_only_sending_is_protected_then_restores_as_unknown_receipt(self): self.set_state(stage='sending', reply_text='可能已发送') self.assertEqual(self.bot.handoff_pending_replies([self.key], confirm_uncertain=True)['protected'], [self.key]) restored = self.make_bot() self.assertTrue(restored.needs_attention) self.assertEqual(restored._pending[self.key]['send_state'], 'uncertain') self.assertEqual(restored.handoff_pending_replies([self.key])['requires_confirmation'], [self.key]) restored.poll_once() self.assertFalse(self.sender.calls) self.generate.assert_not_called() def test_stage_only_receipt_blocks_approval_retry_cancel_and_incoming_replacement(self): self.set_state(stage='receipt_check', reply_text='可能已发送', awaiting_review=True) for action in (self.bot.approve_pending_replies, self.bot.retry_pending_replies, self.bot.cancel_pending_replies): self.assertEqual(action([self.key])['protected'], [self.key]) self.assertEqual(self.bot.handoff_pending_replies([self.key])['requires_confirmation'], [self.key]) self.bot._upsert(message(2)) self.assertEqual(self.bot._pending[self.key]['dedup_key'], message()['dedup_key']) self.bot.poll_once() self.assertFalse(self.sender.calls) self.generate.assert_not_called() if __name__ == '__main__': unittest.main()