179 lines
6.3 KiB
Python
179 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
|
sys.path.insert(0, str(SDK_SRC))
|
|
|
|
from jwebot_sdk import ( # noqa: E402
|
|
ContractViolation,
|
|
Event,
|
|
ExtensionResult,
|
|
Fact,
|
|
PlanKind,
|
|
ReplyPlan,
|
|
ServiceCommandAdapter,
|
|
)
|
|
|
|
|
|
class EventContractTests(unittest.TestCase):
|
|
def event(self, **overrides):
|
|
value = {
|
|
"version": 1,
|
|
"event_id": "event-1",
|
|
"observed_at": "2026-10-14T10:00:00+08:00",
|
|
"sender": "Alice",
|
|
"text": "@JWeBot hello",
|
|
"confidence": 0.95,
|
|
"sender_confidence": 0.96,
|
|
"body_confidence": 0.97,
|
|
"side": "incoming",
|
|
"mention_bot": True,
|
|
"group_fingerprint": "group-a",
|
|
"source": "rapidocr-local",
|
|
}
|
|
value.update(overrides)
|
|
return value
|
|
|
|
def test_event_round_trip_preserves_normalized_fields(self):
|
|
event = Event.from_mapping(self.event())
|
|
self.assertEqual(event.to_mapping(), self.event())
|
|
|
|
def test_event_rejects_outgoing_messages(self):
|
|
with self.assertRaisesRegex(ContractViolation, "incoming"):
|
|
Event.from_mapping(self.event(side="outgoing"))
|
|
|
|
def test_event_rejects_out_of_range_confidence(self):
|
|
with self.assertRaisesRegex(ContractViolation, "between 0 and 1"):
|
|
Event.from_mapping(self.event(confidence=1.1))
|
|
|
|
def test_event_rejects_unsupported_version(self):
|
|
with self.assertRaisesRegex(ContractViolation, "unsupported event version"):
|
|
Event.from_mapping(self.event(version=2))
|
|
|
|
|
|
class PlanContractTests(unittest.TestCase):
|
|
def test_text_plan_has_a_bounded_serialized_shape(self):
|
|
plan = ReplyPlan.text("hello")
|
|
self.assertEqual(plan.kind, PlanKind.TEXT)
|
|
self.assertEqual(plan.to_mapping(), {"kind": "text", "text": "hello"})
|
|
|
|
def test_multi_text_plan_has_bounded_parts(self):
|
|
plan = ReplyPlan.multi_text(["one", "two"])
|
|
self.assertEqual(plan.kind, PlanKind.MULTI_TEXT)
|
|
self.assertEqual(
|
|
plan.to_mapping(),
|
|
{"kind": "multi_text", "texts": ["one", "two"]},
|
|
)
|
|
restored = ReplyPlan.from_mapping(plan.to_mapping())
|
|
self.assertEqual(restored, plan)
|
|
|
|
def test_no_reply_plan_cannot_contain_text(self):
|
|
with self.assertRaisesRegex(ContractViolation, "cannot contain text"):
|
|
ReplyPlan(kind=PlanKind.NO_REPLY, message="unexpected")
|
|
|
|
def test_result_freezes_metadata_and_serializes_facts(self):
|
|
result = ExtensionResult(
|
|
reply=ReplyPlan.text("hello"),
|
|
facts=(Fact(kind="topic", text="likes servers", confidence=0.8),),
|
|
metadata={"source": "test"},
|
|
)
|
|
self.assertEqual(result.to_mapping()["metadata"], {"source": "test"})
|
|
with self.assertRaises(TypeError):
|
|
result.metadata["source"] = "changed" # type: ignore[index]
|
|
|
|
def test_result_rejects_unbounded_fact_count(self):
|
|
facts = tuple(Fact(kind="topic", text=f"fact-{index}") for index in range(13))
|
|
with self.assertRaisesRegex(ContractViolation, "exceeds 12 facts"):
|
|
ExtensionResult(facts=facts)
|
|
|
|
def test_result_round_trip_preserves_reply_facts_and_metadata(self):
|
|
result = ExtensionResult(
|
|
reply=ReplyPlan.text("hello"),
|
|
facts=(Fact(kind="topic", text="likes servers", confidence=0.8),),
|
|
metadata={"source": "test"},
|
|
)
|
|
restored = ExtensionResult.from_mapping(result.to_mapping())
|
|
self.assertEqual(restored.reply, result.reply)
|
|
self.assertEqual(restored.facts, result.facts)
|
|
self.assertEqual(dict(restored.metadata), dict(result.metadata))
|
|
|
|
def test_result_rejects_wrong_api_version(self):
|
|
value = ExtensionResult(reply=ReplyPlan.text("hello")).to_mapping()
|
|
value["api_version"] = "2"
|
|
with self.assertRaisesRegex(ContractViolation, "API version"):
|
|
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()
|