Files
JWeBot/tests/test_wechat_adaptive_recorder.py
T

696 lines
23 KiB
Python

#!/usr/bin/env python3
import importlib.util
import sys
import tempfile
import threading
import time
import unittest
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from unittest import mock
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
MODULE_PATH = PROJECT_ROOT / "wechat_adaptive_recorder.py"
SPEC = importlib.util.spec_from_file_location("wechat_adaptive_recorder", 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 FakeGuiUpstream:
def __init__(self) -> None:
self.events = []
self.bridge_payloads = []
self.current_group = "group-a"
self.title_captures = 0
self.full_captures = 0
@contextmanager
def gui_interaction_lock(self, *, blocking):
yield True
def capture_image(self):
self.full_captures += 1
return object()
def title_fingerprint(self, image):
return self.current_group
def capture_title_fingerprint(self):
self.title_captures += 1
return self.current_group
def telemetry_event(self, name, **kwargs):
self.events.append((name, kwargs))
def emit_bridge_timings(self, payload, **kwargs):
self.bridge_payloads.append((payload, kwargs))
class FakeGuiBridge:
def __init__(self) -> None:
self.calls = []
self.active = 0
self.max_active = 0
self.lock = threading.Lock()
def deliver_text(self, reply, *, before_send=None):
with self.lock:
self.active += 1
self.max_active = max(self.max_active, self.active)
try:
self.calls.append(reply)
if before_send is not None:
before_send()
time.sleep(0.01)
return {
"ok": True,
"state": "sent",
"timings": [{"phase": "deliver.total", "elapsed_ms": 1.0}],
}
finally:
with self.lock:
self.active -= 1
class BlockingGuiBridge(FakeGuiBridge):
def __init__(self, blocked_reply):
super().__init__()
self.blocked_reply = blocked_reply
self.blocked_started = threading.Event()
self.release_blocked = threading.Event()
def deliver_text(self, reply, *, before_send=None):
with self.lock:
self.active += 1
self.max_active = max(self.max_active, self.active)
try:
self.calls.append(reply)
if reply == self.blocked_reply:
self.blocked_started.set()
if not self.release_blocked.wait(2):
raise RuntimeError("blocked GUI job was not released")
if before_send is not None:
before_send()
return {
"ok": True,
"state": "sent",
"timings": [{"phase": "deliver.total", "elapsed_ms": 1.0}],
}
finally:
with self.lock:
self.active -= 1
class LayoutScalingTests(unittest.TestCase):
def run_gui_priority_scenario(self, queued_jobs):
upstream = FakeGuiUpstream()
bridge = BlockingGuiBridge("active-final")
with tempfile.TemporaryDirectory() as temporary:
sender = MODULE.GuiSender(
upstream,
bridge,
socket_path=Path(temporary) / "gui.sock",
)
sender.start()
errors = []
def submit(reply, source):
try:
sender.send_reply(
reply,
"group-a",
source=source,
timeout_seconds=3,
)
except BaseException as error:
errors.append(error)
threads = [
threading.Thread(
target=submit,
args=("active-final", "delivery-helper"),
)
]
threads[0].start()
self.assertTrue(bridge.blocked_started.wait(1))
for index, (reply, source) in enumerate(queued_jobs, start=1):
thread = threading.Thread(target=submit, args=(reply, source))
threads.append(thread)
thread.start()
deadline = time.monotonic() + 1
while (
sender.jobs.qsize() < index
and time.monotonic() < deadline
):
time.sleep(0.01)
self.assertEqual(sender.jobs.qsize(), index)
bridge.release_blocked.set()
for thread in threads:
thread.join(timeout=3)
self.assertFalse(thread.is_alive())
sender.stop()
self.assertEqual(errors, [])
self.assertEqual(bridge.max_active, 1)
return bridge.calls
def test_fast_ack_telemetry_keeps_legacy_and_dedicated_names(self):
source = Path(MODULE.__file__).read_text(encoding="utf-8")
for name in (
"recorder.ack.gui",
"recorder.ack.end_to_end",
"recorder.ack.confirm",
"recorder.fast_ack.gui",
"recorder.fast_ack.end_to_end",
"recorder.fast_ack.confirm",
):
self.assertIn(name, source)
def test_default_upstream_is_installed_with_the_adaptive_runtime(self):
self.assertEqual(
MODULE.UPSTREAM,
MODULE.HOME
/ ".local"
/ "lib"
/ "wechat-health-watch"
/ "wechat_local_ocr.py",
)
def test_wait_for_ready_retries_without_exiting_the_service(self):
sleep = mock.Mock()
with mock.patch.object(
MODULE,
"configure_layout",
side_effect=[
RuntimeError("login required"),
(1440, 873),
],
) as configure:
layout = MODULE.wait_for_ready(
mock.Mock(),
poll_seconds=2.5,
sleep=sleep,
monotonic=mock.Mock(return_value=10.0),
)
self.assertEqual(layout, (1440, 873))
self.assertEqual(configure.call_count, 2)
sleep.assert_called_once_with(2.5)
def test_wait_for_ready_rejects_a_busy_loop(self):
with self.assertRaisesRegex(ValueError, "positive"):
MODULE.wait_for_ready(mock.Mock(), poll_seconds=0)
def test_ready_capture_preserves_the_legacy_zero_argument_call(self):
upstream = mock.Mock()
capture = mock.Mock(return_value="image")
with mock.patch.object(MODULE, "wait_for_ready") as ready:
result = MODULE.capture_when_ready(upstream, capture)
self.assertEqual(result, "image")
ready.assert_called_once_with(upstream)
capture.assert_called_once_with()
def test_ready_capture_forwards_the_verified_window_identifier(self):
upstream = mock.Mock()
capture = mock.Mock(return_value="image")
with mock.patch.object(
MODULE,
"wait_for_ready",
) as ready, mock.patch.object(
MODULE,
"configure_layout",
) as configure:
result = MODULE.capture_when_ready(upstream, capture, "42")
self.assertEqual(result, "image")
ready.assert_not_called()
configure.assert_called_once_with(upstream, identifier="42")
capture.assert_called_once_with("42")
def test_verified_capture_layout_does_not_rediscover_the_window(self):
upstream = mock.Mock()
upstream.window_geometry.return_value = (1440, 873)
layout = MODULE.configure_layout(upstream, identifier="42")
self.assertEqual(layout, (1440, 873))
upstream.window_id.assert_not_called()
upstream.window_geometry.assert_called_once_with("42")
def test_ack_end_to_end_age_uses_the_observed_timestamp(self):
self.assertEqual(
MODULE.elapsed_since_iso_ms(
"2026-10-14T12:00:00+00:00",
now=datetime(
2026,
10,
14,
12,
0,
1,
250000,
tzinfo=timezone.utc,
),
),
1250.0,
)
self.assertIsNone(MODULE.elapsed_since_iso_ms("not-a-timestamp"))
def test_baseline_box_is_preserved(self):
self.assertEqual(
MODULE.scale_box(
MODULE.BASE_MESSAGE_BOX,
width=MODULE.BASE_WIDTH,
height=MODULE.BASE_HEIGHT,
),
MODULE.BASE_MESSAGE_BOX,
)
def test_current_vnc_box_is_scaled_and_bounded(self):
box = MODULE.scale_box(
MODULE.BASE_MESSAGE_BOX,
width=1470,
height=694,
)
self.assertEqual(box, (306, 64, 1469, 582))
self.assertLess(box[0], box[2])
self.assertLess(box[1], box[3])
def test_scaled_box_never_exceeds_image_bounds(self):
self.assertEqual(
MODULE.scale_box(
MODULE.BASE_MESSAGE_BOX,
width=320,
height=200,
),
(67, 18, 320, 168),
)
def test_capture_heartbeat_is_shorter_than_health_staleness_window(self):
self.assertLess(
MODULE.CAPTURE_HEARTBEAT_SECONDS,
180,
)
def test_openclaw_transport_retries_without_control_socket(self):
with mock.patch.dict(
MODULE.os.environ,
{"WECHAT_HEALTH_CLOUD_HOST": "cloud.example"},
clear=True,
):
command = MODULE.user_openclaw_command(
mock.Mock(SSH_KEY=Path("/tmp/key")),
agent="wechat-responder",
session="probe",
timeout_seconds=30,
)
self.assertIn("-F", command)
self.assertIn("/dev/null", command)
self.assertIn("ConnectionAttempts=3", command)
self.assertIn("ServerAliveInterval=5", command)
self.assertIn("ServerAliveCountMax=2", command)
self.assertIn("IPQoS=none", command)
self.assertNotIn("ControlMaster=auto", command)
self.assertNotIn("--model", command)
self.assertNotIn("--thinking", command)
def test_edge_reply_is_used_without_invoking_legacy_router(self):
legacy = mock.Mock(return_value="legacy")
with mock.patch.object(
MODULE.jwebot_edge,
"request_edge",
return_value={"status": "reply", "text": "本地回复喵"},
):
reply = MODULE.edge_router_reply(
{"event_id": "event-1", "text": "@JWeBot 你好"},
legacy_router=legacy,
)
self.assertEqual(reply, "本地回复喵")
legacy.assert_not_called()
def test_multi_edge_reply_is_sent_by_callback(self):
legacy = mock.Mock(return_value="legacy")
reply_sender = mock.Mock(return_value=True)
incoming = {
"event_id": "event-1",
"group_fingerprint": "group-a",
"text": "@JWeBot /自走棋开赛",
}
response = {
"status": "reply",
"route": "local",
"execution_path": "local_command",
"text": "第一段喵",
"texts": ["第一段喵", "第二段喵"],
"event_id": "event-1",
}
with mock.patch.object(
MODULE.jwebot_edge,
"request_edge",
return_value=response,
):
reply = MODULE.edge_router_reply(
incoming,
legacy_router=legacy,
reply_sender=reply_sender,
)
self.assertEqual(reply, "NO_REPLY")
reply_sender.assert_called_once_with(incoming, response)
legacy.assert_not_called()
def test_fast_ack_is_sent_by_callback_without_upstream_duplicate(self):
legacy = mock.Mock(return_value="legacy")
fast_ack_sender = mock.Mock(return_value=True)
incoming = {
"event_id": "event-1",
"group_fingerprint": "group-a",
"text": "@JWeBot 你好",
}
response = {
"status": "reply",
"route": "chat",
"text": MODULE.jwebot_edge.FAST_ACKNOWLEDGEMENT,
"event_id": "event-1",
"fast_ack": True,
}
with mock.patch.object(
MODULE.jwebot_edge,
"request_edge",
return_value=response,
):
reply = MODULE.edge_router_reply(
incoming,
legacy_router=legacy,
fast_ack_sender=fast_ack_sender,
)
self.assertEqual(reply, "NO_REPLY")
fast_ack_sender.assert_called_once_with(incoming, response)
legacy.assert_not_called()
def test_failed_fast_ack_does_not_fall_back_to_a_second_sender(self):
legacy = mock.Mock(return_value="legacy")
fast_ack_sender = mock.Mock(return_value=False)
response = {
"status": "reply",
"route": "chat",
"text": MODULE.jwebot_edge.FAST_ACKNOWLEDGEMENT,
"event_id": "event-1",
"fast_ack": True,
}
with mock.patch.object(
MODULE.jwebot_edge,
"request_edge",
return_value=response,
):
reply = MODULE.edge_router_reply(
{"event_id": "event-1", "text": "@JWeBot 你好"},
legacy_router=legacy,
fast_ack_sender=fast_ack_sender,
)
self.assertEqual(reply, "NO_REPLY")
fast_ack_sender.assert_called_once()
legacy.assert_not_called()
def test_ignored_duplicate_does_not_fall_back(self):
legacy = mock.Mock(return_value="legacy")
response = {
"status": "ignored",
"route": "local",
"execution_path": "local_command",
"duplicate": True,
}
with mock.patch.object(
MODULE.jwebot_edge,
"request_edge",
return_value=response,
):
reply = MODULE.edge_router_reply(
{
"event_id": "event-2",
"group_fingerprint": "group-a",
"text": "@JWeBot /金币",
},
legacy_router=legacy,
)
self.assertEqual(reply, "NO_REPLY")
legacy.assert_not_called()
def test_hash_command_uses_legacy_router_when_edge_is_unavailable(self):
legacy = mock.Mock(return_value="legacy")
with mock.patch.object(
MODULE.jwebot_edge,
"request_edge",
side_effect=MODULE.jwebot_edge.EdgeError("unavailable"),
):
reply = MODULE.edge_router_reply(
{"event_id": "event-1", "text": "#状态"},
legacy_router=legacy,
)
self.assertEqual(reply, "legacy")
legacy.assert_called_once()
def test_non_hash_request_never_falls_back_to_legacy_router(self):
legacy = mock.Mock(return_value="legacy")
with mock.patch.object(
MODULE.jwebot_edge,
"request_edge",
side_effect=MODULE.jwebot_edge.EdgeError("unavailable"),
):
reply = MODULE.edge_router_reply(
{"event_id": "event-1", "text": "@JWeBot /丢骰子5d20"},
legacy_router=legacy,
)
self.assertIn("Local edge unavailable", reply)
legacy.assert_not_called()
def test_gui_sender_serializes_concurrent_jobs(self):
upstream = FakeGuiUpstream()
bridge = FakeGuiBridge()
with tempfile.TemporaryDirectory() as temporary:
sender = MODULE.GuiSender(
upstream,
bridge,
socket_path=Path(temporary) / "gui.sock",
)
sender.start()
try:
callbacks = []
threads = [
threading.Thread(
target=sender.send_reply,
args=(f"reply-{index}", "group-a"),
kwargs={
"event_id": f"event-{index}",
"before_send": lambda i=index: callbacks.append(
f"before-{i}"
),
"after_send": lambda i=index: callbacks.append(
f"after-{i}"
),
"timeout_seconds": 2,
},
)
for index in range(3)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=2)
self.assertTrue(all(not thread.is_alive() for thread in threads))
self.assertEqual(bridge.max_active, 1)
self.assertCountEqual(
bridge.calls,
["reply-0", "reply-1", "reply-2"],
)
self.assertEqual(len(callbacks), 6)
self.assertTrue(upstream.bridge_payloads)
self.assertEqual(upstream.title_captures, 6)
self.assertEqual(upstream.full_captures, 0)
finally:
sender.stop()
def test_gui_sender_prioritizes_new_ack_over_queued_final(self):
calls = self.run_gui_priority_scenario(
[
("queued-final", "delivery-helper"),
("newer-ack", "ack-local"),
]
)
self.assertEqual(
calls,
["active-final", "newer-ack", "queued-final"],
)
def test_gui_sender_prioritizes_local_reply_over_queued_final(self):
calls = self.run_gui_priority_scenario(
[
("queued-final", "delivery-helper"),
("newer-local", "ocr"),
]
)
self.assertEqual(
calls,
["active-final", "newer-local", "queued-final"],
)
def test_gui_sender_preserves_fifo_within_final_priority(self):
calls = self.run_gui_priority_scenario(
[
("first-queued-final", "delivery-helper"),
("second-queued-final", "delivery-helper"),
]
)
self.assertEqual(
calls,
[
"active-final",
"first-queued-final",
"second-queued-final",
],
)
def test_gui_sender_stop_sentinel_terminates_idle_worker(self):
with tempfile.TemporaryDirectory() as temporary:
sender = MODULE.GuiSender(
FakeGuiUpstream(),
FakeGuiBridge(),
socket_path=Path(temporary) / "gui.sock",
)
sender.start()
sender.stop()
self.assertFalse(sender.worker.is_alive())
self.assertFalse(sender.server.is_alive())
self.assertEqual(sender.jobs.unfinished_tasks, 0)
def test_gui_sender_fails_closed_on_title_region_mismatch(self):
upstream = FakeGuiUpstream()
upstream.current_group = "other-group"
bridge = FakeGuiBridge()
with tempfile.TemporaryDirectory() as temporary:
sender = MODULE.GuiSender(
upstream,
bridge,
socket_path=Path(temporary) / "gui.sock",
)
sender.start()
try:
with self.assertRaisesRegex(RuntimeError, "GUI sender failed"):
sender.send_reply(
"reply",
"group-a",
event_id="event-1",
timeout_seconds=2,
)
self.assertEqual(bridge.calls, [])
self.assertEqual(upstream.title_captures, 1)
self.assertEqual(upstream.full_captures, 0)
finally:
sender.stop()
def test_gui_sender_falls_back_to_full_window_group_checks(self):
upstream = FakeGuiUpstream()
upstream.capture_title_fingerprint = None
bridge = FakeGuiBridge()
with tempfile.TemporaryDirectory() as temporary:
sender = MODULE.GuiSender(
upstream,
bridge,
socket_path=Path(temporary) / "gui.sock",
)
sender.start()
try:
sender.send_reply(
"reply",
"group-a",
event_id="event-1",
timeout_seconds=2,
)
self.assertEqual(bridge.calls, ["reply"])
self.assertEqual(upstream.title_captures, 0)
self.assertEqual(upstream.full_captures, 2)
finally:
sender.stop()
def test_gui_sender_socket_submits_to_worker_queue(self):
upstream = FakeGuiUpstream()
bridge = FakeGuiBridge()
with tempfile.TemporaryDirectory() as temporary:
socket_path = Path(temporary) / "gui.sock"
sender = MODULE.GuiSender(upstream, bridge, socket_path=socket_path)
sender.start()
try:
deadline = time.monotonic() + 2
while not socket_path.exists() and time.monotonic() < deadline:
time.sleep(0.01)
response = MODULE.submit_gui_sender_request(
{
"op": "send",
"event_id": "event-1",
"group_fingerprint": "group-a",
"reply": "queued reply",
"source": "delivery-helper",
},
socket_path=socket_path,
timeout_seconds=2,
)
self.assertTrue(response["ok"])
self.assertEqual(response["state"], "sent")
self.assertEqual(bridge.calls, ["queued reply"])
finally:
sender.stop()
def test_edge_request_telemetry_excludes_message_body(self):
legacy = mock.Mock(return_value="legacy")
telemetry = mock.Mock()
with mock.patch.object(
MODULE,
"TELEMETRY",
telemetry,
), mock.patch.object(
MODULE.jwebot_edge,
"request_edge",
return_value={
"status": "reply",
"route": "local",
"text": "本地回复喵",
},
):
MODULE.edge_router_reply(
{
"event_id": "event-1",
"group_fingerprint": "group-a",
"text": "@JWeBot 私密正文",
},
legacy_router=legacy,
)
_, kwargs = telemetry.elapsed.call_args
self.assertEqual(kwargs["route"], "local")
self.assertNotIn("text", kwargs)
self.assertNotIn("私密正文", str(kwargs))
if __name__ == "__main__":
unittest.main()