1291 lines
44 KiB
Python
Executable File
1291 lines
44 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_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()
|