Files
kefu/wechat_rpa/test_runtime_settings.py
T
2026-08-27 14:04:28 +08:00

464 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""通用运行设置、消息合并窗口与发送前等待测试。"""
import json
import queue
import tempfile
from pathlib import Path
from unittest import TestCase, main, mock
import wechat_gui
from gui_runtime import BotThread, LogQueue, delete_pending_reply_file
from wechat_bot import WeChatBot
from test_support import queue_log_redirect
def setUpModule():
# 别把测试写进开发机真实的 queue_events.json——现场排查时,那些假会话
# b"customer"、0x76 重复)混在真实流水里,看着就像机器人在反复重发。
queue_log_redirect.start()
def tearDownModule():
queue_log_redirect.stop()
class RuntimeSettingsTest(TestCase):
def test_legacy_settings_without_batch_window_keep_twenty_second_default(self):
payload = {
"auto_reply_text": "在的",
"poll_interval": 2.0,
"mouse_idle_enabled": True,
"mouse_idle_seconds": 5.0,
}
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "app_settings.json"
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
app = wechat_gui.App.__new__(wechat_gui.App)
with mock.patch.object(wechat_gui, "APP_SETTINGS_FILE", str(path)):
settings = app._load_runtime_settings()
self.assertEqual(settings["message_batch_window_seconds"], 20.0)
self.assertEqual(settings["send_delay_seconds"], 1.0)
self.assertEqual(settings["send_mode"], "auto")
self.assertEqual(settings["mouse_idle_seconds"], 5.0)
def test_invalid_saved_batch_window_falls_back_to_twenty_seconds(self):
self.assertEqual(
wechat_gui.normalize_message_batch_window_seconds(True),
20.0,
)
payload = {
**wechat_gui.App._runtime_defaults(),
"message_batch_window_seconds": 999,
}
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "app_settings.json"
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
app = wechat_gui.App.__new__(wechat_gui.App)
with mock.patch.object(wechat_gui, "APP_SETTINGS_FILE", str(path)):
settings = app._load_runtime_settings()
self.assertEqual(settings["message_batch_window_seconds"], 20.0)
def test_invalid_saved_send_delay_falls_back_to_one_second(self):
self.assertEqual(wechat_gui.normalize_send_delay_seconds(True), 1.0)
payload = {
**wechat_gui.App._runtime_defaults(),
"send_delay_seconds": 31,
}
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "app_settings.json"
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
app = wechat_gui.App.__new__(wechat_gui.App)
with mock.patch.object(wechat_gui, "APP_SETTINGS_FILE", str(path)):
settings = app._load_runtime_settings()
self.assertEqual(settings["send_delay_seconds"], 1.0)
self.assertEqual(settings["mouse_idle_seconds"], 5.0)
def test_invalid_send_mode_falls_back_to_automatic(self):
self.assertEqual(wechat_gui.normalize_send_mode("unknown"), "auto")
self.assertEqual(wechat_gui.normalize_send_mode("review"), "review")
def test_runtime_thread_syncs_updated_batch_window_before_next_poll(self):
observed = []
fake_bot = mock.Mock()
fake_bot.connect.return_value = True
fake_bot._window_ready = True
fake_bot.hwnd = 100
fake_bot.L = fake_bot.T = 0
fake_bot.R = 1600
fake_bot.B = 900
fake_bot.input_x = 1200
fake_bot.input_y = 780
fake_bot.security_verification_required = False
fake_bot.reply_count = 0
fake_bot.false_pos_rows = set()
thread = wechat_gui.BotThread(
queue.Queue(),
"在的",
0.001,
message_batch_window_seconds=7,
send_delay_seconds=1.5,
send_mode="review",
)
def poll_once(scan_unread=True): # noqa: ARG001 # 适配 gui_runtime 新调用契约
observed.append((
fake_bot.message_batch_window_seconds,
fake_bot.send_delay_seconds,
fake_bot.send_mode,
))
if len(observed) == 1:
thread.set_message_batch_window_seconds(9)
thread.set_send_delay_seconds(2.5)
thread.set_send_mode("auto")
else:
thread.stop_event.set()
fake_bot._poll_once.side_effect = poll_once
with mock.patch("wechat_bot.WeChatBot", return_value=fake_bot):
thread.run()
self.assertEqual(
observed,
[(7.0, 1.5, "review"), (9, 2.5, "auto")],
)
def test_classic_settings_save_persists_and_updates_live_thread(self):
app = wechat_gui.App.__new__(wechat_gui.App)
app._runtime_save_job = None
app._last_runtime_settings = {}
app._runtime_settings = {
"send_delay_seconds": 3.5,
"send_mode": "review",
}
app._reply_var = mock.Mock()
app._reply_var.get.return_value = "在的"
app._poll_var = mock.Mock()
app._poll_var.get.return_value = "2"
app._idle_seconds_var = mock.Mock()
app._idle_seconds_var.get.return_value = "5"
app._batch_window_var = mock.Mock()
app._batch_window_var.get.return_value = "12.5"
app._mouse_idle_var = mock.Mock()
app._mouse_idle_var.get.return_value = True
app._thread = mock.Mock()
app._thread.is_alive.return_value = True
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "app_settings.json"
with mock.patch.object(wechat_gui, "APP_SETTINGS_FILE", str(path)):
self.assertTrue(app._save_runtime_settings(silent=True))
saved = json.loads(path.read_text(encoding="utf-8"))
self.assertEqual(saved["message_batch_window_seconds"], 12.5)
self.assertEqual(saved["send_delay_seconds"], 3.5)
self.assertEqual(saved["send_mode"], "review")
app._thread.set_message_batch_window_seconds.assert_called_once_with(12.5)
app._thread.set_send_delay_seconds.assert_called_once_with(3.5)
app._thread.set_send_mode.assert_called_once_with("review")
def test_send_delay_wait_is_independent_and_interruptible(self):
bot = WeChatBot.__new__(WeChatBot)
bot.send_delay_seconds = 1.25
bot._stop_check = mock.Mock()
bot._stop_check.wait.side_effect = [False, True]
with mock.patch("builtins.print"):
self.assertFalse(bot.wait_before_send())
self.assertAlmostEqual(bot._stop_check.wait.call_args_list[0].args[0], 0.2)
def test_review_pending_state_survives_restart(self):
fp = b"r" * 40
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "pending_replies.json"
bot = WeChatBot.__new__(WeChatBot)
bot._pending_reply_path = str(path)
bot._pending_reply_sessions = {
fp.hex(): {
"batch_ready": True,
"confirmed_unread": True,
"created_at": 1.0,
"updated_at": 2.0,
"awaiting_review": True,
"review_requested_at": 2.0,
"reply_text": "请人工确认",
"stage": "manual_review",
}
}
self.assertTrue(bot._persist_pending_replies())
restored_bot = WeChatBot.__new__(WeChatBot)
restored_bot._pending_reply_path = str(path)
restored = restored_bot._load_pending_replies()
self.assertTrue(restored[fp.hex()]["awaiting_review"])
self.assertEqual(restored[fp.hex()]["reply_text"], "请人工确认")
self.assertEqual(restored[fp.hex()]["stage"], "manual_review")
def test_manual_retry_only_requeues_retry_wait_tasks(self):
bot = WeChatBot.__new__(WeChatBot)
retry_fp = b"r" * 40
normal_fp = b"n" * 40
review_fp = b"v" * 40
uncertain_fp = b"u" * 40
bot._pending_reply_path = ""
bot._pending_reply_sessions = {
retry_fp.hex(): {
"stage": "retry_wait",
"stage_started_at": 1.0,
"updated_at": 1.0,
"last_error": "temporary",
"resume_failures": 3,
"last_resume_attempt": 999.0,
"display_name": "待重试客户",
},
normal_fp.hex(): {"stage": "generating", "updated_at": 1.0},
review_fp.hex(): {
"stage": "manual_review",
"awaiting_review": True,
"updated_at": 1.0,
},
uncertain_fp.hex(): {
"stage": "retry_wait",
"send_state": "uncertain",
"updated_at": 1.0,
},
}
# 对着真正的 `queue_log` 访问器断言,而不是私有的 `_queue_log_instance`
# 模块级的队列日志重定向覆盖的就是这个属性,盯着私有字段会漏掉真实调用。
recorder = mock.Mock()
with mock.patch.object(
type(bot), "queue_log", property(lambda _self, _log=recorder: _log)
):
result = bot.retry_pending_replies(
[retry_fp.hex(), normal_fp.hex(), review_fp.hex(), uncertain_fp.hex()]
)
self.assertEqual(result["retried"], [retry_fp.hex()])
self.assertEqual(result["not_retryable"], [normal_fp.hex()])
self.assertEqual(
set(result["protected"]), {review_fp.hex(), uncertain_fp.hex()}
)
retried = bot._pending_reply_sessions[retry_fp.hex()]
self.assertEqual(retried["stage"], "queued")
self.assertEqual(retried["last_resume_attempt"], 0.0)
self.assertNotIn("last_error", retried)
self.assertEqual(retried["resume_failures"], 3)
recorder.append.assert_called_once()
def test_retry_request_queues_until_bot_startup_finishes(self):
key = (b"q" * 40).hex()
thread = BotThread(queue.Queue(), "在的", 1.0)
result = thread.retry_pending_tasks([key])
self.assertEqual(result["scheduled"], [key])
fake_bot = mock.Mock()
thread._drain_queued_retries(fake_bot)
fake_bot.retry_pending_replies.assert_called_once_with({key})
def test_bot_uses_instance_batch_window_when_call_has_no_override(self):
bot = WeChatBot.__new__(WeChatBot)
bot.message_batch_window_seconds = 7
bot._active_session_fp = b"target"
bot._active_identity_signature = b"identity"
bot._chat_identity_signature = mock.Mock(return_value=b"identity")
bot._chat_surface_signature = mock.Mock(return_value=b"surface")
bot._raw_selected_session_fingerprint = mock.Mock(return_value=b"target")
bot._selected_session_fingerprint = mock.Mock(return_value=b"target")
bot._stop_check = mock.Mock()
bot._stop_check.wait.return_value = True
with mock.patch("builtins.print") as output:
self.assertFalse(bot._wait_for_message_batch(b"target"))
rendered = "\n".join(
" ".join(str(part) for part in call.args)
for call in output.call_args_list
)
self.assertIn("7 秒", rendered)
def test_classic_capsule_renders_each_progress_message_as_a_numbered_step(self):
app = wechat_gui.App.__new__(wechat_gui.App)
app._running = True
app._capsule_progress_text = ""
app._capsule_progress_step = 0
app._capsule_progress = mock.Mock()
app._update_capsule_progress("消息读取 6/8 · 复制聊天第 1/1 屏")
app._capsule_progress.configure.assert_called_once_with(
text="步骤 001 · 消息读取 6/8 · 复制聊天第 1/1 屏"
)
self.assertEqual(app._capsule_progress_step, 1)
self.assertEqual(app._capsule_progress_text, "消息读取 6/8 · 复制聊天第 1/1 屏")
def test_classic_queue_consumes_the_runtime_progress_channel(self):
app = wechat_gui.App.__new__(wechat_gui.App)
app._queue = queue.Queue()
app._queue.put(("progress", "视觉识别 3/4 · 等待多模态模型分析"))
app._sync_console_dpi = mock.Mock()
app._update_capsule_progress = mock.Mock()
app._start_time = None
app._running = True
app.after = mock.Mock()
app._process_queue()
app._update_capsule_progress.assert_called_once_with(
"视觉识别 3/4 · 等待多模态模型分析"
)
def test_atomic_operation_progress_includes_phase_step_detail_and_customer(self):
bot = WeChatBot.__new__(WeChatBot)
fp = b"customer"
bot._pending_reply_sessions = {
fp.hex(): {"display_name": "高瑞@微信"}
}
bot._progress_text = ""
bot.progress_cb = mock.Mock()
bot.report_operation("发送", 9, 12, "按 Ctrl+V 粘贴回复", fp)
bot.progress_cb.assert_called_once_with(
"发送 9/12 · 按 Ctrl+V 粘贴回复 · 高瑞@微信"
)
def test_tagged_low_level_logs_are_mirrored_to_capsule_progress(self):
target = queue.Queue()
log = LogQueue.__new__(LogQueue)
log.target_queue = target
log._handle = None
log.write(" [未读扫描] 正在从列表顶部逐页查找未置顶未读…")
self.assertEqual(target.get_nowait()[0], "log")
self.assertEqual(
target.get_nowait(),
("progress", "未读扫描 · 正在从列表顶部逐页查找未置顶未读…"),
)
def test_customer_chat_body_is_not_mirrored_into_floating_capsule(self):
self.assertEqual(
LogQueue.progress_from_log("[AI] 本次提取的新内容:\n客户隐私正文"),
"",
)
def test_message_copy_retries_when_wecom_writes_clipboard_late(self):
bot = WeChatBot.__new__(WeChatBot)
bot.report_operation = mock.Mock()
sentinel = WeChatBot._CLIP_SENTINEL
clipboard_reads = [sentinel] + [sentinel] * 6 + ["客户刚发的消息"]
with mock.patch("wechat_bot.pyperclip.copy"), \
mock.patch("wechat_bot.pyperclip.paste", side_effect=clipboard_reads), \
mock.patch("wechat_bot.pyautogui.hotkey") as hotkey, \
mock.patch("wechat_bot.time.sleep"):
copied = bot._copy_selection()
self.assertEqual(copied, "客户刚发的消息")
self.assertEqual(hotkey.call_count, 2)
def test_manual_queue_delete_removes_only_selected_full_key_atomically(self):
first = "a" * 80
second = "b" * 80
payload = {
"pending": {
first: {"display_name": "甲", "created_at": 1},
second: {"display_name": "乙", "created_at": 2},
},
"version": 1,
}
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "pending_replies.json"
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
with mock.patch("queue_log.QueueLog"):
result = delete_pending_reply_file([first], str(path))
saved = json.loads(path.read_text(encoding="utf-8"))
self.assertEqual(result["deleted"], [first])
self.assertNotIn(first, saved["pending"])
self.assertIn(second, saved["pending"])
self.assertEqual(saved["version"], 1)
class LogRetentionTest(TestCase):
"""设置页的「数据保留 N 天」必须真的删东西,不能只是个摆设。"""
def _log_dir(self):
import os
root = Path(tempfile.mkdtemp())
patcher = mock.patch.object(LogQueue, "LOG_DIR", str(root))
patcher.start()
self.addCleanup(patcher.stop)
os.makedirs(root, exist_ok=True)
return root
def _write(self, root, name, age_days):
import os
import time
path = root / name
path.write_text("旧日志", encoding="utf-8")
stamp = time.time() - age_days * 86400.0
os.utime(path, (stamp, stamp))
return path
def test_out_of_range_values_fall_back_to_the_documented_defaults(self):
self.assertEqual(LogQueue.normalize_retention_days(30), 30)
self.assertEqual(LogQueue.normalize_retention_days(0), LogQueue.MIN_RETENTION_DAYS)
self.assertEqual(LogQueue.normalize_retention_days(9999), LogQueue.MAX_RETENTION_DAYS)
for junk in (None, "", "abc", True):
self.assertEqual(
LogQueue.normalize_retention_days(junk),
LogQueue.DEFAULT_RETENTION_DAYS,
junk,
)
def test_logs_older_than_the_retention_window_are_deleted(self):
root = self._log_dir()
stale = self._write(root, "gui_20250101_010101.log", age_days=120)
fresh = self._write(root, "gui_20260820_010101.log", age_days=3)
self.addCleanup(LogQueue(queue.Queue(), retention_days=90).close)
self.assertFalse(stale.exists())
self.assertTrue(fresh.exists())
def test_a_shorter_window_deletes_more(self):
root = self._log_dir()
older = self._write(root, "gui_a.log", age_days=20)
newer = self._write(root, "gui_b.log", age_days=3)
self.addCleanup(LogQueue(queue.Queue(), retention_days=7).close)
self.assertFalse(older.exists())
self.assertTrue(newer.exists())
def test_the_file_count_cap_still_applies(self):
root = self._log_dir()
for index in range(LogQueue.KEEP_FILES + 5):
self._write(root, f"gui_{index:03d}.log", age_days=1)
self.addCleanup(LogQueue(queue.Queue(), retention_days=365).close)
remaining = sorted(root.glob("gui_*.log"))
self.assertLessEqual(len(remaining), LogQueue.KEEP_FILES)
def test_a_clock_rollback_never_deletes_a_fresh_log(self):
# mtime 落在未来(时钟被回拨)时 age 为负,绝不能当成"很旧"删掉
root = self._log_dir()
future = self._write(root, "gui_future.log", age_days=-30)
self.addCleanup(LogQueue(queue.Queue(), retention_days=7).close)
self.assertTrue(future.exists())
def test_unreadable_directory_never_breaks_startup(self):
root = self._log_dir()
self._write(root, "gui_x.log", age_days=1)
with mock.patch("gui_runtime.glob.glob", side_effect=OSError("盘掉了")):
self.addCleanup(LogQueue(queue.Queue(), retention_days=90).close) # 不抛异常即可
if __name__ == "__main__":
main()