#!/usr/bin/env python3 import importlib.util import subprocess import sys import tempfile import types import unittest from contextlib import nullcontext from datetime import datetime, timezone from pathlib import Path from unittest import mock PROJECT_ROOT = Path(__file__).resolve().parents[1] MODULE_PATH = PROJECT_ROOT / "wechat_local_ocr.py" SPEC = importlib.util.spec_from_file_location("wechat_local_ocr", MODULE_PATH) MODULE = importlib.util.module_from_spec(SPEC) assert SPEC.loader is not None sys.modules[SPEC.name] = MODULE SPEC.loader.exec_module(MODULE) class TelemetryTests(unittest.TestCase): def test_telemetry_hashes_identifiers_and_excludes_unknown_fields(self): with self.assertLogs(level="INFO") as captured: MODULE.telemetry_event( "ocr.scan", event_id="private-event", group_id="private-group", elapsed_ms=12.5, count=2, message_body="must-not-appear", ) output = "\n".join(captured.output) self.assertIn('"name":"ocr.scan"', output) self.assertNotIn("private-event", output) self.assertNotIn("private-group", output) self.assertNotIn("message_body", output) self.assertNotIn("must-not-appear", output) class LayoutTests(unittest.TestCase): def test_sender_and_body_are_paired(self): lines = [ MODULE.OcrLine("Alice", 0.99, 62, 150, 105, 173), MODULE.OcrLine("@JWeBot test", 0.98, 77, 179, 164, 201), MODULE.OcrLine("03:19", 0.99, 399, 229, 441, 246), MODULE.OcrLine("self reply", 0.99, 609, 275, 751, 296), ] bubbles = [MODULE.Bubble(64, 172, 107, 34, 3461)] messages = MODULE.messages_from_layout(lines, bubbles) self.assertEqual(len(messages), 1) self.assertEqual(messages[0].sender, "Alice") self.assertEqual(messages[0].text, "@JWeBot test") self.assertEqual(MODULE.trigger_question(messages[0].text), "test") def test_trigger_allows_ocr_to_remove_mention_space(self): self.assertEqual( MODULE.trigger_question("@JWeBot现在几点了"), "现在几点了", ) def test_unknown_sender_is_preserved_but_low_confidence(self): lines = [MODULE.OcrLine("hello", 0.99, 77, 179, 164, 201)] bubbles = [MODULE.Bubble(64, 172, 107, 34, 3461)] messages = MODULE.messages_from_layout(lines, bubbles) self.assertEqual(messages[0].sender, "unknown") self.assertEqual(messages[0].confidence, 0.0) def test_single_line_wide_incoming_bubble_is_eligible(self): bubble = MODULE.Bubble(66, 242, 573, 37, 20939) self.assertTrue(MODULE.is_incoming_bubble(bubble)) def test_oversized_or_right_aligned_bubbles_remain_ineligible(self): self.assertFalse( MODULE.is_incoming_bubble( MODULE.Bubble(66, 242, MODULE.INCOMING_MAX_WIDTH + 1, 37, 30000) ) ) self.assertFalse( MODULE.is_incoming_bubble( MODULE.Bubble(500, 242, 573, 37, 20939) ) ) class MonitorTests(unittest.TestCase): def test_message_damage_intersection(self): self.assertTrue( MODULE.damage_intersects_message_box(371, 549, 96, 96) ) def test_input_cursor_damage_is_ignored(self): self.assertFalse( MODULE.damage_intersects_message_box(321, 742, 8, 22) ) def test_target_chat_unread_damage_is_monitored(self): self.assertTrue( MODULE.damage_intersects_message_box(95, 88, 24, 24) ) def test_zero_area_damage_is_ignored(self): self.assertFalse( MODULE.damage_intersects_message_box(400, 200, 0, 10) ) class CaptureBufferTests(unittest.TestCase): def frame(self, sequence, visual_hash=None): return MODULE.CapturedFrame( image=MODULE.Image.new("RGB", (2, 2), "white"), layout_hash=f"layout-{sequence}", visual_hash=visual_hash or f"visual-{sequence}", observed_at=f"2026-10-10T00:00:{sequence:02d}+08:00", captured_monotonic=float(sequence), sequence=sequence, latest_synced=True, ) def test_frames_remain_fifo_when_capacity_is_available(self): buffer = MODULE.CaptureFrameBuffer(3) for sequence in (1, 2, 3): self.assertEqual(buffer.offer(self.frame(sequence)), "enqueued") self.assertEqual([buffer.get().sequence for _ in range(3)], [1, 2, 3]) def test_overflow_discards_backlog_and_keeps_latest_frame(self): buffer = MODULE.CaptureFrameBuffer(3) for sequence in (1, 2, 3): buffer.offer(self.frame(sequence)) self.assertEqual(buffer.offer(self.frame(4)), "reset_to_latest") self.assertEqual(buffer.get().sequence, 4) self.assertEqual(buffer.stats()["coalesced"], 1) self.assertEqual(buffer.stats()["stale_discarded"], 3) self.assertEqual(buffer.stats()["max_depth"], 3) def test_get_latest_discards_queued_stale_frames(self): buffer = MODULE.CaptureFrameBuffer(3) for sequence in (1, 2, 3): buffer.offer(self.frame(sequence)) self.assertEqual(buffer.get_latest().sequence, 3) self.assertEqual(buffer.stats()["stale_discarded"], 2) self.assertEqual(buffer.stats()["depth"], 0) def test_adjacent_duplicate_visual_frame_is_not_queued(self): buffer = MODULE.CaptureFrameBuffer(2) self.assertEqual(buffer.offer(self.frame(1, "same")), "enqueued") self.assertEqual(buffer.offer(self.frame(2, "same")), "duplicate") self.assertEqual(buffer.get().sequence, 1) self.assertEqual(buffer.stats()["duplicates"], 1) def test_graceful_close_drains_frames_then_stops(self): buffer = MODULE.CaptureFrameBuffer(2) buffer.offer(self.frame(1)) buffer.close() self.assertEqual(buffer.get().sequence, 1) with self.assertRaises(MODULE.CaptureBufferClosed): buffer.get() def test_producer_error_is_propagated_without_draining_stale_frames(self): buffer = MODULE.CaptureFrameBuffer(2) buffer.offer(self.frame(1)) cause = RuntimeError("capture failed") buffer.close(cause) with self.assertRaises(MODULE.CaptureProducerError) as raised: buffer.get() self.assertIs(raised.exception.__cause__, cause) def test_capture_producer_reports_monitor_failure(self): class BrokenMonitor: window_id = 1 def __enter__(self): return self def __exit__(self, *_): return None def wait_for_message_change(self): raise RuntimeError("damage stream failed") recorder = MODULE.Recorder.__new__(MODULE.Recorder) buffer = MODULE.CaptureFrameBuffer(2) with mock.patch.object(MODULE, "XDamageMonitor", return_value=BrokenMonitor()): recorder.capture_damage_frames(buffer) with self.assertRaises(MODULE.CaptureProducerError) as raised: buffer.get() self.assertIsInstance(raised.exception.__cause__, RuntimeError) def test_capture_producer_reports_follow_latest_steps(self): class OneFrameMonitor: window_id = 42 def __enter__(self): return self def __exit__(self, *_): return None def wait_for_message_change(self): return True class OneFrameBuffer: capacity = 1 closed = False def offer(self, _frame): self.closed = True return "closed" def stats(self): return { "depth": 0, "coalesced": 0, "stale_discarded": 0, "duplicates": 0, } def close(self, _error=None): self.closed = True recorder = MODULE.Recorder.__new__(MODULE.Recorder) first = MODULE.Image.new( "RGB", (MODULE.EXPECTED_WIDTH, MODULE.EXPECTED_HEIGHT), "white", ) second = first.copy() with mock.patch.object( MODULE, "XDamageMonitor", return_value=OneFrameMonitor(), ), mock.patch.object( MODULE, "window_id", return_value="42", ), mock.patch.object( MODULE, "capture_image", side_effect=[first, second], ), mock.patch.object( MODULE, "message_view_at_loaded_bottom", side_effect=[False, True], ), mock.patch.object( MODULE, "follow_latest_messages", return_value=True, ), mock.patch.object( MODULE, "incoming_frame_hashes", return_value=("layout", "visual"), ), mock.patch.object( MODULE, "telemetry_event", ) as telemetry: recorder.capture_damage_frames(OneFrameBuffer()) capture_steps = [ call for call in telemetry.call_args_list if call.args == ("ocr.capture.step",) ] self.assertEqual( [call.kwargs["phase"] for call in capture_steps], ["initial_capture", "follow_latest", "recapture"], ) self.assertEqual(capture_steps[1].kwargs["outcome"], "followed") def test_visual_hash_changes_when_text_pixels_change(self): bubble = MODULE.Bubble(50, 50, 80, 30, 2000) mask = MODULE.Image.new("L", (160, 120), 0) first = MODULE.Image.new("RGB", (160, 120), "white") second = first.copy() second.putpixel((70, 65), (0, 0, 0)) with mock.patch.object( MODULE, "incoming_mask_and_bubbles", return_value=(mask, [bubble]), ): first_layout, first_visual = MODULE.incoming_frame_hashes(first) second_layout, second_visual = MODULE.incoming_frame_hashes(second) self.assertEqual(first_layout, second_layout) self.assertNotEqual(first_visual, second_visual) class GuiCoordinationTests(unittest.TestCase): def scrollbar_image(self, start, end): image = MODULE.Image.new( "RGB", (MODULE.EXPECTED_WIDTH, MODULE.EXPECTED_HEIGHT), "white", ) for y in range(start, end + 1): image.putpixel( (MODULE.FOLLOW_LATEST_MOUSE_X, y), MODULE.SCROLLBAR_THUMB_COLOR, ) return image def test_main_window_is_selected_by_exact_title(self): search = subprocess.CompletedProcess([], 0, "11\n22\n", "") child = subprocess.CompletedProcess([], 0, "Search chat history\n", "") main = subprocess.CompletedProcess([], 0, "Weixin\n", "") with mock.patch.object( MODULE, "as_bridge_user", side_effect=[search, child, main], ): self.assertEqual(MODULE.window_id(), "22") def test_geometry_drift_is_restored_by_maximizing(self): completed = subprocess.CompletedProcess([], 0, "", "") with mock.patch.object( MODULE, "window_geometry", side_effect=[ (900, 700), (MODULE.EXPECTED_WIDTH, MODULE.EXPECTED_HEIGHT), ], ), mock.patch.object( MODULE, "as_bridge_user", return_value=completed, ) as bridge, mock.patch.object( MODULE.time, "sleep", ): geometry = MODULE.ensure_maximized_window("42") self.assertEqual( geometry, (MODULE.EXPECTED_WIDTH, MODULE.EXPECTED_HEIGHT), ) command = bridge.call_args.args[0] self.assertIn("wmctrl", command[0]) self.assertIn("add,maximized_vert,maximized_horz", command) def test_expected_geometry_does_not_touch_window_state(self): with mock.patch.object( MODULE, "window_geometry", return_value=(MODULE.EXPECTED_WIDTH, MODULE.EXPECTED_HEIGHT), ), mock.patch.object( MODULE, "as_bridge_user", ) as bridge: geometry = MODULE.ensure_maximized_window("42") self.assertEqual( geometry, (MODULE.EXPECTED_WIDTH, MODULE.EXPECTED_HEIGHT), ) bridge.assert_not_called() def test_capture_reuses_a_verified_window_identifier(self): payload = MODULE.io.BytesIO() MODULE.Image.new( "RGB", (MODULE.EXPECTED_WIDTH, MODULE.EXPECTED_HEIGHT), "white", ).save(payload, format="PNG") completed = subprocess.CompletedProcess( [], 0, payload.getvalue(), b"", ) with mock.patch.object( MODULE, "ensure_directories", ), mock.patch.object( MODULE, "window_id", ) as find_window, mock.patch.object( MODULE, "ensure_maximized_window", return_value=(MODULE.EXPECTED_WIDTH, MODULE.EXPECTED_HEIGHT), ) as maximize, mock.patch.object( MODULE, "as_bridge_user_bytes", return_value=completed, ) as bridge: image = MODULE.capture_image("42") self.assertEqual( image.size, (MODULE.EXPECTED_WIDTH, MODULE.EXPECTED_HEIGHT), ) find_window.assert_not_called() maximize.assert_called_once_with("42") command = bridge.call_args.args[0] self.assertEqual(command[command.index("--window") + 1], "42") def test_window_bounds_preserve_negative_window_position(self): completed = subprocess.CompletedProcess( [], 0, "WINDOW=42\nX=-12\nY=27\nWIDTH=1440\nHEIGHT=873\nSCREEN=0\n", "", ) with mock.patch.object( MODULE, "as_bridge_user", return_value=completed, ): self.assertEqual( MODULE.window_bounds("42"), (-12, 27, 1440, 873), ) def test_title_fingerprint_uses_direct_region_capture(self): payload = MODULE.io.BytesIO() MODULE.Image.new("RGB", (600, 47), (20, 30, 40)).save( payload, format="PNG", ) completed = subprocess.CompletedProcess( [], 0, payload.getvalue(), b"", ) with mock.patch.object( MODULE, "window_id", return_value="42", ), mock.patch.object( MODULE, "window_bounds", return_value=(0, 27, 1440, 873), ), mock.patch.object( MODULE, "as_bridge_user_bytes", return_value=completed, ) as bridge: signature = MODULE.capture_title_fingerprint() self.assertEqual(len(signature), 64) command = bridge.call_args.args[0] self.assertIn("--autoselect", command) self.assertIn("300,60,600,47", command) self.assertNotIn("--window", command) def test_bottom_scrollbar_thumb_is_detected(self): image = self.scrollbar_image( MODULE.MESSAGE_BOX[3] - 40, MODULE.MESSAGE_BOX[3] - 4, ) self.assertTrue(MODULE.message_view_at_loaded_bottom(image)) def test_historical_scrollbar_thumb_is_not_at_bottom(self): image = self.scrollbar_image(170, 317) self.assertFalse(MODULE.message_view_at_loaded_bottom(image)) def test_hidden_scrollbar_is_not_assumed_to_be_at_bottom(self): image = MODULE.Image.new( "RGB", (MODULE.EXPECTED_WIDTH, MODULE.EXPECTED_HEIGHT), "white", ) self.assertFalse(MODULE.message_view_at_loaded_bottom(image)) def test_follow_latest_scrolls_message_pane_when_lock_is_available(self): completed = subprocess.CompletedProcess([], 0, "", "") with mock.patch.object( MODULE, "gui_interaction_lock", return_value=nullcontext(True), ), mock.patch.object( MODULE, "as_bridge_user", return_value=completed, ) as bridge, mock.patch.object( MODULE.time, "sleep", ): self.assertTrue(MODULE.follow_latest_messages("42")) command = bridge.call_args.args[0] self.assertIn("windowactivate", command) self.assertIn("mousemove", command) self.assertIn(str(MODULE.FOLLOW_LATEST_MOUSE_X), command) self.assertIn(str(MODULE.FOLLOW_LATEST_MOUSE_Y), command) self.assertIn("click", command) self.assertIn(str(MODULE.FOLLOW_LATEST_SCROLL_STEPS), command) delay_index = command.index("--delay") self.assertEqual( command[delay_index + 1], str(MODULE.FOLLOW_LATEST_SCROLL_DELAY_MS), ) self.assertEqual(MODULE.FOLLOW_LATEST_SCROLL_DELAY_MS, 1) self.assertEqual(command[-1], "5") self.assertNotIn("ctrl+End", command) def test_follow_latest_skips_when_responder_holds_lock(self): with mock.patch.object( MODULE, "gui_interaction_lock", return_value=nullcontext(False), ), mock.patch.object( MODULE, "as_bridge_user", ) as bridge: self.assertFalse(MODULE.follow_latest_messages("42")) bridge.assert_not_called() class OcrFallbackTests(unittest.TestCase): def test_ocr_engine_limits_onnxruntime_threads_by_default(self): captured = {} class FakeRapidOCR: def __init__(self, **kwargs): captured.update(kwargs) fake_module = types.SimpleNamespace(RapidOCR=FakeRapidOCR) fake_numpy = types.SimpleNamespace(array=lambda value: value) with mock.patch.dict( sys.modules, {"rapidocr": fake_module, "numpy": fake_numpy}, ), mock.patch.dict( MODULE.os.environ, { "WECHAT_OCR_ONNX_INTRA_OP_THREADS": "", "WECHAT_OCR_ONNX_INTER_OP_THREADS": "", }, ): MODULE.OcrEngine() self.assertEqual( captured["params"], { "EngineConfig.onnxruntime.intra_op_num_threads": 1, "EngineConfig.onnxruntime.inter_op_num_threads": 1, }, ) def test_ocr_engine_allows_thread_limit_overrides(self): captured = {} class FakeRapidOCR: def __init__(self, **kwargs): captured.update(kwargs) fake_module = types.SimpleNamespace(RapidOCR=FakeRapidOCR) fake_numpy = types.SimpleNamespace(array=lambda value: value) with mock.patch.dict( sys.modules, {"rapidocr": fake_module, "numpy": fake_numpy}, ), mock.patch.dict( MODULE.os.environ, { "WECHAT_OCR_ONNX_INTRA_OP_THREADS": "2", "WECHAT_OCR_ONNX_INTER_OP_THREADS": "3", }, ): MODULE.OcrEngine() self.assertEqual( captured["params"], { "EngineConfig.onnxruntime.intra_op_num_threads": 2, "EngineConfig.onnxruntime.inter_op_num_threads": 3, }, ) def test_low_confidence_line_uses_detection_fallback(self): engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine) bubble = MODULE.Bubble(50, 50, 132, 68, 5000) image = MODULE.Image.new("RGB", (300, 180), "white") with mock.patch.object( engine, "recognize_line", side_effect=[("Alice", 0.95), ("broken", 0.36)], ), mock.patch.object( engine, "recognize_block", return_value=[("@JWeBot recovered", 0.98)], ) as fallback, mock.patch.object( MODULE, "text_line_regions", return_value=[(0, 20)], ), mock.patch.object( MODULE, "run", return_value=subprocess.CompletedProcess([], 0, "", ""), ): messages, _ = engine.recognize_messages(image, [bubble]) fallback.assert_called_once() self.assertEqual(len(messages), 1) self.assertEqual(messages[0].text, "@JWeBot recovered") self.assertEqual(messages[0].body_confidence, 0.98) self.assertEqual(len(messages[0].visual_signature), 64) class BubbleSequenceSignatureTests(unittest.TestCase): def test_signature_ignores_small_color_and_antialias_drift(self): first_sender = MODULE.Image.new("RGB", (12, 6), (238, 238, 240)) first_body = MODULE.Image.new("RGB", (20, 10), (238, 238, 240)) second_sender = MODULE.Image.new("RGB", (12, 6), (242, 242, 244)) second_body = MODULE.Image.new("RGB", (20, 10), (242, 242, 244)) first_sender.putpixel((2, 2), (20, 20, 20)) first_body.putpixel((4, 4), (30, 30, 30)) second_sender.putpixel((2, 2), (35, 35, 35)) second_body.putpixel((4, 4), (45, 45, 45)) first = MODULE.bubble_sequence_signature(first_sender, first_body) second = MODULE.bubble_sequence_signature(second_sender, second_body) self.assertEqual(first, second) self.assertNotEqual( MODULE.OcrEngine._cache_key(first_sender, first_body), MODULE.OcrEngine._cache_key(second_sender, second_body), ) def test_signature_changes_when_text_structure_changes(self): sender = MODULE.Image.new("RGB", (12, 6), "white") first_body = MODULE.Image.new("RGB", (20, 10), "white") second_body = first_body.copy() first_body.putpixel((4, 4), (0, 0, 0)) second_body.putpixel((5, 4), (0, 0, 0)) self.assertNotEqual( MODULE.bubble_sequence_signature(sender, first_body), MODULE.bubble_sequence_signature(sender, second_body), ) class OcrCacheTests(unittest.TestCase): @staticmethod def message(y): return MODULE.IncomingMessage( "Alice", "@JWeBot cached", 0.98, 0.99, 0.98, y, ) def test_unchanged_bubble_reuses_ocr_without_dropping_occurrences(self): engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine) image = MODULE.Image.new("RGB", (300, 240), "white") bubbles = [ MODULE.Bubble(50, 60, 132, 40, 5000), MODULE.Bubble(50, 120, 132, 40, 5000), ] with mock.patch.object( engine, "_recognize_bubble", return_value=self.message(60), ) as recognize: messages, _ = engine.recognize_messages(image, bubbles) self.assertEqual(recognize.call_count, 1) self.assertEqual([message.y for message in messages], [60, 120]) self.assertEqual(engine.last_cache_hits, 1) self.assertEqual(engine.last_cache_misses, 1) def test_changed_bubble_pixels_force_fresh_ocr(self): engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine) bubble = MODULE.Bubble(50, 60, 132, 40, 5000) first = MODULE.Image.new("RGB", (300, 180), "white") second = first.copy() second.putpixel((70, 75), (0, 0, 0)) with mock.patch.object( engine, "_recognize_bubble", return_value=self.message(60), ) as recognize: engine.recognize_messages(first, [bubble]) engine.recognize_messages(second, [bubble]) self.assertEqual(recognize.call_count, 2) self.assertEqual(engine.last_cache_hits, 0) self.assertEqual(engine.last_cache_misses, 1) def test_antialias_drift_reuses_visual_signature_cache(self): engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine) bubble = MODULE.Bubble(50, 60, 132, 40, 5000) first = MODULE.Image.new("RGB", (300, 180), (238, 238, 240)) second = MODULE.Image.new("RGB", (300, 180), (242, 242, 244)) first.putpixel((70, 75), (20, 20, 20)) second.putpixel((70, 75), (35, 35, 35)) with mock.patch.object( engine, "_recognize_bubble", return_value=self.message(60), ) as recognize: engine.recognize_messages(first, [bubble]) messages, _ = engine.recognize_messages(second, [bubble]) self.assertEqual(recognize.call_count, 1) self.assertEqual(len(messages), 1) self.assertEqual(engine.last_cache_hits, 1) self.assertEqual(engine.last_cache_misses, 0) def test_unrecognized_bubble_reuses_negative_cache(self): engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine) image = MODULE.Image.new("RGB", (300, 180), "white") bubble = MODULE.Bubble(50, 60, 132, 40, 5000) with mock.patch.object( engine, "_recognize_bubble", return_value=None, ) as recognize: first_messages, _ = engine.recognize_messages(image, [bubble]) second_messages, _ = engine.recognize_messages(image, [bubble]) self.assertEqual(first_messages, []) self.assertEqual(second_messages, []) self.assertEqual(recognize.call_count, 1) self.assertEqual(engine.last_cache_hits, 1) self.assertEqual(engine.last_cache_misses, 0) def test_periodic_refresh_revalidates_cached_bubbles(self): engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine) image = MODULE.Image.new("RGB", (300, 180), "white") bubble = MODULE.Bubble(50, 60, 132, 40, 5000) with mock.patch.object( engine, "_recognize_bubble", return_value=self.message(60), ) as recognize: engine.recognize_messages(image, [bubble]) engine._scan_count = MODULE.OCR_CACHE_REFRESH_SCANS - 1 engine.recognize_messages(image, [bubble]) self.assertEqual(recognize.call_count, 2) self.assertTrue(engine.last_cache_refresh) def test_warmup_initializes_recognition_and_detection_paths(self): engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine) with mock.patch.object( engine, "recognize_line", return_value=("", 0.0), ) as line, mock.patch.object( engine, "recognize_block", return_value=[], ) as block: engine.warmup() line.assert_called_once() block.assert_called_once() class SequenceTests(unittest.TestCase): def message(self, sender, text, y, visual_signature=""): return MODULE.IncomingMessage( sender, text, 0.99, 0.99, 0.99, y, visual_signature, ) def test_sliding_overlap_returns_only_new_suffix(self): first = self.message("A", "one", 10) second = self.message("B", "two", 20) third = self.message("A", "three", 30) new, current, overlap, resynced = MODULE.new_sequence_suffix( [first.signature, second.signature], [second, third], ) self.assertEqual(new, [third]) self.assertEqual(current, [second.signature, third.signature]) self.assertEqual(overlap, 1) self.assertFalse(resynced) def test_identical_repeated_message_is_a_new_occurrence(self): repeated = self.message("A", "same", 10) new, _, overlap, resynced = MODULE.new_sequence_suffix( [repeated.signature], [repeated, repeated], ) self.assertEqual(new, [repeated]) self.assertEqual(overlap, 1) self.assertFalse(resynced) def test_signature_ignores_ocr_case_drift(self): upper = self.message("USERA", "Hello JWEBOT", 10) mixed = self.message("uSeRa", "hello jwebot", 10) self.assertEqual(upper.signature, mixed.signature) def test_visual_signature_survives_ocr_text_drift(self): first = self.message("Alice", "broken OCR", 10, "visual-a") corrected = self.message( "Alice", "@JWeBot /steam", 10, "visual-a", ) self.assertEqual(first.signature, corrected.signature) def test_identical_text_with_distinct_visual_occurrences_stays_distinct(self): first = self.message("Alice", "@JWeBot /steam", 10, "visual-a") second = self.message("Alice", "@JWeBot /steam", 20, "visual-b") self.assertNotEqual(first.signature, second.signature) def test_ordered_fallback_keeps_new_suffix_after_ocr_omission(self): first = self.message("A", "one", 10) omitted = self.message("B", "two", 20) third = self.message("C", "three", 30) extra_top = self.message("X", "older", 5) latest = self.message("D", "@JWeBot latest", 40) new, _, overlap, resynced = MODULE.new_sequence_suffix( [first.signature, omitted.signature, third.signature], [extra_top, first, third, latest], ) self.assertEqual(new, [latest]) self.assertEqual(overlap, 2) self.assertTrue(resynced) def test_no_overlap_fails_closed(self): old = self.message("A", "old", 10) new_message = self.message("B", "new", 20) new, _, overlap, resynced = MODULE.new_sequence_suffix( [old.signature], [new_message], ) self.assertEqual(new, []) self.assertEqual(overlap, 0) self.assertTrue(resynced) def test_no_overlap_recovers_one_unseen_trigger_at_the_bottom(self): old = self.message("A", "old", 10, "visual-old") trigger = self.message( "B", "@JWeBot test", 20, "visual-new-trigger", ) new, _, overlap, resynced = MODULE.new_sequence_suffix( [old.signature], [trigger], ) self.assertEqual(new, [trigger]) self.assertEqual(overlap, 0) self.assertTrue(resynced) def test_no_overlap_does_not_replay_a_recent_trigger(self): old = self.message("A", "old", 10, "visual-old") trigger = self.message( "B", "@JWeBot test", 20, "visual-seen-trigger", ) new, _, overlap, resynced = MODULE.new_sequence_suffix( [old.signature], [trigger], recent_trigger_signatures=[trigger.signature], ) self.assertEqual(new, []) self.assertEqual(overlap, 0) self.assertTrue(resynced) def test_no_overlap_recovery_is_disabled_for_startup_baseline(self): old = self.message("A", "old", 10, "visual-old") trigger = self.message( "B", "@JWeBot test", 20, "visual-new-trigger", ) new, _, overlap, resynced = MODULE.new_sequence_suffix( [old.signature], [trigger], recover_unseen_trigger=False, ) self.assertEqual(new, []) self.assertEqual(overlap, 0) self.assertTrue(resynced) class RecorderSignatureMigrationTests(unittest.TestCase): def test_legacy_text_signatures_reset_to_a_safe_visual_baseline(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_visual_hash": "old-frame", "visible_signatures": ["old-text-signature"], "recent_trigger_signatures": ["old-trigger-signature"], }, ): recorder = MODULE.Recorder(queue_enabled=True) self.assertFalse(recorder.state["initialized"]) self.assertEqual(recorder.state["last_visual_hash"], "") self.assertEqual(recorder.state["visible_signatures"], []) self.assertEqual(recorder.state["recent_trigger_signatures"], []) self.assertFalse(recorder.no_overlap_recovery_ready) 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) self.assertEqual( MODULE.elapsed_since_iso_ms( "2026-10-14T12:00:00+00:00", now=now, ), 1000.0, ) self.assertEqual( MODULE.elapsed_since_iso_ms( "2026-10-14T12:00:02+00:00", now=now, ), 0.0, ) self.assertIsNone(MODULE.elapsed_since_iso_ms("not-a-timestamp")) def test_jsonl_contains_text_only_event_fields(self): message = MODULE.IncomingMessage( "Alice", "@JWeBot hello", 0.98, 0.99, 0.98, 10, ) event = MODULE.history_event( message, observed_at="2026-10-10T03:23:38+08:00", group_fingerprint="group", ordinal=0, ) self.assertEqual(event["sender"], "Alice") self.assertEqual(event["text"], "@JWeBot hello") self.assertTrue(event["mention_bot"]) self.assertNotIn("image", event) self.assertNotIn("screenshot", event) def test_atomic_json_roundtrip(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "state.json" MODULE.write_json(path, {"ok": True}) self.assertEqual(MODULE.read_json(path), {"ok": True}) def test_queued_event_records_its_queue_entry_time(self): with tempfile.TemporaryDirectory() as directory: queue = Path(directory) incoming = { "event_id": "event-1", "observed_at": "2026-10-14T11:59:59+00:00", } with mock.patch.object( MODULE, "QUEUE_DIR", queue, ), mock.patch.object( MODULE, "now_iso", return_value="2026-10-14T12:00:00+00:00", ): MODULE.QUEUE_WAKE_EVENT.clear() MODULE.queue_event(incoming) stored = MODULE.read_json(queue / "event-1.json") self.assertEqual( stored["queued_at"], "2026-10-14T12:00:00+00:00", ) self.assertTrue(MODULE.QUEUE_WAKE_EVENT.is_set()) MODULE.QUEUE_WAKE_EVENT.clear() class RoutingTests(unittest.TestCase): def event(self): return { "event_id": "event-route", "sender": "Alice", "text": "@JWeBot test", "group_fingerprint": "fixed-group", } def test_router_decision_accepts_direct_reply(self): decision = MODULE.router_decision( '{"route":"reply","text":"收到,测试正常。"}' ) self.assertEqual( decision, {"route": "reply", "text": "收到,测试正常喵"}, ) def test_router_decision_removes_english_full_stop(self): decision = MODULE.router_decision( '{"route":"reply","text":"你急了嘎嘎嘎."}' ) self.assertEqual( decision, {"route": "reply", "text": "你急了嘎嘎嘎喵"}, ) def test_router_decision_does_not_duplicate_meow_suffix(self): decision = MODULE.router_decision( '{"route":"reply","text":"收到喵~"}' ) self.assertEqual( decision, {"route": "reply", "text": "收到喵"}, ) def test_router_decision_rejects_main_route(self): with self.assertRaises(ValueError): MODULE.router_decision( '```json\n{"route":"main","task":"use browser"}\n```' ) def test_direct_reply_does_not_start_main_agent(self): with mock.patch.object( MODULE, "invoke_openclaw_agent", return_value='{"route":"reply","text":"fast reply"}', ) as invoke: reply = MODULE.ask_openclaw(self.event()) self.assertEqual(reply, "fast reply喵") self.assertEqual(invoke.call_count, 1) self.assertEqual(invoke.call_args.kwargs["agent"], MODULE.ROUTER_AGENT) def test_tool_escalation_fails_closed_without_main_agent(self): with mock.patch.object( MODULE, "invoke_openclaw_agent", return_value='{"route":"main","task":"use Computer Use"}', ) as invoke: reply = MODULE.ask_openclaw(self.event()) self.assertEqual(reply, "JWeBot Error: Invalid router reply") self.assertEqual(invoke.call_count, 1) self.assertEqual(invoke.call_args.kwargs["agent"], MODULE.ROUTER_AGENT) def test_router_invocation_failure_returns_visible_error(self): with mock.patch.object( MODULE, "invoke_openclaw_agent", side_effect=RuntimeError("router unavailable"), ) as invoke: reply = MODULE.ask_openclaw(self.event()) self.assertEqual(reply, "JWeBot Error: Router unavailable") self.assertEqual(invoke.call_count, 1) self.assertEqual(invoke.call_args.kwargs["agent"], MODULE.ROUTER_AGENT) def test_router_error_message_classifies_gateway_failure(self): result = subprocess.CompletedProcess( [], 255, "", "ssh: connect to host: Host is down", ) self.assertEqual( MODULE.router_error_message(result=result), "JWeBot Error: Gateway unreachable", ) def test_router_error_message_classifies_timeout(self): self.assertEqual( MODULE.router_error_message( exc=RuntimeError("operation timed out") ), "JWeBot Error: Router timeout", ) def test_router_can_be_disabled_without_fallback(self): with mock.patch.dict( MODULE.os.environ, {"WECHAT_ROUTER_ENABLED": "0"}, ), mock.patch.object( MODULE, "invoke_openclaw_agent", ) as invoke: reply = MODULE.ask_openclaw(self.event()) self.assertEqual(reply, "NO_REPLY") invoke.assert_not_called() class ResponderTests(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() root = Path(self.temporary.name) self.paths = { "STATE_ROOT": root, "HISTORY_DIR": root / "history", "QUEUE_DIR": root / "queue", "FAILED_DIR": root / "failed", "RECORDER_STATE": root / "recorder-state.json", "RESPONDER_STATE": root / "responder-state.json", "GUI_LOCK_FILE": root / "gui-interaction.lock", } self.original_paths = { name: getattr(MODULE, name) for name in self.paths } for name, value in self.paths.items(): setattr(MODULE, name, value) MODULE.ensure_directories() def tearDown(self): for name, value in self.original_paths.items(): setattr(MODULE, name, value) self.temporary.cleanup() def event(self): return { "event_id": "event-1", "side": "incoming", "mention_bot": True, "sender": "Alice", "sender_confidence": 0.99, "text": "@JWeBot hello", "group_fingerprint": "fixed-group", "attempts": 0, "next_attempt_at": 0, } def queue(self, event=None): path = self.paths["QUEUE_DIR"] / "event-1.json" MODULE.write_json(path, event or self.event()) return path def test_successful_delivery_is_completed_once(self): path = self.queue() send_calls = [] def fake_send(reply, expected_group, *, before_send, after_send, event_id): self.assertEqual(reply, "reply") self.assertEqual(expected_group, "fixed-group") self.assertEqual(event_id, "event-1") before_send() send_calls.append(reply) after_send() with mock.patch.object(MODULE, "route_event", return_value="reply"), mock.patch.object( MODULE, "send_reply", side_effect=fake_send ): responder = MODULE.Responder() responder.process(path) self.assertEqual(send_calls, ["reply"]) self.assertEqual(list(self.paths["QUEUE_DIR"].iterdir()), []) self.assertEqual(responder.state["sent_total"], 1) self.assertEqual(responder.state["processed_total"], 1) def test_uncertain_delivery_is_not_retried(self): path = self.queue() def uncertain_send(reply, expected_group, *, before_send, after_send, event_id): self.assertEqual(event_id, "event-1") before_send() raise RuntimeError("lost send acknowledgement") with mock.patch.object(MODULE, "route_event", return_value="reply"), mock.patch.object( MODULE, "send_reply", side_effect=uncertain_send ): responder = MODULE.Responder() responder.process(path) self.assertEqual(list(self.paths["QUEUE_DIR"].iterdir()), []) failed = MODULE.read_json(self.paths["FAILED_DIR"] / "event-1.json") self.assertEqual(failed["delivery_outcome"], "uncertain") self.assertEqual(responder.state["uncertain_total"], 1) def test_restart_quarantines_send_started_event(self): event = self.event() event["delivery_state"] = "send_started" processing = self.paths["QUEUE_DIR"] / "event-1.processing" MODULE.write_json(processing, event) responder = MODULE.Responder() self.assertFalse(processing.exists()) self.assertEqual(list(self.paths["QUEUE_DIR"].iterdir()), []) self.assertTrue((self.paths["FAILED_DIR"] / "event-1.json").exists()) self.assertEqual(responder.state["recovered_uncertain_total"], 1) def test_group_is_rechecked_after_drafting(self): draft = subprocess.CompletedProcess( [], 0, ( '{"confirmation_token":"token","timings":[' '{"phase":"draft.total","elapsed_ms":1.2}]}' "\n" ), "", ) cleared = subprocess.CompletedProcess([], 0, '{"state":"cleared"}\n', "") calls = [] def fake_run(argv, **kwargs): calls.append(argv) return draft if argv[1] == "draft" else cleared with mock.patch.object(MODULE, "capture_image", side_effect=[object(), object()]), mock.patch.object( MODULE, "title_fingerprint", side_effect=["fixed-group", "other-group"] ), mock.patch.object(MODULE, "run", side_effect=fake_run): with self.assertRaisesRegex(RuntimeError, "changed after draft"): MODULE.send_reply("reply", "fixed-group") self.assertEqual([call[1] for call in calls], ["draft", "clear"]) def test_send_reply_emits_bridge_step_timings(self): draft = subprocess.CompletedProcess( [], 0, ( '{"confirmation_token":"token","timings":[' '{"phase":"draft.window","elapsed_ms":2.0,"width":1440,' '"height":873},' '{"phase":"draft.confirm_poll","elapsed_ms":55.0,' '"polls":2,"pixels":120,"threshold":80,' '"timeout_ms":800,"outcome":"confirmed"}]}' "\n" ), "", ) sent = subprocess.CompletedProcess( [], 0, ( '{"state":"sent","timings":[' '{"phase":"send.return_key","elapsed_ms":52.0,' '"method":"vnc-enter","x":441,"y":786},' '{"phase":"send.return_confirm_poll","elapsed_ms":50.0,' '"polls":1,"pixels":140,"threshold":80,' '"timeout_ms":900,"outcome":"confirmed",' '"roi":"input_text","flag":true,' '"flag_wait_ms":50.0,' '"confirmed_by":"input_roi_cleared"}]}' "\n" ), "", ) def fake_run(argv, **kwargs): return draft if argv[1] == "draft" else sent with mock.patch.object( MODULE, "capture_image", side_effect=[object(), object()], ), mock.patch.object( MODULE, "title_fingerprint", side_effect=["fixed-group", "fixed-group"], ), mock.patch.object( MODULE, "run", side_effect=fake_run, ), mock.patch.object( MODULE, "telemetry_event", ) as telemetry: MODULE.send_reply("reply", "fixed-group", event_id="event-1") names = [call.args[0] for call in telemetry.call_args_list] self.assertIn("gui.send.step", names) self.assertIn("gui.bridge.step", names) self.assertIn("gui.send.total", names) bridge_phases = [ call.kwargs["phase"] for call in telemetry.call_args_list if call.args[0] == "gui.bridge.step" ] self.assertIn("draft.confirm_poll", bridge_phases) self.assertIn("send.return_confirm_poll", bridge_phases) class CaptureProcessTests(unittest.TestCase): def test_root_capture_uses_bridge_user_owned_inner_pipe(self): completed = subprocess.CompletedProcess([], 0, b"png", b"") with mock.patch.object(MODULE.os, "geteuid", return_value=0), mock.patch.object( MODULE.subprocess, "run", return_value=completed ) as run_mock: result = MODULE.as_bridge_user_bytes(["/usr/bin/scrot", "/dev/stdout"]) command = run_mock.call_args.args[0] self.assertEqual(result.stdout, b"png") self.assertEqual( command[:4], ["/usr/sbin/runuser", "-u", MODULE.BRIDGE_USER, "--"], ) self.assertIn("/usr/bin/python3", command) self.assertEqual(command[-2:], ["/usr/bin/scrot", "/dev/stdout"]) if __name__ == "__main__": unittest.main()