@@ -931,6 +931,263 @@ class RecorderSignatureMigrationTests(unittest.TestCase):
|
||||
engine.warmup.assert_called_once()
|
||||
|
||||
|
||||
class RecorderConversationStateTests(unittest.TestCase):
|
||||
def test_legacy_active_state_migrates_only_to_its_matching_conversation(self):
|
||||
engine = mock.Mock()
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"ensure_directories",
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"OcrEngine",
|
||||
return_value=engine,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"read_json",
|
||||
return_value={
|
||||
"initialized": True,
|
||||
"last_group_fingerprint": "group-a",
|
||||
"last_visual_hash": "visual-a",
|
||||
"visible_signatures": ["message-a"],
|
||||
"recent_trigger_signatures": ["trigger-a"],
|
||||
"message_signature_scheme": MODULE.MESSAGE_SIGNATURE_SCHEME,
|
||||
},
|
||||
):
|
||||
recorder = MODULE.Recorder(queue_enabled=True)
|
||||
|
||||
group_a = recorder.conversation_state("group-a")
|
||||
group_b = recorder.conversation_state("group-b")
|
||||
|
||||
self.assertTrue(group_a["initialized"])
|
||||
self.assertEqual(group_a["visible_signatures"], ["message-a"])
|
||||
self.assertFalse(group_b["initialized"])
|
||||
self.assertEqual(group_b["visible_signatures"], [])
|
||||
|
||||
def test_switching_conversations_does_not_cross_deduplicate_messages(self):
|
||||
engine = mock.Mock()
|
||||
first_a = MODULE.IncomingMessage(
|
||||
"Alice",
|
||||
"@JWeBot first",
|
||||
0.98,
|
||||
0.99,
|
||||
0.98,
|
||||
100,
|
||||
"a-first",
|
||||
)
|
||||
first_b = MODULE.IncomingMessage(
|
||||
"Bob",
|
||||
"@JWeBot first",
|
||||
0.98,
|
||||
0.99,
|
||||
0.98,
|
||||
100,
|
||||
"b-first",
|
||||
)
|
||||
second_a = MODULE.IncomingMessage(
|
||||
"Alice",
|
||||
"@JWeBot second",
|
||||
0.98,
|
||||
0.99,
|
||||
0.98,
|
||||
160,
|
||||
"a-second",
|
||||
)
|
||||
engine.recognize_messages.side_effect = [
|
||||
([first_a], 0.01),
|
||||
([first_b], 0.01),
|
||||
([first_a, second_a], 0.01),
|
||||
]
|
||||
engine.last_cache_hits = 0
|
||||
engine.last_cache_misses = 1
|
||||
engine.last_cache_refresh = False
|
||||
queued = []
|
||||
image = MODULE.Image.new(
|
||||
"RGB",
|
||||
(MODULE.EXPECTED_WIDTH, MODULE.EXPECTED_HEIGHT),
|
||||
"white",
|
||||
)
|
||||
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"ensure_directories",
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"OcrEngine",
|
||||
return_value=engine,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"read_json",
|
||||
return_value={},
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"incoming_mask_and_bubbles",
|
||||
return_value=(None, []),
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"write_history_event",
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"queue_event",
|
||||
side_effect=queued.append,
|
||||
):
|
||||
recorder = MODULE.Recorder(queue_enabled=True)
|
||||
recorder.save_state = mock.Mock()
|
||||
recorder.process_stable_image(
|
||||
image,
|
||||
"layout-a-1",
|
||||
visual_hash="visual-a-1",
|
||||
observed_at="2026-10-20T12:00:00+08:00",
|
||||
group_fingerprint="group-a",
|
||||
)
|
||||
recorder.process_stable_image(
|
||||
image,
|
||||
"layout-b-1",
|
||||
visual_hash="visual-b-1",
|
||||
observed_at="2026-10-20T12:00:01+08:00",
|
||||
group_fingerprint="group-b",
|
||||
)
|
||||
recorder.process_stable_image(
|
||||
image,
|
||||
"layout-a-2",
|
||||
visual_hash="visual-a-2",
|
||||
observed_at="2026-10-20T12:00:02+08:00",
|
||||
group_fingerprint="group-a",
|
||||
)
|
||||
|
||||
self.assertEqual(len(queued), 1)
|
||||
self.assertEqual(queued[0]["group_fingerprint"], "group-a")
|
||||
self.assertEqual(queued[0]["text"], "@JWeBot second")
|
||||
states = recorder.state["conversation_states"]
|
||||
self.assertEqual(states["group-a"]["visible_signatures"], ["a-first", "a-second"])
|
||||
self.assertEqual(states["group-b"]["visible_signatures"], ["b-first"])
|
||||
|
||||
def test_shadow_direct_messages_are_observed_without_queueing(self):
|
||||
class ShadowTarget:
|
||||
def question(self, text, *, bot_mention):
|
||||
del bot_mention
|
||||
return text if text.startswith("/") else ""
|
||||
|
||||
class ShadowRegistry:
|
||||
def target_for_fingerprint(self, fingerprint):
|
||||
return ShadowTarget() if fingerprint == "direct-a" else None
|
||||
|
||||
def build_shadow_event(
|
||||
target,
|
||||
*,
|
||||
observed_at,
|
||||
text,
|
||||
body_confidence,
|
||||
visual_signature,
|
||||
bot_mention,
|
||||
observed_sender,
|
||||
sender_confidence,
|
||||
ordinal,
|
||||
):
|
||||
del (
|
||||
target,
|
||||
body_confidence,
|
||||
visual_signature,
|
||||
bot_mention,
|
||||
observed_sender,
|
||||
sender_confidence,
|
||||
ordinal,
|
||||
)
|
||||
return {
|
||||
"event_id": "shadow-event",
|
||||
"observed_at": observed_at,
|
||||
"sender": "Test User",
|
||||
"text": text,
|
||||
"mention_bot": True,
|
||||
"group_fingerprint": "direct-a",
|
||||
"shadow_only": True,
|
||||
"dispatch_allowed": False,
|
||||
}
|
||||
|
||||
engine = mock.Mock()
|
||||
first = MODULE.IncomingMessage(
|
||||
"unknown",
|
||||
"/状态",
|
||||
0.0,
|
||||
0.0,
|
||||
0.98,
|
||||
100,
|
||||
"direct-first",
|
||||
)
|
||||
second = MODULE.IncomingMessage(
|
||||
"unknown",
|
||||
"/版本",
|
||||
0.0,
|
||||
0.0,
|
||||
0.98,
|
||||
160,
|
||||
"direct-second",
|
||||
)
|
||||
engine.recognize_messages.side_effect = [
|
||||
([first], 0.01),
|
||||
([first, second], 0.01),
|
||||
]
|
||||
engine.last_cache_hits = 0
|
||||
engine.last_cache_misses = 1
|
||||
engine.last_cache_refresh = False
|
||||
history = []
|
||||
image = MODULE.Image.new(
|
||||
"RGB",
|
||||
(MODULE.EXPECTED_WIDTH, MODULE.EXPECTED_HEIGHT),
|
||||
"white",
|
||||
)
|
||||
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"ensure_directories",
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"OcrEngine",
|
||||
return_value=engine,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"read_json",
|
||||
return_value={},
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"load_conversation_shadow_support",
|
||||
return_value=(ShadowRegistry(), build_shadow_event),
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"incoming_mask_and_bubbles",
|
||||
return_value=(None, []),
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"write_history_event",
|
||||
side_effect=history.append,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"queue_event",
|
||||
) as queue_event:
|
||||
recorder = MODULE.Recorder(queue_enabled=True)
|
||||
recorder.save_state = mock.Mock()
|
||||
recorder.process_stable_image(
|
||||
image,
|
||||
"layout-direct-1",
|
||||
visual_hash="visual-direct-1",
|
||||
observed_at="2026-10-20T12:00:00+08:00",
|
||||
group_fingerprint="direct-a",
|
||||
)
|
||||
recorder.process_stable_image(
|
||||
image,
|
||||
"layout-direct-2",
|
||||
visual_hash="visual-direct-2",
|
||||
observed_at="2026-10-20T12:00:01+08:00",
|
||||
group_fingerprint="direct-a",
|
||||
)
|
||||
|
||||
queue_event.assert_not_called()
|
||||
self.assertEqual(len(history), 1)
|
||||
self.assertEqual(history[0]["text"], "/版本")
|
||||
self.assertTrue(history[0]["shadow_only"])
|
||||
self.assertFalse(history[0]["dispatch_allowed"])
|
||||
self.assertEqual(recorder.state["shadowed_total"], 1)
|
||||
|
||||
|
||||
class StorageTests(unittest.TestCase):
|
||||
def test_elapsed_since_iso_is_timezone_aware_and_bounded(self):
|
||||
now = datetime(2026, 10, 14, 12, 0, 1, tzinfo=timezone.utc)
|
||||
|
||||
Reference in New Issue
Block a user