Browse Source

Pin the presence coercion to a dict and the test to the wire shape

The presence fix mapped the proto enum name by scanning PresenceStatus for
every event, and its regression test mocked a payload whose status was a raw
int on a MagicMock — which still left the older presence tests mocking a
StrEnum, a shape the wire never sends.

Build the name→member map once at module level, so a presence change costs a
dict lookup rather than a linear scan of a five-member enum on a hot path.
The regression test now drives a real PresenceChangedEvent inside a real
RealtimeEvent, matching the AGENTS.md rule that tests exercise real library
types rather than mocks shaped like them: the int-shaped mock passed for the
same reason the StrEnum mock hid the bug, because both agreed on a contract
the wire does not have.
Paul Klumpp 6 days ago
parent
commit
293c099641
2 changed files with 37 additions and 19 deletions
  1. 14 12
      adapter.py
  2. 23 7
      test_adapter.py

+ 14 - 12
adapter.py

@@ -308,6 +308,13 @@ _PRESENCE_LABELS = {
     PresenceStatus.OFFLINE: "offline",
 }
 
+# Proto enum name (``PRESENCE_STATUS_ONLINE``) → chattolib member, built once
+# from the enum's own values. The realtime wire carries the presence status as
+# a plain int (protobuf runtime without native enum wrappers), so every
+# presence change needs this lookup — a linear scan per event would be O(n) on
+# a hot path for a five-member enum.
+_PRESENCE_BY_PROTO_NAME = {member.value: member for member in PresenceStatus}
+
 
 @dataclass(frozen=True)
 class _Mentions:
@@ -2668,23 +2675,18 @@ class ChattoAdapter(BasePlatformAdapter):
                 and (user := self._user_cache.get(changed_user_id)) is not None
             ):
                 # The realtime payload carries the proto enum as a plain int
-                # (or proto enum wrapper); chattolib's own bot.py coerces the
-                # same field the same way — read the wire shape, never
-                # ``.name`` directly, which raised AttributeError on every
-                # presence change and dropped inbound messages inside the
-                # reconnect gaps that followed.
+                # (protobuf runtime without native enum wrappers); chattolib's
+                # own bot.py coerces the same field the same way. Read the
+                # wire shape, never ``.name`` directly, which raised
+                # AttributeError on every presence change and dropped inbound
+                # messages inside the reconnect gaps that followed.
                 raw_status = event.payload.status
                 if isinstance(raw_status, PresenceStatus):
                     status = raw_status
                 else:
                     proto_name = ProtoPresenceStatus.Name(int(raw_status))
-                    status = next(
-                        (
-                            member
-                            for member in PresenceStatus
-                            if member.value == proto_name
-                        ),
-                        PresenceStatus.UNSPECIFIED,
+                    status = _PRESENCE_BY_PROTO_NAME.get(
+                        proto_name, PresenceStatus.UNSPECIFIED
                     )
                 user.presence_status = status
                 logger.debug(

+ 23 - 7
test_adapter.py

@@ -40,9 +40,13 @@ from vendor_path import setup_vendor_path
 
 setup_vendor_path()
 
-from chattolib._pb.chatto.realtime.v1.events_pb2 import MessageMention
+from chattolib._pb.chatto.realtime.v1.events_pb2 import (
+    MessageMention,
+    PresenceChangedEvent,
+)
 from chattolib.client import ChattoClient
 from chattolib.exceptions import ChattoConnectError, ChattoError
+from chattolib.realtime import RealtimeEvent
 from chattolib.types import (
     Asset,
     AssetUpload,
@@ -1278,10 +1282,24 @@ class TestReactionForwarding:
         """The wire carries the proto enum as a plain int (protobuf runtime
         without native enums), so ``status`` has no ``.name`` attribute. A
         ``.name`` read raised AttributeError on every presence change, and
-        each one cost a 30s reconnect gap that swallowed inbound messages."""
-        event = self._event("presence_changed", actor_id="human-1")
-        event.payload = MagicMock(spec=["status"])
-        event.payload.status = 1  # PRESENCE_STATUS_ONLINE as a raw int
+        each one cost a 30s reconnect gap that swallowed inbound messages.
+
+        Both envelope and payload are real chattolib types here, not mocks
+        shaped like them: the earlier presence tests mocked ``status`` as a
+        StrEnum, which carries ``.name`` and so exercised a shape the wire
+        never sends.
+        """
+        payload = PresenceChangedEvent()
+        payload.status = 1  # PRESENCE_STATUS_ONLINE as a raw int
+        event = RealtimeEvent(
+            id="evt-presence-human-1",
+            created_at=None,
+            actor_id="human-1",
+            cursor=None,
+            kind="presence_changed",
+            payload=payload,
+            raw=None,
+        )
         adapter._user_cache["human-1"] = _make_user("human-1", "human")
 
         await adapter._handle_realtime_event(event)
@@ -1399,8 +1417,6 @@ class TestRealtimeResume:
     the socket was down."""
 
     def _event(self, cursor):
-        from chattolib.realtime import RealtimeEvent
-
         return RealtimeEvent(
             id=f"evt-{cursor}",
             created_at=None,