#!/usr/bin/env python3 import importlib.util import subprocess import sys import tempfile 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_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_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 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()