Files
hermes-webui/tests/test_issue4295_scroll_pin_reentry.py
nesquena-hermes 7fe6861a54 test(#4702): thread _lastMessageClientHeight through the scroll-pin-reentry JS harness
The #4295 harness runs the extracted scroll-handler body in a Node new Function();
the body now references _lastMessageClientHeight, so it must be a param + threaded
through state (seeded null, samples keep clientHeight stable so grew stays false and
the pin-reentry assertions are unchanged).
2026-06-24 10:15:04 +00:00

163 lines
4.5 KiB
Python

"""Behavioral regression locks for #4295 scroll re-pin handling."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).parent.parent
NODE = shutil.which("node")
def _ui_js() -> str:
return (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
def _balanced_block(src: str, brace_start: int) -> str:
depth = 0
for i in range(brace_start, len(src)):
ch = src[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[brace_start + 1 : i]
raise AssertionError("balanced block not found")
def _scroll_listener_raf_body() -> str:
src = _ui_js()
listener_start = src.index("el.addEventListener('scroll'")
raf_start = src.index("requestAnimationFrame(()=>", listener_start)
brace_start = src.index("{", raf_start)
return _balanced_block(src, brace_start)
def _record_non_message_scroll_intent() -> str:
src = _ui_js()
start = src.index("function _recordNonMessageScrollIntent")
end = src.index("function _recentNonMessageScrollIntent", start)
return src[start:end]
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
def _run_scroll_listener(samples: list[dict[str, int]]) -> dict[str, int | bool | None]:
payload = {
"body": _scroll_listener_raf_body(),
"samples": samples,
}
script = (
"const payload = " + json.dumps(payload) + ";\n"
+ r"""
const step = new Function(
'el',
'_lastScrollTop',
'_lastMessageClientHeight',
'_nearBottomCount',
'_scrollPinned',
'_messageUserUnpinned',
'_newMessageCueVisible',
'_programmaticScroll',
'_cancelBottomSettle',
'_clearNewMessageScrollCue',
'_syncScrollToBottomCue',
'_updateSessionStartJumpButton',
'_isSessionEndlessScrollEnabled',
'_messagesTruncated',
'_loadOlderMessages',
payload.body + `
return {
_lastScrollTop,
_lastMessageClientHeight,
_nearBottomCount,
_scrollPinned,
_messageUserUnpinned,
};
`
);
let state = {
_lastScrollTop: 800,
_lastMessageClientHeight: null,
_nearBottomCount: 0,
_scrollPinned: true,
_messageUserUnpinned: false,
};
const noop = () => {};
for (const sample of payload.samples) {
state = step(
sample,
state._lastScrollTop,
state._lastMessageClientHeight,
state._nearBottomCount,
state._scrollPinned,
state._messageUserUnpinned,
false,
false,
noop,
noop,
noop,
noop,
() => false,
false,
noop
);
}
console.log(JSON.stringify(state));
"""
)
result = subprocess.run(
[NODE, "-e", script],
check=True,
capture_output=True,
text=True,
timeout=30,
)
return json.loads(result.stdout.strip())
class TestScrollPinReentry:
def test_manual_scroll_back_to_true_bottom_rearms_follow(self):
state = _run_scroll_listener(
[
{"scrollTop": 760, "scrollHeight": 1000, "clientHeight": 200},
{"scrollTop": 770, "scrollHeight": 1000, "clientHeight": 200},
{"scrollTop": 780, "scrollHeight": 1000, "clientHeight": 200},
]
)
assert state["_scrollPinned"] is True, (
"Reaching the true bottom tail after a manual scroll-up must re-arm auto-follow."
)
assert state["_messageUserUnpinned"] is False, (
"The sticky unpin flag must clear once the reader manually scrolls back to the real bottom."
)
def test_near_bottom_proximity_alone_does_not_repin(self):
state = _run_scroll_listener(
[
{"scrollTop": 760, "scrollHeight": 1200, "clientHeight": 200},
{"scrollTop": 775, "scrollHeight": 1200, "clientHeight": 200},
{"scrollTop": 790, "scrollHeight": 1200, "clientHeight": 200},
]
)
assert state["_scrollPinned"] is False, (
"The reader must stay unpinned inside the 250px near-bottom band until they reach the true bottom tail."
)
assert state["_messageUserUnpinned"] is True, (
"Near-bottom proximity alone must not clear the sticky unpin flag."
)
def test_scroll_up_threshold_prevents_jitter_unpin(self):
record = _record_non_message_scroll_intent()
assert "e.deltaY< -30" in record or "e.deltaY < -30" in record, (
"The wheel-intent path must keep the 30px upward threshold to avoid touch jitter false unpins."
)