Release beta 2.0.0.1021
check / local-gate (push) Failing after 1m38s

This commit is contained in:
JWeBot Contributors
2026-10-21 17:05:09 +08:00
parent 5a3c4fb94c
commit e4365841ed
79 changed files with 1572 additions and 1840 deletions
+236
View File
@@ -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()
+45 -3
View File
@@ -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")
+29 -13
View File
@@ -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")
+66
View File
@@ -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()
+257
View File
@@ -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)