""" Structural guards for the chat panel's scroll and fetch behaviour. These have regressed repeatedly. The tests below lock the invariants that prevent the known failure modes so that a future change to chat-app.js that breaks them fails loudly in the suite rather than silently shipping to a browser. Scroll (regressed five times): 1. The scroll-to-bottom layout effect must depend on *both* ``messages`` and ``hasMore``. The "load older" button is controlled by ``hasMore``; when it appears, it pushes all messages down. If the effect ignores ``hasMore``, it misses that shift and the chat stays above the last message. 2. In the initial fetch callback, ``setHasMore`` must be called before ``setMessages``. If the framework does not batch the two updates, messages would arrive (and the scroll effect would fire) before the button is in the DOM. Fetch (regressed three times): 3. The history-fetch effect must depend on ``status``, not on ``transportRef.current?.connected``. ChatPanel remounts on group switch (keyed by groupId), and its mount effect fires *before* GroupPage's cleanup releases the old transport. With a ref-based dep the effect sees the old transport still connected, fetches from the wrong group, sets ``loadedRef = true``, and never re-fires when the correct transport connects (dep stays ``true``). Depending on the ``status`` prop avoids this: GroupPage sets ``status = 'connected'`` only after the new transport is fully connected and the index is fetched. """ from pathlib import Path import pytest STATIC = (Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static") CHAT = STATIC / "chat-app.js" pytestmark = pytest.mark.skipif(not CHAT.exists(), reason="SPA sources not present") def _chat_panel_source() -> str: src = CHAT.read_text() start = src.index("\nfunction ChatPanel(") end = src.find("\nfunction ", start + 1) return src[start:end if end != -1 else len(src)] def test_scroll_layout_effect_depends_on_has_more(): """Without hasMore the 'load older' button appearing in a second render is invisible to the scroll effect.""" src = _chat_panel_source() assert "}, [messages, hasMore])" in src, ( "the scroll-to-bottom useLayoutEffect must depend on [messages, hasMore] " "-- hasMore controls the 'load older' button, which shifts all messages " "down when it appears; without it the scroll effect misses the shift") def test_initial_fetch_sets_has_more_before_messages(): """If the framework does not batch the two setState calls, calling setMessages first lets the scroll effect run while the 'load older' button is not yet in the DOM. Setting hasMore first means the button is already present by the time messages (and the scroll) arrive.""" src = _chat_panel_source() fetch = src[src.index("fetchChatHistory("):] fetch = fetch[:fetch.index(".catch(")] has_more_pos = fetch.index("setHasMore") messages_pos = fetch.index("setMessages") assert has_more_pos < messages_pos, ( "in the initial fetchChatHistory callback, setHasMore must come before " "setMessages -- otherwise a non-batched render lets the scroll effect " "run without the 'load older' button in the DOM") def test_fetch_effect_depends_on_status_not_transport_ref(): """The history-fetch effect must gate on the status prop, not on transportRef.current?.connected. With a ref-based dep, ChatPanel's mount effect (which fires before GroupPage's cleanup) sees the old transport still connected, fetches from the wrong group, and never re-fires when the correct transport connects.""" src = _chat_panel_source() fetch_block = src[src.index("fetchChatHistory("):] effect_end = fetch_block[:fetch_block.index("const loadOlder")] assert "}, [status])" in effect_end, ( "the chat history fetch useEffect must depend on [status], not on " "transportRef.current?.connected -- the ref-based dep races with " "GroupPage's cleanup and picks up the stale transport on group switch") assert "transportRef.current?.connected" not in effect_end, ( "transportRef.current?.connected must not appear in the fetch effect's " "dependency array -- it causes a race on group switch")