Files
JWeBot/tests/test_wechat_local_ocr.py
T
JWeBot Contributors 22edf6ff15
check / local-gate (push) Failing after 42s
Cache OCR-negative bubbles
2026-10-16 11:38:03 +08:00

1331 lines
46 KiB
Python
Executable File

#!/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 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()