main
py 202 lines 6.18 KB
Raw
1 import sys
2 from pathlib import Path
3
4 import pytest
5
6 PROJECT_ROOT = Path(__file__).resolve().parents[1]
7 if str(PROJECT_ROOT) not in sys.path:
8 sys.path.insert(0, str(PROJECT_ROOT))
9
10
11 def test_state_monitor_defaults_to_ten_pushes_per_second() -> None:
12 from helpers.state_monitor import StateMonitor
13
14 assert StateMonitor().debounce_seconds == 0.1
15
16
17 @pytest.mark.asyncio
18 async def test_state_monitor_debounce_coalesces_without_postponing_and_cleanup_cancels_pending():
19 from helpers.state_monitor import StateMonitor
20 from helpers.state_snapshot import StateRequestV1
21
22 namespace = "/ws"
23 monitor = StateMonitor(debounce_seconds=10.0)
24 monitor.register_sid(namespace, "sid-1")
25 monitor.bind_manager(type("FakeManager", (), {"_dispatcher_loop": None})())
26 monitor.update_projection(
27 namespace,
28 "sid-1",
29 request=StateRequestV1(context=None, log_from=0, notifications_from=0, timezone="UTC"),
30 seq_base=1,
31 )
32
33 monitor.mark_dirty(namespace, "sid-1")
34 first = monitor._debounce_handles[(namespace, "sid-1")]
35
36 monitor.mark_dirty(namespace, "sid-1")
37 second = monitor._debounce_handles[(namespace, "sid-1")]
38
39 # Throttled coalescing: subsequent dirties keep the scheduled push instead of postponing it.
40 assert first is second
41 assert not second.cancelled()
42
43 monitor.unregister_sid(namespace, "sid-1")
44 assert second.cancelled()
45 assert (namespace, "sid-1") not in monitor._debounce_handles
46
47
48 @pytest.mark.asyncio
49 async def test_state_monitor_namespace_identity_prevents_cross_namespace_state_push(monkeypatch) -> None:
50 import asyncio
51 from unittest.mock import AsyncMock
52
53 from helpers.state_monitor import StateMonitor
54 from helpers.state_snapshot import StateRequestV1
55
56 loop = asyncio.get_running_loop()
57 push_ready = asyncio.Event()
58 captured: list[tuple[str, str]] = []
59
60 async def _emit_to(namespace: str, sid: str, event_type: str, _payload: object, **_kwargs):
61 if event_type == "state_push":
62 captured.append((namespace, sid))
63 push_ready.set()
64
65 class FakeManager:
66 def __init__(self):
67 self._dispatcher_loop = loop
68 self.emit_to = AsyncMock(side_effect=_emit_to)
69
70 monitor = StateMonitor(debounce_seconds=0.0)
71 manager = FakeManager()
72 monitor.bind_manager(manager, handler_id="tester")
73
74 sid = "shared-sid"
75 ns_a = "/a"
76 ns_b = "/b"
77 monitor.register_sid(ns_a, sid)
78 monitor.register_sid(ns_b, sid)
79 monitor.update_projection(
80 ns_a,
81 sid,
82 request=StateRequestV1(context=None, log_from=0, notifications_from=0, timezone="UTC"),
83 seq_base=1,
84 )
85 monitor.update_projection(
86 ns_b,
87 sid,
88 request=StateRequestV1(context=None, log_from=0, notifications_from=0, timezone="UTC"),
89 seq_base=1,
90 )
91
92 async def _fake_snapshot(**_kwargs):
93 return {
94 "log_version": 0,
95 "notifications_version": 0,
96 "logs": [],
97 "contexts": [],
98 "tasks": [],
99 "notifications": [],
100 }
101
102 # Patch build_snapshot used by StateMonitor so this test stays lightweight.
103 monkeypatch.setattr("helpers.state_monitor.build_snapshot_from_request", _fake_snapshot)
104
105 monitor.mark_dirty(ns_a, sid, reason="test")
106 await asyncio.wait_for(push_ready.wait(), timeout=1.0)
107
108 assert captured
109 assert all(ns == ns_a for ns, _ in captured)
110
111
112 @pytest.mark.asyncio
113 async def test_collection_delta_tracks_full_and_stream_dirty_waves(monkeypatch) -> None:
114 import asyncio
115
116 import helpers.state_monitor as state_monitor_module
117 from helpers.state_monitor import StateMonitor
118 from helpers.state_snapshot import StateRequestV1
119
120 namespace = "/ws"
121 sid = "sid-delta"
122 identity = (namespace, sid)
123 include_calls: list[bool] = []
124 emitted: list[dict] = []
125
126 async def fake_snapshot(*, request, include_collections=True):
127 include_calls.append(include_collections)
128 return {
129 "deselect_chat": False,
130 "context": request.context or "",
131 "contexts": [] if include_collections else None,
132 "tasks": [] if include_collections else None,
133 "logs": [],
134 "log_guid": "guid",
135 "log_version": request.log_from,
136 "log_progress": "",
137 "log_progress_active": False,
138 "paused": False,
139 "notifications": [],
140 "notifications_guid": "notifications",
141 "notifications_version": request.notifications_from,
142 }
143
144 class FakeManager:
145 def __init__(self, loop):
146 self._dispatcher_loop = loop
147
148 async def emit_to(self, _namespace, _sid, _event_type, payload, **_kwargs):
149 emitted.append(payload["snapshot"])
150
151 monitor = StateMonitor(debounce_seconds=60.0)
152 monitor.bind_manager(FakeManager(asyncio.get_running_loop()))
153 monitor.register_sid(namespace, sid)
154 monitor.update_projection(
155 namespace,
156 sid,
157 request=StateRequestV1(
158 context="ctx",
159 log_from=0,
160 notifications_from=0,
161 timezone="UTC",
162 collections_delta=True,
163 ),
164 seq_base=1,
165 )
166 monkeypatch.setattr(
167 state_monitor_module,
168 "build_snapshot_from_request",
169 fake_snapshot,
170 )
171
172 async def flush() -> None:
173 handle = monitor._debounce_handles.pop(identity)
174 handle.cancel()
175 await monitor._flush_push(identity)
176
177 monitor.mark_dirty(namespace, sid, include_collections=False)
178 await flush()
179
180 monitor.mark_dirty(namespace, sid, include_collections=False)
181 monitor.mark_dirty(namespace, sid, include_collections=True)
182 await flush()
183
184 monitor.update_projection(
185 namespace,
186 sid,
187 request=StateRequestV1(
188 context="ctx",
189 log_from=0,
190 notifications_from=0,
191 timezone="UTC",
192 ),
193 seq_base=1,
194 )
195 monitor.mark_dirty(namespace, sid, include_collections=False)
196 await flush()
197
198 assert include_calls == [False, True, True]
199 assert emitted[0]["contexts"] is None
200 assert emitted[0]["tasks"] is None
201 assert emitted[1]["contexts"] == []
202 assert emitted[2]["contexts"] == []