更新
This commit is contained in:
@@ -0,0 +1,622 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""会话身份改用昵称之后,身份必须稳:同一个人永远同一个 ID。"""
|
||||
|
||||
import io
|
||||
from contextlib import redirect_stdout
|
||||
from unittest import TestCase, main, mock
|
||||
|
||||
import numpy as np
|
||||
|
||||
import session_name
|
||||
from session_name import NameReader, crop_to_ink, ink_bounds, normalize_name, session_id_for
|
||||
from test_support import queue_log_redirect
|
||||
|
||||
|
||||
def setUpModule():
|
||||
queue_log_redirect.start()
|
||||
|
||||
|
||||
def tearDownModule():
|
||||
queue_log_redirect.stop()
|
||||
|
||||
|
||||
def panel_with_text(width=200, height=40, box=(20, 10, 120, 30)) -> np.ndarray:
|
||||
"""浅底深字的一块面板,box 是那行字占的矩形。"""
|
||||
image = np.full((height, width, 3), 245, dtype=np.uint8)
|
||||
x1, y1, x2, y2 = box
|
||||
image[y1:y2, x1:x2] = 30
|
||||
return image
|
||||
|
||||
|
||||
class FakeEngine:
|
||||
"""替身 OCR:按调用顺序吐出预先排好的(文本, 置信度)。"""
|
||||
|
||||
def __init__(self, *results):
|
||||
self.results = list(results)
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, image, **kwargs):
|
||||
self.calls.append((image.shape, kwargs))
|
||||
if not self.results:
|
||||
return None, None
|
||||
text, score = self.results.pop(0)
|
||||
return [[text, score]], None
|
||||
|
||||
|
||||
def reader_with(*results) -> NameReader:
|
||||
reader = NameReader()
|
||||
reader._engine = FakeEngine(*results)
|
||||
return reader
|
||||
|
||||
|
||||
class NameNormalisationTest(TestCase):
|
||||
def test_the_title_and_the_list_row_agree_on_one_person(self):
|
||||
# 实测标题读出来带空格、列表行不带。不抹平的话同一个人两个 ID,
|
||||
# 又回到一人多档、客户收不到回复的老问题
|
||||
self.assertEqual(normalize_name("高瑞 @微信"), normalize_name("高瑞@微信"))
|
||||
self.assertEqual(
|
||||
session_id_for("高瑞 @微信"), session_id_for("高瑞@微信")
|
||||
)
|
||||
|
||||
def test_full_width_characters_fold_to_the_same_id(self):
|
||||
self.assertEqual(session_id_for("高瑞@微信"), session_id_for("高瑞@微信"))
|
||||
|
||||
def test_the_id_is_a_plain_md5_hex(self):
|
||||
sid = session_id_for("高瑞@微信")
|
||||
self.assertEqual(len(sid), 32)
|
||||
self.assertTrue(all(ch in "0123456789abcdef" for ch in sid))
|
||||
|
||||
def test_two_different_people_never_share_an_id(self):
|
||||
self.assertNotEqual(
|
||||
session_id_for("高瑞@微信"), session_id_for("一个小迷糊@微信")
|
||||
)
|
||||
|
||||
def test_no_name_means_no_id(self):
|
||||
# 读不到名字时给个空 ID,调用方好判断"这一轮先别认",
|
||||
# 而不是拿空字符串的 md5 去当成某个真实会话
|
||||
self.assertEqual(session_id_for(""), "")
|
||||
self.assertEqual(session_id_for(" "), "")
|
||||
|
||||
|
||||
class InkCropTest(TestCase):
|
||||
def test_it_finds_the_line_of_text_in_the_panel(self):
|
||||
image = panel_with_text(box=(20, 10, 120, 30))
|
||||
self.assertEqual(ink_bounds(image), (20, 10, 120, 30))
|
||||
|
||||
def test_a_blank_panel_has_no_text(self):
|
||||
blank = np.full((40, 200, 3), 245, dtype=np.uint8)
|
||||
self.assertIsNone(ink_bounds(blank))
|
||||
self.assertIsNone(crop_to_ink(blank))
|
||||
|
||||
def test_the_crop_keeps_a_margin_around_the_glyphs(self):
|
||||
# 贴着字裁会切掉首字的一竖,实测「一个小迷糊@微信」会读成「个小迷糊@微信」
|
||||
crop = crop_to_ink(panel_with_text(box=(20, 10, 120, 30)), pad=5)
|
||||
self.assertEqual(crop.shape[1], 110)
|
||||
self.assertEqual(crop.shape[0], 30)
|
||||
|
||||
def test_the_crop_never_runs_off_the_panel(self):
|
||||
# 字顶到左右边框时,留白不能把裁剪框推到画面外面去
|
||||
crop = crop_to_ink(panel_with_text(box=(0, 12, 200, 28)), pad=8)
|
||||
self.assertEqual(crop.shape[:2], (32, 200))
|
||||
|
||||
def test_a_dark_themed_panel_still_yields_its_text(self):
|
||||
image = np.full((40, 200, 3), 30, dtype=np.uint8)
|
||||
image[10:30, 20:120] = 240
|
||||
self.assertEqual(ink_bounds(image), (20, 10, 120, 30))
|
||||
|
||||
|
||||
class NameReadingTest(TestCase):
|
||||
def test_a_confident_read_becomes_the_session_identity(self):
|
||||
reader = reader_with(("高瑞@微信", 0.94))
|
||||
self.assertEqual(reader.read(panel_with_text()), "高瑞@微信")
|
||||
|
||||
def test_the_detector_is_skipped_so_it_stays_fast_enough_to_poll(self):
|
||||
# 带检测要 1.6 秒,放不进轮询;框自己算好了就别让模型再找一遍
|
||||
reader = reader_with(("高瑞@微信", 0.94))
|
||||
reader.read(panel_with_text())
|
||||
_shape, kwargs = reader._engine.calls[0]
|
||||
self.assertFalse(kwargs["use_det"])
|
||||
self.assertFalse(kwargs["use_cls"])
|
||||
|
||||
def test_a_shaky_read_is_refused_rather_than_guessed(self):
|
||||
# 实测「打卡」被读成「江卡」只有 0.708。宁可这一轮不认,
|
||||
# 也不能凭一个错名字开出一份新档案
|
||||
reader = reader_with(("江卡", 0.70))
|
||||
self.assertEqual(reader.read(panel_with_text()), "")
|
||||
|
||||
def test_a_blank_panel_never_reaches_the_engine(self):
|
||||
reader = reader_with(("不该被调用", 0.99))
|
||||
blank = np.full((40, 200, 3), 245, dtype=np.uint8)
|
||||
self.assertEqual(reader.read(blank), "")
|
||||
self.assertEqual(reader._engine.calls, [])
|
||||
|
||||
def test_the_same_screen_is_only_read_once(self):
|
||||
# 同一块画面在一轮里会被反复问到;每次都重识别既慢,又可能这次读对
|
||||
# 下次读错,会话身份就在同一轮里跳变
|
||||
reader = reader_with(("高瑞@微信", 0.94))
|
||||
image = panel_with_text()
|
||||
first = reader.read(image, signature=b"same-screen")
|
||||
second = reader.read(image, signature=b"same-screen")
|
||||
self.assertEqual(first, "高瑞@微信")
|
||||
self.assertEqual(second, "高瑞@微信")
|
||||
self.assertEqual(len(reader._engine.calls), 1)
|
||||
|
||||
def test_a_different_screen_is_read_again(self):
|
||||
reader = reader_with(("高瑞@微信", 0.94), ("一个小迷糊@微信", 0.98))
|
||||
image = panel_with_text()
|
||||
self.assertEqual(reader.read(image, signature=b"screen-a"), "高瑞@微信")
|
||||
self.assertEqual(reader.read(image, signature=b"screen-b"), "一个小迷糊@微信")
|
||||
|
||||
def test_a_refused_read_is_not_cached_as_an_answer(self):
|
||||
# 低置信度不该被记成"这块画面就是没名字",下一轮画面清楚了要能读出来
|
||||
reader = reader_with(("江卡", 0.70), ("打卡记录", 0.95))
|
||||
image = panel_with_text()
|
||||
self.assertEqual(reader.read(image, signature=b"row"), "")
|
||||
self.assertEqual(reader.read(image, signature=b"row"), "打卡记录")
|
||||
|
||||
def test_the_cache_is_capped_so_a_day_long_run_cannot_eat_memory(self):
|
||||
# 每一行的未读态/选中态/悬停态各占一个条目,机器人一跑就是一整天
|
||||
reader = NameReader()
|
||||
reader._engine = FakeEngine(
|
||||
*[("高瑞@微信", 0.94)] * (session_name.MAX_CACHE_ENTRIES + 40)
|
||||
)
|
||||
image = panel_with_text()
|
||||
for index in range(session_name.MAX_CACHE_ENTRIES + 30):
|
||||
reader.read(image, signature=f"screen-{index}".encode())
|
||||
self.assertLessEqual(len(reader._cache), session_name.MAX_CACHE_ENTRIES)
|
||||
self.assertGreater(len(reader._cache), 0)
|
||||
|
||||
def test_a_missing_engine_reports_no_name_instead_of_crashing(self):
|
||||
reader = NameReader()
|
||||
reader._engine_failed = True
|
||||
self.assertFalse(reader.available)
|
||||
self.assertEqual(reader.read(panel_with_text()), "")
|
||||
|
||||
def test_an_engine_that_throws_does_not_take_the_round_down(self):
|
||||
class Exploding:
|
||||
def __call__(self, image, **kwargs):
|
||||
raise RuntimeError("模型崩了")
|
||||
|
||||
reader = NameReader()
|
||||
reader._engine = Exploding()
|
||||
with redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(reader.read(panel_with_text()), "")
|
||||
|
||||
|
||||
class MisreadCorrectionTest(TestCase):
|
||||
def test_a_one_character_slip_is_pulled_back_to_the_known_contact(self):
|
||||
# 「行业资讯」被读成「亏业资讯」。不纠的话这一轮就成了另一个客户,
|
||||
# 上下文断掉、档案分家
|
||||
reader = NameReader()
|
||||
reader.remember("行业资讯")
|
||||
with redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(reader.canonical("亏业资讯"), "行业资讯")
|
||||
|
||||
def test_a_genuinely_new_contact_is_not_folded_into_an_old_one(self):
|
||||
reader = NameReader()
|
||||
reader.remember("高瑞@微信")
|
||||
self.assertEqual(reader.canonical("一个小迷糊@微信"), "一个小迷糊@微信")
|
||||
|
||||
def test_two_characters_apart_is_too_far_to_assume(self):
|
||||
reader = NameReader()
|
||||
reader.remember("甄养堂助理-江莉莉")
|
||||
self.assertEqual(reader.canonical("甄养堂助理-王菲菲"), "甄养堂助理-王菲菲")
|
||||
|
||||
def test_short_names_are_never_corrected(self):
|
||||
# 「小王」和「小李」也只差一个字,但那是两个人
|
||||
reader = NameReader()
|
||||
reader.remember("小王")
|
||||
self.assertEqual(reader.canonical("小李"), "小李")
|
||||
|
||||
def test_the_contact_seen_most_often_wins_a_tie(self):
|
||||
# 偶发误读只会出现一两次,常客才是真身
|
||||
reader = NameReader()
|
||||
reader._known = {"客户联系": 40, "客尸联系": 1}
|
||||
with redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(reader.canonical("客户联糸"), "客户联系")
|
||||
|
||||
def test_an_exact_known_name_is_returned_untouched(self):
|
||||
reader = NameReader()
|
||||
reader.remember("高瑞@微信")
|
||||
self.assertEqual(reader.canonical("高瑞@微信"), "高瑞@微信")
|
||||
|
||||
def test_correction_keeps_the_id_stable_across_a_slip(self):
|
||||
# 这才是纠正的意义:读错一个字,会话 ID 依然不变
|
||||
reader = NameReader()
|
||||
reader.remember("行业资讯")
|
||||
with redirect_stdout(io.StringIO()):
|
||||
corrected = reader.canonical("亏业资讯")
|
||||
self.assertEqual(session_id_for(corrected), session_id_for("行业资讯"))
|
||||
|
||||
def test_a_corrected_read_does_not_register_the_wrong_spelling(self):
|
||||
reader = NameReader()
|
||||
reader.remember("行业资讯")
|
||||
with redirect_stdout(io.StringIO()):
|
||||
reader.read(panel_with_text(), signature=b"")
|
||||
reader._engine = FakeEngine(("亏业资讯", 0.89))
|
||||
with redirect_stdout(io.StringIO()):
|
||||
reader.read(panel_with_text())
|
||||
self.assertNotIn("亏业资讯", reader.known_names())
|
||||
|
||||
|
||||
class BotIdentityReadingTest(TestCase):
|
||||
"""机器人读「当前打开的是谁」,是进度提示和会话身份共同的入口。"""
|
||||
|
||||
def _bot(self):
|
||||
from wechat_bot import WeChatBot
|
||||
|
||||
bot = WeChatBot.__new__(WeChatBot)
|
||||
bot._progress_text = ""
|
||||
bot.progress_cb = None
|
||||
return bot
|
||||
|
||||
def test_the_open_chat_is_named_from_its_title(self):
|
||||
bot = self._bot()
|
||||
bot._name_reader_instance = reader_with(("高瑞@微信", 0.94))
|
||||
bot._chat_title_panel = lambda: panel_with_text()
|
||||
bot._chat_identity_signature = lambda: b"title-sig"
|
||||
self.assertEqual(bot._open_chat_display_name(), "高瑞@微信")
|
||||
|
||||
def test_the_title_pixels_key_the_cache_so_one_chat_reads_once(self):
|
||||
# 同一个会话在一轮里会被问到好几次,每次都重识别既慢、又可能这次读对
|
||||
# 下次读错,身份就在同一轮里跳变
|
||||
bot = self._bot()
|
||||
reader = reader_with(("高瑞@微信", 0.94))
|
||||
bot._name_reader_instance = reader
|
||||
bot._chat_title_panel = lambda: panel_with_text()
|
||||
bot._chat_identity_signature = lambda: b"title-sig"
|
||||
bot._open_chat_display_name()
|
||||
bot._open_chat_display_name()
|
||||
self.assertEqual(len(reader._engine.calls), 1)
|
||||
|
||||
def test_a_missing_title_panel_names_nobody(self):
|
||||
bot = self._bot()
|
||||
bot._name_reader_instance = reader_with(("不该被调用", 0.99))
|
||||
bot._chat_title_panel = lambda: None
|
||||
bot._chat_identity_signature = lambda: b""
|
||||
self.assertEqual(bot._open_chat_display_name(), "")
|
||||
|
||||
def test_the_progress_line_falls_back_to_a_fingerprint_stub(self):
|
||||
# 读不出名字时进度提示也不能变成空白,用户会以为它死了
|
||||
bot = self._bot()
|
||||
bot._chat_title_panel = lambda: None
|
||||
bot._chat_identity_signature = lambda: b""
|
||||
bot._name_reader_instance = reader_with()
|
||||
self.assertEqual(bot._session_label(bytes.fromhex("ab" * 20)), "会话 abababab")
|
||||
|
||||
def test_the_progress_line_prefers_the_nickname(self):
|
||||
bot = self._bot()
|
||||
bot._name_reader_instance = reader_with(("一个小迷糊@微信", 0.98))
|
||||
bot._chat_title_panel = lambda: panel_with_text()
|
||||
bot._chat_identity_signature = lambda: b"title-sig"
|
||||
self.assertEqual(bot._session_label(b"\x01" * 40), "一个小迷糊@微信")
|
||||
|
||||
|
||||
class RowIdentityTest(TestCase):
|
||||
"""会话身份改由昵称派生:同一行在任何渲染下都必须是同一个人。"""
|
||||
|
||||
def _bot(self, name_by_row=None):
|
||||
from wechat_bot import WeChatBot
|
||||
|
||||
bot = WeChatBot.__new__(WeChatBot)
|
||||
bot.identity_by_name = True
|
||||
bot.scale = 1.0
|
||||
bot.session_item_h = 64
|
||||
bot._session_identity_trustworthy = lambda: True
|
||||
if name_by_row is not None:
|
||||
bot._row_display_name = (
|
||||
lambda img, rel_y, row_center=False: name_by_row(rel_y)
|
||||
)
|
||||
return bot
|
||||
|
||||
@staticmethod
|
||||
def _row_image(width=230, height=64, bold=False, selected=False):
|
||||
"""一行会话。未读是加粗、选中是蓝底白字——像素差别正是老 bug 的根源。"""
|
||||
background = (230, 120, 40) if selected else (245, 245, 245)
|
||||
image = np.full((height, width, 3), background, dtype=np.uint8)
|
||||
ink = 255 if selected else (10 if bold else 60)
|
||||
image[16:30, 70:150] = ink
|
||||
if bold:
|
||||
image[16:32, 70:154] = ink
|
||||
image[8:24, 210:226] = (60, 60, 220) # 未读红点
|
||||
return image
|
||||
|
||||
def test_the_same_contact_matches_across_unread_and_selected_renderings(self):
|
||||
# 这就是现场那句「实际打开对象与目标不一致,已停止后续发送」:
|
||||
# 记下目标时它是未读(加粗+红点),点开后它是选中(蓝底白字),
|
||||
# 像素哈希算出两个身份,消息复制到了也不敢发
|
||||
bot = self._bot(lambda rel_y: "一个小迷糊@微信")
|
||||
unread_fp = bot._session_fingerprint(
|
||||
self._row_image(bold=True), 32, row_center=True
|
||||
)
|
||||
selected_fp = bot._session_fingerprint(
|
||||
self._row_image(selected=True), 32, row_center=True
|
||||
)
|
||||
self.assertTrue(unread_fp)
|
||||
self.assertEqual(unread_fp, selected_fp)
|
||||
self.assertTrue(bot._session_fp_matches(unread_fp, selected_fp))
|
||||
|
||||
def test_two_different_contacts_stay_apart(self):
|
||||
bot = self._bot(lambda rel_y: "高瑞@微信" if rel_y < 50 else "一个小迷糊@微信")
|
||||
first = bot._session_fingerprint(self._row_image(), 32, row_center=True)
|
||||
second = bot._session_fingerprint(self._row_image(), 96, row_center=True)
|
||||
self.assertNotEqual(first, second)
|
||||
self.assertFalse(bot._session_fp_matches(first, second))
|
||||
|
||||
def test_the_session_id_is_exactly_the_nickname_md5(self):
|
||||
from wechat_bot import WeChatBot
|
||||
|
||||
fp = WeChatBot._fp_from_name("高瑞@微信")
|
||||
self.assertEqual(len(fp), 40)
|
||||
self.assertEqual(WeChatBot.session_id_of(fp), session_id_for("高瑞@微信"))
|
||||
|
||||
def test_the_title_and_the_row_produce_one_identity(self):
|
||||
# 标题里带空格、列表行不带;归一之后必须是同一个人
|
||||
from wechat_bot import WeChatBot
|
||||
|
||||
self.assertEqual(
|
||||
WeChatBot._fp_from_name("高瑞 @微信"),
|
||||
WeChatBot._fp_from_name("高瑞@微信"),
|
||||
)
|
||||
|
||||
def test_no_nickname_means_no_identity(self):
|
||||
from wechat_bot import WeChatBot
|
||||
|
||||
self.assertEqual(WeChatBot._fp_from_name(""), b"")
|
||||
self.assertEqual(WeChatBot._fp_from_name(" "), b"")
|
||||
|
||||
def test_an_unreadable_row_falls_back_to_the_pixel_path(self):
|
||||
# OCR 读不出来(空行、引擎缺失)时不能整个瘫掉,还得按老办法认
|
||||
bot = self._bot(lambda rel_y: "")
|
||||
fallback = mock.Mock(return_value=b"pixel-path")
|
||||
bot._canonical_fp = fallback
|
||||
bot._raw_session_fingerprint = mock.Mock(return_value=b"raw")
|
||||
bot._session_name_fingerprint = mock.Mock(return_value=b"")
|
||||
bot._session_fingerprint(self._row_image(), 32, row_center=True)
|
||||
fallback.assert_called_once()
|
||||
|
||||
def test_the_pixel_path_is_untouched_when_the_switch_is_off(self):
|
||||
bot = self._bot(lambda rel_y: "高瑞@微信")
|
||||
bot.identity_by_name = False
|
||||
bot._canonical_fp = mock.Mock(return_value=b"a" * 8)
|
||||
bot._raw_session_fingerprint = mock.Mock(return_value=b"raw")
|
||||
bot._session_name_fingerprint = mock.Mock(return_value=b"n" * 32)
|
||||
bot._canonical_session_fp = lambda raw: raw
|
||||
bot._session_render_fingerprint = mock.Mock(return_value=b"")
|
||||
fp = bot._session_fingerprint(self._row_image(), 32, row_center=True)
|
||||
self.assertEqual(fp, b"a" * 8 + b"n" * 32)
|
||||
|
||||
|
||||
class RowNameCropTest(TestCase):
|
||||
def _bot(self, width=460, item_h=128):
|
||||
from wechat_bot import WeChatBot
|
||||
|
||||
bot = WeChatBot.__new__(WeChatBot)
|
||||
bot.scale = 2.0
|
||||
bot.session_item_h = item_h
|
||||
bot._avatar_anchor = lambda img, y: y
|
||||
return bot
|
||||
|
||||
def test_the_crop_skips_the_avatar_and_stops_before_the_timestamp(self):
|
||||
# 实测 460px 宽的列表:头像占到 112,昵称 136..318,时间戳更靠右。
|
||||
# 时间戳一旦进框,「1分钟前」变「12:08」就会让同一个人换 md5
|
||||
bot = self._bot()
|
||||
image = np.zeros((1208, 460, 3), dtype=np.uint8)
|
||||
panel = bot._row_name_panel(image, 78, row_center=True)
|
||||
self.assertIsNotNone(panel)
|
||||
left = int(460 * 0.26)
|
||||
right = int(460 * 0.78)
|
||||
self.assertEqual(panel.shape[1], right - left)
|
||||
self.assertGreater(left, 112) # 头像在外
|
||||
self.assertLess(left, 136) # 昵称起点没被切
|
||||
self.assertGreater(right, 318) # 最长的昵称还在
|
||||
self.assertLess(right, 460) # 时间戳在外
|
||||
|
||||
def test_the_crop_follows_a_narrow_sidebar_instead_of_fixed_pixels(self):
|
||||
# 侧栏宽度是动态识别出来的,写死像素在窄侧栏下会切掉字
|
||||
narrow = self._bot()
|
||||
image = np.zeros((600, 300, 3), dtype=np.uint8)
|
||||
panel = narrow._row_name_panel(image, 78, row_center=True)
|
||||
self.assertEqual(panel.shape[1], int(300 * 0.78) - int(300 * 0.26))
|
||||
|
||||
def test_the_crop_takes_the_name_line_not_the_message_preview(self):
|
||||
bot = self._bot()
|
||||
image = np.zeros((1208, 460, 3), dtype=np.uint8)
|
||||
panel = bot._row_name_panel(image, 200, row_center=True)
|
||||
self.assertLessEqual(panel.shape[0], int(128 * 0.34))
|
||||
|
||||
def test_a_row_too_small_to_hold_a_name_is_refused(self):
|
||||
bot = self._bot(item_h=8)
|
||||
image = np.zeros((40, 20, 3), dtype=np.uint8)
|
||||
self.assertIsNone(bot._row_name_panel(image, 10, row_center=True))
|
||||
|
||||
def test_a_missing_image_never_raises(self):
|
||||
bot = self._bot()
|
||||
self.assertIsNone(bot._row_name_panel(None, 10, row_center=True))
|
||||
|
||||
|
||||
class ChatEdgeRuleTest(TestCase):
|
||||
"""聊天区左边缘那条分隔线,是「对着同一句话连回三遍」的真正根因。"""
|
||||
|
||||
@staticmethod
|
||||
def _grid(rows=36, cols=48):
|
||||
return np.zeros((rows, cols), dtype=bool)
|
||||
|
||||
def _bot(self):
|
||||
from wechat_bot import WeChatBot
|
||||
|
||||
return WeChatBot
|
||||
|
||||
def test_a_full_height_line_at_the_left_edge_is_erased(self):
|
||||
# 会话列表和聊天区之间的分隔线被裁进了聊天区,网格最左一列每行都有内容
|
||||
grid = self._grid()
|
||||
grid[3:, 0] = True
|
||||
cleaned = self._bot()._without_edge_rules(grid)
|
||||
self.assertFalse(cleaned.any())
|
||||
|
||||
def test_the_bottom_most_message_is_found_again_once_the_line_is_gone(self):
|
||||
# 有这条线时,「最底下有内容的行」永远是最后一行;判定区间落在空白处,
|
||||
# 只看见左边那条线,于是任何会话都被判成「最后一条是客户发的」
|
||||
grid = self._grid()
|
||||
grid[3:, 0] = True # 边缘竖线
|
||||
grid[17:21, 26:48] = True # 我方气泡,靠右
|
||||
cleaned = self._bot()._without_edge_rules(grid)
|
||||
bottom = int(np.where(cleaned.any(axis=1))[0][-1])
|
||||
self.assertEqual(bottom, 20)
|
||||
|
||||
def test_a_real_bubble_touching_the_left_edge_is_kept(self):
|
||||
# 客户消息本来就靠左,不能连它一起抹掉
|
||||
grid = self._grid()
|
||||
grid[17:21, 0:22] = True
|
||||
cleaned = self._bot()._without_edge_rules(grid)
|
||||
self.assertTrue(cleaned[17:21, 0].all())
|
||||
|
||||
def test_a_long_message_spanning_the_screen_is_not_mistaken_for_a_rule(self):
|
||||
# 中间列再长也不清;只有贴边且几乎贯穿全高的才算 UI 线条
|
||||
grid = self._grid()
|
||||
grid[:, 20] = True
|
||||
cleaned = self._bot()._without_edge_rules(grid)
|
||||
self.assertTrue(cleaned[:, 20].all())
|
||||
|
||||
def test_a_rule_on_the_right_edge_is_erased_too(self):
|
||||
grid = self._grid()
|
||||
grid[:, -1] = True
|
||||
cleaned = self._bot()._without_edge_rules(grid)
|
||||
self.assertFalse(cleaned.any())
|
||||
|
||||
def test_an_empty_grid_survives_untouched(self):
|
||||
cleaned = self._bot()._without_edge_rules(self._grid())
|
||||
self.assertFalse(cleaned.any())
|
||||
|
||||
def test_a_degenerate_grid_never_raises(self):
|
||||
bot = self._bot()
|
||||
self.assertIsNone(bot._without_edge_rules(None))
|
||||
tiny = np.zeros((2, 4), dtype=bool)
|
||||
self.assertEqual(bot._without_edge_rules(tiny).shape, (2, 4))
|
||||
|
||||
def test_the_original_grid_is_not_mutated(self):
|
||||
grid = self._grid()
|
||||
grid[:, 0] = True
|
||||
self._bot()._without_edge_rules(grid)
|
||||
self.assertTrue(grid[:, 0].all())
|
||||
|
||||
|
||||
class NoRepeatReplyTest(TestCase):
|
||||
"""一条来消息只回一次。现场出过对着同一句「你好」连回三条的事故。"""
|
||||
|
||||
def _bot(self, last_bubble_outgoing, pending=None):
|
||||
from wechat_bot import WeChatBot
|
||||
|
||||
bot = WeChatBot.__new__(WeChatBot)
|
||||
bot._last_visible_bubble_is_outgoing = lambda: last_bubble_outgoing
|
||||
bot._pending_reply_sessions = dict(pending or {})
|
||||
bot._pending_exchanges = {}
|
||||
bot._clear_reply_pending = mock.Mock()
|
||||
bot._commit_staged_exchange = mock.Mock()
|
||||
bot._remember_active_surface = mock.Mock()
|
||||
bot._pending_reply_state = lambda fp: bot._pending_reply_sessions.get(fp.hex())
|
||||
return bot
|
||||
|
||||
FP = bytes.fromhex("ab" * 20)
|
||||
|
||||
def test_our_own_reply_on_screen_stops_another_one(self):
|
||||
# 客户只发了一句「你好」,回执没确认,任务留在队列里 —— 这时候绝不能
|
||||
# 再生成一条措辞不同的问候发出去
|
||||
bot = self._bot(True, {self.FP.hex(): {"staged_reply_text": "你好呀,我在呢"}})
|
||||
with redirect_stdout(io.StringIO()):
|
||||
self.assertTrue(bot._nothing_left_to_answer(self.FP, "你好\n你好呀,我在呢"))
|
||||
|
||||
def test_seeing_our_reply_settles_the_task_instead_of_retrying_forever(self):
|
||||
# 自己写的那句就在屏幕上,发送成功是确凿的:落档、清任务,别再重试
|
||||
bot = self._bot(True, {self.FP.hex(): {"staged_reply_text": "你好呀,我在呢"}})
|
||||
with redirect_stdout(io.StringIO()):
|
||||
bot._nothing_left_to_answer(self.FP, "你好\n你好呀,我在呢")
|
||||
bot._commit_staged_exchange.assert_called_once_with(self.FP.hex())
|
||||
bot._clear_reply_pending.assert_called_once_with(self.FP)
|
||||
|
||||
def test_a_human_reply_also_ends_the_task_without_archiving_our_draft(self):
|
||||
# 右边最后那句不是我们写的,说明人工接手回过了
|
||||
bot = self._bot(True, {self.FP.hex(): {"staged_reply_text": "机器人拟的稿"}})
|
||||
with redirect_stdout(io.StringIO()):
|
||||
self.assertTrue(bot._nothing_left_to_answer(self.FP, "你好\n我是人工,这就帮您看"))
|
||||
bot._commit_staged_exchange.assert_not_called()
|
||||
bot._clear_reply_pending.assert_called_once_with(self.FP)
|
||||
|
||||
def test_a_waiting_customer_still_gets_answered(self):
|
||||
bot = self._bot(False, {self.FP.hex(): {"staged_reply_text": "上一轮的回复"}})
|
||||
self.assertFalse(bot._nothing_left_to_answer(self.FP, "你好"))
|
||||
bot._clear_reply_pending.assert_not_called()
|
||||
|
||||
def test_an_unreadable_bubble_side_never_silences_the_bot(self):
|
||||
# 宽气泡、深色主题判不出左右时宁可多回一句,不能因为看不清就把客户晾着
|
||||
bot = self._bot(None, {self.FP.hex(): {"staged_reply_text": "上一轮的回复"}})
|
||||
self.assertFalse(bot._nothing_left_to_answer(self.FP, "你好"))
|
||||
|
||||
def test_an_idle_chat_with_no_task_is_simply_left_alone(self):
|
||||
bot = self._bot(True)
|
||||
self.assertTrue(bot._nothing_left_to_answer(self.FP, ""))
|
||||
bot._clear_reply_pending.assert_not_called()
|
||||
|
||||
def test_the_guard_survives_a_blank_transcript(self):
|
||||
# 剪贴板没抓到文字时也不能把"我方最后发言"当成要回复
|
||||
bot = self._bot(True, {self.FP.hex(): {"staged_reply_text": "你好呀"}})
|
||||
with redirect_stdout(io.StringIO()):
|
||||
self.assertTrue(bot._nothing_left_to_answer(self.FP, ""))
|
||||
bot._commit_staged_exchange.assert_not_called()
|
||||
bot._clear_reply_pending.assert_called_once_with(self.FP)
|
||||
|
||||
|
||||
class ProgressReportingTest(TestCase):
|
||||
def _bot(self):
|
||||
from wechat_bot import WeChatBot
|
||||
|
||||
bot = WeChatBot.__new__(WeChatBot)
|
||||
bot._progress_text = ""
|
||||
return bot
|
||||
|
||||
def test_the_same_line_is_not_sent_twice(self):
|
||||
# 轮询每秒都在跑,重复内容会把界面刷爆
|
||||
bot = self._bot()
|
||||
seen = []
|
||||
bot.progress_cb = seen.append
|
||||
bot.report_progress("正在回复 高瑞@微信")
|
||||
bot.report_progress("正在回复 高瑞@微信")
|
||||
bot.report_progress("已回复 高瑞@微信")
|
||||
self.assertEqual(seen, ["正在回复 高瑞@微信", "已回复 高瑞@微信"])
|
||||
|
||||
def test_a_broken_callback_never_takes_the_round_down(self):
|
||||
bot = self._bot()
|
||||
|
||||
def explode(_text):
|
||||
raise RuntimeError("界面没了")
|
||||
|
||||
bot.progress_cb = explode
|
||||
bot.report_progress("正在回复 高瑞@微信")
|
||||
|
||||
def test_a_headless_run_reports_to_nobody_without_complaining(self):
|
||||
bot = self._bot()
|
||||
bot.progress_cb = None
|
||||
bot.report_progress("正在回复 高瑞@微信")
|
||||
|
||||
def test_a_partially_built_bot_still_accepts_progress(self):
|
||||
# 不少代码路径用 __new__ 造机器人,绕过了 __init__
|
||||
from wechat_bot import WeChatBot
|
||||
|
||||
bot = WeChatBot.__new__(WeChatBot)
|
||||
seen = []
|
||||
bot.progress_cb = seen.append
|
||||
bot.report_progress("等待新消息")
|
||||
self.assertEqual(seen, ["等待新消息"])
|
||||
|
||||
|
||||
class CorrectionDistanceTest(TestCase):
|
||||
def test_the_distance_helper_stops_early_instead_of_scanning_everything(self):
|
||||
far = session_name._edit_distance("abcdefgh", "zzzzzzzz", 1)
|
||||
self.assertGreater(far, 1)
|
||||
|
||||
def test_identical_strings_are_zero_apart(self):
|
||||
self.assertEqual(session_name._edit_distance("高瑞@微信", "高瑞@微信", 1), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user