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).
163 lines
4.5 KiB
Python
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."
|
|
)
|