@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [
|
||||
str(PROJECT_ROOT / "packages" / "jwebot-config" / "src"),
|
||||
str(PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"),
|
||||
str(PROJECT_ROOT / "packages" / "jwebot-official-extensions" / "src"),
|
||||
str(PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"),
|
||||
]
|
||||
|
||||
from jwebot_kernel.conversation_scope import ( # noqa: E402
|
||||
ConversationConfigError,
|
||||
ConversationKind,
|
||||
ConversationTarget,
|
||||
ShadowConversationRegistry,
|
||||
build_shadow_event,
|
||||
)
|
||||
|
||||
|
||||
def fingerprint(label: str) -> str:
|
||||
return hashlib.sha256(label.encode()).hexdigest()
|
||||
|
||||
|
||||
class ConversationTargetTests(unittest.TestCase):
|
||||
def test_group_requires_an_explicit_mention(self):
|
||||
target = ConversationTarget(
|
||||
key="main-group",
|
||||
kind=ConversationKind.GROUP,
|
||||
title_fingerprint=fingerprint("main-group"),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
target.question("@JWeBot hello", bot_mention="JWeBot"),
|
||||
"hello",
|
||||
)
|
||||
self.assertEqual(target.question("hello", bot_mention="JWeBot"), "")
|
||||
|
||||
def test_direct_chat_accepts_only_commands_or_wake_words(self):
|
||||
target = ConversationTarget(
|
||||
key="test-direct",
|
||||
kind=ConversationKind.DIRECT,
|
||||
title_fingerprint=fingerprint("test-direct"),
|
||||
sender="Test User",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
target.question("/状态", bot_mention="JWeBot"),
|
||||
"/状态",
|
||||
)
|
||||
self.assertEqual(
|
||||
target.question("JWeBot: hello", bot_mention="JWeBot"),
|
||||
"hello",
|
||||
)
|
||||
self.assertEqual(target.question("普通聊天", bot_mention="JWeBot"), "")
|
||||
|
||||
def test_direct_chat_requires_a_configured_sender(self):
|
||||
with self.assertRaisesRegex(ConversationConfigError, "direct sender"):
|
||||
ConversationTarget(
|
||||
key="test-direct",
|
||||
kind=ConversationKind.DIRECT,
|
||||
title_fingerprint=fingerprint("test-direct"),
|
||||
)
|
||||
|
||||
def test_title_identity_must_be_a_sha256_digest(self):
|
||||
with self.assertRaisesRegex(ConversationConfigError, "SHA-256"):
|
||||
ConversationTarget(
|
||||
key="test-direct",
|
||||
kind=ConversationKind.DIRECT,
|
||||
title_fingerprint="visible-contact-name",
|
||||
sender="Test User",
|
||||
)
|
||||
|
||||
|
||||
class ShadowRegistryTests(unittest.TestCase):
|
||||
def test_registry_is_disabled_without_an_explicit_path(self):
|
||||
registry = ShadowConversationRegistry.from_environment({})
|
||||
|
||||
self.assertFalse(registry.enabled)
|
||||
self.assertEqual(registry.targets, ())
|
||||
|
||||
def test_disabled_registry_never_matches_targets(self):
|
||||
digest = fingerprint("test-direct")
|
||||
registry = ShadowConversationRegistry.from_mapping(
|
||||
{
|
||||
"format_version": 1,
|
||||
"enabled": False,
|
||||
"targets": [
|
||||
{
|
||||
"key": "test-direct",
|
||||
"kind": "direct",
|
||||
"title_fingerprint": digest,
|
||||
"sender": "Test User",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertIsNone(registry.target_for_fingerprint(digest))
|
||||
|
||||
def test_explicit_config_loads_only_shadow_targets(self):
|
||||
digest = fingerprint("test-direct")
|
||||
config = {
|
||||
"format_version": 1,
|
||||
"enabled": True,
|
||||
"targets": [
|
||||
{
|
||||
"key": "test-direct",
|
||||
"kind": "direct",
|
||||
"title_fingerprint": digest,
|
||||
"sender": "Test User",
|
||||
}
|
||||
],
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "shadow.json"
|
||||
path.write_text(json.dumps(config), encoding="utf-8")
|
||||
registry = ShadowConversationRegistry.from_environment(
|
||||
{"JWEBOT_CONVERSATION_SHADOW_CONFIG": str(path)}
|
||||
)
|
||||
|
||||
self.assertTrue(registry.enabled)
|
||||
self.assertEqual(registry.target_for_fingerprint(digest), registry.targets[0])
|
||||
|
||||
def test_duplicate_title_fingerprints_are_rejected(self):
|
||||
digest = fingerprint("duplicate")
|
||||
with self.assertRaisesRegex(ConversationConfigError, "fingerprints"):
|
||||
ShadowConversationRegistry.from_mapping(
|
||||
{
|
||||
"format_version": 1,
|
||||
"enabled": True,
|
||||
"targets": [
|
||||
{
|
||||
"key": "direct-a",
|
||||
"kind": "direct",
|
||||
"title_fingerprint": digest,
|
||||
"sender": "Test A",
|
||||
},
|
||||
{
|
||||
"key": "direct-b",
|
||||
"kind": "direct",
|
||||
"title_fingerprint": digest,
|
||||
"sender": "Test B",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
def test_group_targets_are_rejected_in_private_shadow_phase(self):
|
||||
with self.assertRaisesRegex(ConversationConfigError, "direct targets only"):
|
||||
ShadowConversationRegistry.from_mapping(
|
||||
{
|
||||
"format_version": 1,
|
||||
"enabled": True,
|
||||
"targets": [
|
||||
{
|
||||
"key": "main-group",
|
||||
"kind": "group",
|
||||
"title_fingerprint": fingerprint("main-group"),
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
def test_unknown_delivery_field_is_rejected(self):
|
||||
with self.assertRaisesRegex(ConversationConfigError, "unknown fields"):
|
||||
ShadowConversationRegistry.from_mapping(
|
||||
{
|
||||
"format_version": 1,
|
||||
"enabled": True,
|
||||
"delivery_enabled": True,
|
||||
"targets": [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ShadowEventTests(unittest.TestCase):
|
||||
def test_direct_event_uses_verified_conversation_identity(self):
|
||||
digest = fingerprint("test-direct")
|
||||
target = ConversationTarget(
|
||||
key="test-direct",
|
||||
kind=ConversationKind.DIRECT,
|
||||
title_fingerprint=digest,
|
||||
sender="Test User",
|
||||
)
|
||||
|
||||
event = build_shadow_event(
|
||||
target,
|
||||
observed_at="2026-10-20T12:00:00+08:00",
|
||||
text="/状态",
|
||||
body_confidence=0.98,
|
||||
visual_signature="visual-1",
|
||||
bot_mention="JWeBot",
|
||||
)
|
||||
|
||||
self.assertEqual(event["sender"], "Test User")
|
||||
self.assertEqual(event["sender_confidence"], 1.0)
|
||||
self.assertEqual(event["question"], "/状态")
|
||||
self.assertTrue(event["mention_bot"])
|
||||
self.assertEqual(event["group_fingerprint"], digest)
|
||||
self.assertEqual(event["conversation_kind"], "direct")
|
||||
self.assertTrue(event["shadow_only"])
|
||||
self.assertFalse(event["dispatch_allowed"])
|
||||
|
||||
def test_unaddressed_direct_message_is_observed_but_not_triggered(self):
|
||||
target = ConversationTarget(
|
||||
key="test-direct",
|
||||
kind=ConversationKind.DIRECT,
|
||||
title_fingerprint=fingerprint("test-direct"),
|
||||
sender="Test User",
|
||||
)
|
||||
|
||||
event = build_shadow_event(
|
||||
target,
|
||||
observed_at="2026-10-20T12:00:00+08:00",
|
||||
text="普通聊天",
|
||||
body_confidence=0.98,
|
||||
visual_signature="visual-2",
|
||||
bot_mention="JWeBot",
|
||||
)
|
||||
|
||||
self.assertEqual(event["question"], "")
|
||||
self.assertFalse(event["mention_bot"])
|
||||
self.assertTrue(event["shadow_only"])
|
||||
self.assertFalse(event["dispatch_allowed"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -316,6 +316,48 @@ class LedgerAndApplicationTests(unittest.TestCase):
|
||||
"第二条回复喵",
|
||||
)
|
||||
|
||||
def test_conversation_context_is_isolated_by_fingerprint(self):
|
||||
observed = {}
|
||||
|
||||
def complete(**kwargs):
|
||||
observed[kwargs["event_id"]] = list(kwargs["context"])
|
||||
return f"{kwargs['event_id']} 回复喵"
|
||||
|
||||
self.application.deepseek.complete = complete
|
||||
self.application.deliver = mock.Mock()
|
||||
for event_id, group, text in (
|
||||
("group-first", "group-a", "@JWeBot 群里的第一条"),
|
||||
("direct-first", "direct-a", "私聊里的第一条"),
|
||||
("group-second", "group-a", "@JWeBot 群里继续"),
|
||||
("direct-second", "direct-a", "私聊继续"),
|
||||
):
|
||||
self.application.accept(event(event_id, text, group=group))
|
||||
self.application.process_direct_chat_once()
|
||||
|
||||
self.assertEqual(
|
||||
observed["group-second"],
|
||||
[("群里的第一条", "group-first 回复喵")],
|
||||
)
|
||||
self.assertEqual(
|
||||
observed["direct-second"],
|
||||
[("私聊里的第一条", "direct-first 回复喵")],
|
||||
)
|
||||
|
||||
def test_shadow_event_is_rejected_before_routing_or_persistence(self):
|
||||
response = self.application.accept(
|
||||
{
|
||||
**event("shadow-1", "/状态", group="direct-a"),
|
||||
"shadow_only": True,
|
||||
"dispatch_allowed": False,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
response,
|
||||
{"status": "ignored", "reason": "shadow_only"},
|
||||
)
|
||||
self.assertIsNone(self.application.ledger.row("shadow-1"))
|
||||
|
||||
def test_local_game_command_result_is_available_to_followup_chat(self):
|
||||
observed_context = []
|
||||
|
||||
@@ -1039,9 +1081,9 @@ class LedgerAndApplicationTests(unittest.TestCase):
|
||||
self.assertEqual(version["route"], "local")
|
||||
self.assertIn("JWeBot 版本", version["text"])
|
||||
self.assertIn("kernel", version["text"])
|
||||
self.assertIn("最新版本:beta 1.2.8.1019", version["text"])
|
||||
self.assertIn("更新时间:2026-10-19 22:49:30", version["text"])
|
||||
self.assertIn("更新摘要:冷门知识问答题库整体重写", version["text"])
|
||||
self.assertIn("最新版本:beta 2.0.0.1021", version["text"])
|
||||
self.assertIn("更新时间:2026-10-21 16:57:39", version["text"])
|
||||
self.assertIn("更新摘要:Beta 2.0 架构更新完成", version["text"])
|
||||
self.assertEqual(debug["route"], "local")
|
||||
self.assertIn("调试埋点", debug["text"])
|
||||
self.assertEqual(balance["route"], "local")
|
||||
|
||||
@@ -20,7 +20,7 @@ SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class ReleaseUpdateTests(unittest.TestCase):
|
||||
release = "beta 1.2.8.1019"
|
||||
release = "beta 2.0.0.1021"
|
||||
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
@@ -111,8 +111,7 @@ class ReleaseUpdateTests(unittest.TestCase):
|
||||
self.assertEqual(summary, "第一行更新摘要, 同一条摘要可以换行。")
|
||||
announcement = MODULE.build_announcement(self.release, summary)
|
||||
self.assertIn(f"{self.release} 更新现已完成", announcement)
|
||||
self.assertIn("更新补偿:摩拉 x200", announcement)
|
||||
self.assertNotIn("原石 x1", announcement)
|
||||
self.assertIn("更新补偿:摩拉 x400,原石 x2", announcement)
|
||||
self.assertNotIn("第二条", announcement)
|
||||
|
||||
def test_policy_release_announces_primogem(self):
|
||||
@@ -121,10 +120,17 @@ class ReleaseUpdateTests(unittest.TestCase):
|
||||
"beta 1.1.0.1016",
|
||||
"beta 1.2.0.1019",
|
||||
):
|
||||
self.assertEqual(MODULE.mora_compensation_amount(release), 200)
|
||||
self.assertEqual(MODULE.primogem_compensation_amount(release), 1)
|
||||
announcement = MODULE.build_announcement(release, "原石系统上线。")
|
||||
self.assertIn("更新补偿:摩拉 x200,原石 x1", announcement)
|
||||
|
||||
def test_major_release_announces_larger_compensation(self):
|
||||
self.assertEqual(MODULE.mora_compensation_amount(self.release), 400)
|
||||
self.assertEqual(MODULE.primogem_compensation_amount(self.release), 2)
|
||||
announcement = MODULE.build_announcement(self.release, "架构更新完成。")
|
||||
self.assertIn("更新补偿:摩拉 x400,原石 x2", announcement)
|
||||
|
||||
def test_patch_release_without_policy_does_not_announce_primogem(self):
|
||||
release = "beta 1.1.2.1019"
|
||||
self.assertEqual(MODULE.primogem_compensation_amount(release), 0)
|
||||
@@ -149,8 +155,13 @@ class ReleaseUpdateTests(unittest.TestCase):
|
||||
self.assertEqual(result["stage"], "shutdown")
|
||||
self.assertEqual(result["delivered"], 1)
|
||||
self.assertEqual(delivered[0][1], "group-a")
|
||||
self.assertIn("准备停机更新", delivered[0][2])
|
||||
self.assertIn(self.release, delivered[0][2])
|
||||
self.assertEqual(
|
||||
delivered[0][2],
|
||||
(
|
||||
"Taffy 准备停机更新,本次更新是架构级大版本更新,"
|
||||
f"预期版本{self.release},离线的时候保持耐心迎接Taffy的归来哦~"
|
||||
),
|
||||
)
|
||||
self.assertIn("shutdown", delivered[0][3])
|
||||
self.assertEqual([state["balance"] for state in self.account_states()], [1000, 50])
|
||||
|
||||
@@ -186,13 +197,13 @@ class ReleaseUpdateTests(unittest.TestCase):
|
||||
self.assertEqual(len(delivered), 1)
|
||||
self.assertEqual(delivered[0][1], "group-a")
|
||||
self.assertIn("更新摩拉账户并修复重复回复问题", delivered[0][2])
|
||||
self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250])
|
||||
self.assertEqual([state["balance"] for state in self.account_states()], [1400, 450])
|
||||
for state in self.account_states():
|
||||
self.assertEqual(state["primogems"], 0)
|
||||
self.assertEqual(state["primogems"], 2)
|
||||
matches = [
|
||||
event
|
||||
for event in state["coin_events"]
|
||||
if event["delta"] == 200
|
||||
if event["delta"] == 400
|
||||
and event["reason"] == f"{self.release} 版本更新补偿"
|
||||
]
|
||||
self.assertEqual(len(matches), 1)
|
||||
@@ -202,7 +213,12 @@ class ReleaseUpdateTests(unittest.TestCase):
|
||||
for event in state["primogem_events"]
|
||||
if event["reason"] == f"{self.release} 版本更新补偿"
|
||||
]
|
||||
self.assertEqual(primogem_matches, [])
|
||||
self.assertEqual(len(primogem_matches), 1)
|
||||
self.assertEqual(primogem_matches[0]["delta"], 2)
|
||||
self.assertEqual(
|
||||
primogem_matches[0]["created_at"],
|
||||
applied_at.timestamp(),
|
||||
)
|
||||
self.assertEqual(self.announcement_marker()["state"], "delivered")
|
||||
self.assertEqual(len(list(self.backups.glob("*.sqlite3"))), 1)
|
||||
|
||||
@@ -219,8 +235,8 @@ class ReleaseUpdateTests(unittest.TestCase):
|
||||
delivery_helper=self.delivery_helper,
|
||||
deliverer=uncertain,
|
||||
)
|
||||
self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250])
|
||||
self.assertEqual([state["primogems"] for state in self.account_states()], [0, 0])
|
||||
self.assertEqual([state["balance"] for state in self.account_states()], [1400, 450])
|
||||
self.assertEqual([state["primogems"] for state in self.account_states()], [2, 2])
|
||||
self.assertEqual(self.announcement_marker()["state"], "uncertain")
|
||||
|
||||
called = []
|
||||
@@ -250,8 +266,8 @@ class ReleaseUpdateTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result["compensation"], "already_applied")
|
||||
self.assertEqual(len(called), 1)
|
||||
self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250])
|
||||
self.assertEqual([state["primogems"] for state in self.account_states()], [0, 0])
|
||||
self.assertEqual([state["balance"] for state in self.account_states()], [1400, 450])
|
||||
self.assertEqual([state["primogems"] for state in self.account_states()], [2, 2])
|
||||
self.assertEqual(self.announcement_marker()["state"], "delivered")
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from jwebot_sdk import ( # noqa: E402
|
||||
Fact,
|
||||
PlanKind,
|
||||
ReplyPlan,
|
||||
ServiceCommandAdapter,
|
||||
)
|
||||
|
||||
|
||||
@@ -108,5 +109,70 @@ class PlanContractTests(unittest.TestCase):
|
||||
ExtensionResult.from_mapping(value)
|
||||
|
||||
|
||||
class ServiceCommandAdapterTests(unittest.TestCase):
|
||||
def event(self):
|
||||
return Event(
|
||||
event_id="event-1",
|
||||
observed_at="2026-10-14T10:00:00+08:00",
|
||||
sender="Alice",
|
||||
text="@JWeBot /coin",
|
||||
group_fingerprint="group-a",
|
||||
mention_bot=True,
|
||||
)
|
||||
|
||||
def context(self, response):
|
||||
class Commands:
|
||||
def __init__(self):
|
||||
self.handlers = {}
|
||||
|
||||
def register(self, command, handler):
|
||||
self.handlers[command] = handler
|
||||
|
||||
class Services:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def call(self, profile, *, operation, payload):
|
||||
self.calls.append((profile, operation, payload))
|
||||
return response
|
||||
|
||||
class Context:
|
||||
pass
|
||||
|
||||
context = Context()
|
||||
context.commands = Commands()
|
||||
context.services = Services()
|
||||
return context
|
||||
|
||||
def test_adapter_delegates_to_declared_service_profile(self):
|
||||
context = self.context({"texts": ["第一条", "第二条"]})
|
||||
adapter = ServiceCommandAdapter(
|
||||
"coin",
|
||||
metadata={"local_extension": "coin"},
|
||||
)
|
||||
|
||||
adapter.register(context, ["/翻硬币"])
|
||||
result = context.commands.handlers["/翻硬币"](self.event(), "")
|
||||
|
||||
self.assertEqual(result.reply.kind, PlanKind.MULTI_TEXT)
|
||||
self.assertEqual(result.reply.messages, ("第一条", "第二条"))
|
||||
self.assertEqual(dict(result.metadata), {"local_extension": "coin"})
|
||||
self.assertEqual(context.services.calls[0][0], "coin")
|
||||
self.assertEqual(context.services.calls[0][1], "execute")
|
||||
self.assertEqual(context.services.calls[0][2]["command"], "/翻硬币")
|
||||
self.assertEqual(
|
||||
context.services.calls[0][2]["event"]["event_id"],
|
||||
"event-1",
|
||||
)
|
||||
|
||||
def test_adapter_rejects_invalid_service_text(self):
|
||||
context = self.context({"texts": ["ok", ""]})
|
||||
adapter = ServiceCommandAdapter("coin")
|
||||
|
||||
adapter.register(context, ["/翻硬币"])
|
||||
with self.assertRaisesRegex(ValueError, "invalid text parts"):
|
||||
context.commands.handlers["/翻硬币"](self.event(), "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -931,6 +931,263 @@ class RecorderSignatureMigrationTests(unittest.TestCase):
|
||||
engine.warmup.assert_called_once()
|
||||
|
||||
|
||||
class RecorderConversationStateTests(unittest.TestCase):
|
||||
def test_legacy_active_state_migrates_only_to_its_matching_conversation(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_group_fingerprint": "group-a",
|
||||
"last_visual_hash": "visual-a",
|
||||
"visible_signatures": ["message-a"],
|
||||
"recent_trigger_signatures": ["trigger-a"],
|
||||
"message_signature_scheme": MODULE.MESSAGE_SIGNATURE_SCHEME,
|
||||
},
|
||||
):
|
||||
recorder = MODULE.Recorder(queue_enabled=True)
|
||||
|
||||
group_a = recorder.conversation_state("group-a")
|
||||
group_b = recorder.conversation_state("group-b")
|
||||
|
||||
self.assertTrue(group_a["initialized"])
|
||||
self.assertEqual(group_a["visible_signatures"], ["message-a"])
|
||||
self.assertFalse(group_b["initialized"])
|
||||
self.assertEqual(group_b["visible_signatures"], [])
|
||||
|
||||
def test_switching_conversations_does_not_cross_deduplicate_messages(self):
|
||||
engine = mock.Mock()
|
||||
first_a = MODULE.IncomingMessage(
|
||||
"Alice",
|
||||
"@JWeBot first",
|
||||
0.98,
|
||||
0.99,
|
||||
0.98,
|
||||
100,
|
||||
"a-first",
|
||||
)
|
||||
first_b = MODULE.IncomingMessage(
|
||||
"Bob",
|
||||
"@JWeBot first",
|
||||
0.98,
|
||||
0.99,
|
||||
0.98,
|
||||
100,
|
||||
"b-first",
|
||||
)
|
||||
second_a = MODULE.IncomingMessage(
|
||||
"Alice",
|
||||
"@JWeBot second",
|
||||
0.98,
|
||||
0.99,
|
||||
0.98,
|
||||
160,
|
||||
"a-second",
|
||||
)
|
||||
engine.recognize_messages.side_effect = [
|
||||
([first_a], 0.01),
|
||||
([first_b], 0.01),
|
||||
([first_a, second_a], 0.01),
|
||||
]
|
||||
engine.last_cache_hits = 0
|
||||
engine.last_cache_misses = 1
|
||||
engine.last_cache_refresh = False
|
||||
queued = []
|
||||
image = MODULE.Image.new(
|
||||
"RGB",
|
||||
(MODULE.EXPECTED_WIDTH, MODULE.EXPECTED_HEIGHT),
|
||||
"white",
|
||||
)
|
||||
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"ensure_directories",
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"OcrEngine",
|
||||
return_value=engine,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"read_json",
|
||||
return_value={},
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"incoming_mask_and_bubbles",
|
||||
return_value=(None, []),
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"write_history_event",
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"queue_event",
|
||||
side_effect=queued.append,
|
||||
):
|
||||
recorder = MODULE.Recorder(queue_enabled=True)
|
||||
recorder.save_state = mock.Mock()
|
||||
recorder.process_stable_image(
|
||||
image,
|
||||
"layout-a-1",
|
||||
visual_hash="visual-a-1",
|
||||
observed_at="2026-10-20T12:00:00+08:00",
|
||||
group_fingerprint="group-a",
|
||||
)
|
||||
recorder.process_stable_image(
|
||||
image,
|
||||
"layout-b-1",
|
||||
visual_hash="visual-b-1",
|
||||
observed_at="2026-10-20T12:00:01+08:00",
|
||||
group_fingerprint="group-b",
|
||||
)
|
||||
recorder.process_stable_image(
|
||||
image,
|
||||
"layout-a-2",
|
||||
visual_hash="visual-a-2",
|
||||
observed_at="2026-10-20T12:00:02+08:00",
|
||||
group_fingerprint="group-a",
|
||||
)
|
||||
|
||||
self.assertEqual(len(queued), 1)
|
||||
self.assertEqual(queued[0]["group_fingerprint"], "group-a")
|
||||
self.assertEqual(queued[0]["text"], "@JWeBot second")
|
||||
states = recorder.state["conversation_states"]
|
||||
self.assertEqual(states["group-a"]["visible_signatures"], ["a-first", "a-second"])
|
||||
self.assertEqual(states["group-b"]["visible_signatures"], ["b-first"])
|
||||
|
||||
def test_shadow_direct_messages_are_observed_without_queueing(self):
|
||||
class ShadowTarget:
|
||||
def question(self, text, *, bot_mention):
|
||||
del bot_mention
|
||||
return text if text.startswith("/") else ""
|
||||
|
||||
class ShadowRegistry:
|
||||
def target_for_fingerprint(self, fingerprint):
|
||||
return ShadowTarget() if fingerprint == "direct-a" else None
|
||||
|
||||
def build_shadow_event(
|
||||
target,
|
||||
*,
|
||||
observed_at,
|
||||
text,
|
||||
body_confidence,
|
||||
visual_signature,
|
||||
bot_mention,
|
||||
observed_sender,
|
||||
sender_confidence,
|
||||
ordinal,
|
||||
):
|
||||
del (
|
||||
target,
|
||||
body_confidence,
|
||||
visual_signature,
|
||||
bot_mention,
|
||||
observed_sender,
|
||||
sender_confidence,
|
||||
ordinal,
|
||||
)
|
||||
return {
|
||||
"event_id": "shadow-event",
|
||||
"observed_at": observed_at,
|
||||
"sender": "Test User",
|
||||
"text": text,
|
||||
"mention_bot": True,
|
||||
"group_fingerprint": "direct-a",
|
||||
"shadow_only": True,
|
||||
"dispatch_allowed": False,
|
||||
}
|
||||
|
||||
engine = mock.Mock()
|
||||
first = MODULE.IncomingMessage(
|
||||
"unknown",
|
||||
"/状态",
|
||||
0.0,
|
||||
0.0,
|
||||
0.98,
|
||||
100,
|
||||
"direct-first",
|
||||
)
|
||||
second = MODULE.IncomingMessage(
|
||||
"unknown",
|
||||
"/版本",
|
||||
0.0,
|
||||
0.0,
|
||||
0.98,
|
||||
160,
|
||||
"direct-second",
|
||||
)
|
||||
engine.recognize_messages.side_effect = [
|
||||
([first], 0.01),
|
||||
([first, second], 0.01),
|
||||
]
|
||||
engine.last_cache_hits = 0
|
||||
engine.last_cache_misses = 1
|
||||
engine.last_cache_refresh = False
|
||||
history = []
|
||||
image = MODULE.Image.new(
|
||||
"RGB",
|
||||
(MODULE.EXPECTED_WIDTH, MODULE.EXPECTED_HEIGHT),
|
||||
"white",
|
||||
)
|
||||
|
||||
with mock.patch.object(
|
||||
MODULE,
|
||||
"ensure_directories",
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"OcrEngine",
|
||||
return_value=engine,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"read_json",
|
||||
return_value={},
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"load_conversation_shadow_support",
|
||||
return_value=(ShadowRegistry(), build_shadow_event),
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"incoming_mask_and_bubbles",
|
||||
return_value=(None, []),
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"write_history_event",
|
||||
side_effect=history.append,
|
||||
), mock.patch.object(
|
||||
MODULE,
|
||||
"queue_event",
|
||||
) as queue_event:
|
||||
recorder = MODULE.Recorder(queue_enabled=True)
|
||||
recorder.save_state = mock.Mock()
|
||||
recorder.process_stable_image(
|
||||
image,
|
||||
"layout-direct-1",
|
||||
visual_hash="visual-direct-1",
|
||||
observed_at="2026-10-20T12:00:00+08:00",
|
||||
group_fingerprint="direct-a",
|
||||
)
|
||||
recorder.process_stable_image(
|
||||
image,
|
||||
"layout-direct-2",
|
||||
visual_hash="visual-direct-2",
|
||||
observed_at="2026-10-20T12:00:01+08:00",
|
||||
group_fingerprint="direct-a",
|
||||
)
|
||||
|
||||
queue_event.assert_not_called()
|
||||
self.assertEqual(len(history), 1)
|
||||
self.assertEqual(history[0]["text"], "/版本")
|
||||
self.assertTrue(history[0]["shadow_only"])
|
||||
self.assertFalse(history[0]["dispatch_allowed"])
|
||||
self.assertEqual(recorder.state["shadowed_total"], 1)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user