test: exercise reachable live scroll restore boundary

This commit is contained in:
Frank Song 2026-07-25 15:36:09 +08:00
parent a6a891f079
commit cc5cf19710
4 changed files with 36 additions and 27 deletions

View file

@ -12884,15 +12884,7 @@ function renderLiveAnchorActivityScene(streamId, scene, opts){
_dedupeLiveProcessedWorklogAnchors(turn); _dedupeLiveProcessedWorklogAnchors(turn);
if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd(); if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd();
_restoreMessageScrollSnapshotSameFrame(scrollSnapshot); _restoreMessageScrollSnapshotSameFrame(scrollSnapshot);
if(typeof _restoreLiveAnchorScrollSnapshotAfterRebuild==='function'){ _restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard);
_restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard);
}else if(scrollRebuildGuard&&scrollRebuildGuard.release){
// Keep extracted test harnesses safe when they omit the shared helper.
requestAnimationFrame(()=>{
scrollRebuildGuard.release();
if(_messageUserUnpinned) _restoreMessageScrollSnapshotSameFrame(scrollSnapshot);
});
}
if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned(); if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned();
return true; return true;
} }
@ -12998,15 +12990,7 @@ function _renderLiveAnchorActivitySceneTransparent(streamId, scene, opts){
if(renderedRows.length) _syncTransparentEventControls(turn); if(renderedRows.length) _syncTransparentEventControls(turn);
if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd(); if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd();
_restoreMessageScrollSnapshotSameFrame(scrollSnapshot); _restoreMessageScrollSnapshotSameFrame(scrollSnapshot);
if(typeof _restoreLiveAnchorScrollSnapshotAfterRebuild==='function'){ _restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard);
_restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard);
}else if(scrollRebuildGuard&&scrollRebuildGuard.release){
// Keep extracted test harnesses safe when they omit the shared helper.
requestAnimationFrame(()=>{
scrollRebuildGuard.release();
if(_messageUserUnpinned) _restoreMessageScrollSnapshotSameFrame(scrollSnapshot);
});
}
if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned(); if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned();
return !!renderedRows.length; return !!renderedRows.length;
} }

View file

@ -311,6 +311,8 @@ eval(extractFunc('_bindTransparentFadeCleanup'));
eval(extractFunc('_appendTransparentFadeText')); eval(extractFunc('_appendTransparentFadeText'));
eval(extractFunc('_refreshTransparentFadeProseRow')); eval(extractFunc('_refreshTransparentFadeProseRow'));
eval(extractFunc('_refreshTransparentLiveRow')); eval(extractFunc('_refreshTransparentLiveRow'));
eval(extractFunc('_restoreLiveAnchorScrollSnapshotAfterRebuild'));
eval(extractFunc('_restoreLiveAnchorScrollSnapshotAfterRebuild'));
eval(extractFunc('_renderLiveAnchorActivitySceneTransparent')); eval(extractFunc('_renderLiveAnchorActivitySceneTransparent'));
const firstScene = {{ const firstScene = {{
@ -1264,6 +1266,7 @@ eval(extractFunc('_transparentLiveRowInteractiveState'));
eval(extractFunc('_rehydrateTransparentLiveRow')); eval(extractFunc('_rehydrateTransparentLiveRow'));
eval(extractFunc('_refreshTransparentThinkingLiveRow')); eval(extractFunc('_refreshTransparentThinkingLiveRow'));
eval(extractFunc('_refreshTransparentLiveRow')); eval(extractFunc('_refreshTransparentLiveRow'));
eval(extractFunc('_restoreLiveAnchorScrollSnapshotAfterRebuild'));
eval(extractFunc('_renderLiveAnchorActivitySceneTransparent')); eval(extractFunc('_renderLiveAnchorActivitySceneTransparent'));
const firstScene = { version:'activity_scene_v1', activity_rows:[{ row_id:'row-tool', role:'tool', source_event_type:'tool_delta', toolName:'old_tool' }] }; const firstScene = { version:'activity_scene_v1', activity_rows:[{ row_id:'row-tool', role:'tool', source_event_type:'tool_delta', toolName:'old_tool' }] };
@ -1601,6 +1604,7 @@ eval(extractFunc('_transparentLiveRowInteractiveState'));
eval(extractFunc('_rehydrateTransparentLiveRow')); eval(extractFunc('_rehydrateTransparentLiveRow'));
eval(extractFunc('_refreshTransparentThinkingLiveRow')); eval(extractFunc('_refreshTransparentThinkingLiveRow'));
eval(extractFunc('_refreshTransparentLiveRow')); eval(extractFunc('_refreshTransparentLiveRow'));
eval(extractFunc('_restoreLiveAnchorScrollSnapshotAfterRebuild'));
eval(extractFunc('_renderLiveAnchorActivitySceneTransparent')); eval(extractFunc('_renderLiveAnchorActivitySceneTransparent'));
global._copyEventToClipboard = (row) => {{ global._copyEventToClipboard = (row) => {{
const tc = row && row._tcData || {{}}; const tc = row && row._tcData || {{}};

View file

@ -119,6 +119,7 @@ def _live_render_ownership_harness(*, move_to_bottom: bool) -> str:
"_abandonMessageScrollSnapshot", "_abandonMessageScrollSnapshot",
"_restorePinnedMessageScrollSnapshot", "_restorePinnedMessageScrollSnapshot",
"_restoreMessageScrollSnapshotSameFrame", "_restoreMessageScrollSnapshotSameFrame",
"_prepareLiveAnchorScrollRebuildGuard",
"_restoreLiveAnchorScrollSnapshotAfterRebuild", "_restoreLiveAnchorScrollSnapshotAfterRebuild",
] ]
production = "\n".join(_extract_js_function(js, name) for name in functions) production = "\n".join(_extract_js_function(js, name) for name in functions)
@ -144,7 +145,8 @@ const el={{
querySelector(){{return null;}}, querySelector(){{return null;}},
}}; }};
const document={{getElementById(id){{return id==='messages'?el:null;}}}}; const document={{getElementById(id){{return id==='messages'?el:null;}}}};
function $(id){{return id==='messages'?el:null;}} const msgInner={{style:{{}},dataset:{{}}}};
function $(id){{return id==='messages'?el:id==='msgInner'?msgInner:null;}}
function _captureMessageViewportAnchor(){{return null;}} function _captureMessageViewportAnchor(){{return null;}}
function _shouldFollowMessagesOnDomReplace(){{return true;}} function _shouldFollowMessagesOnDomReplace(){{return true;}}
function _deferClearProgrammaticScroll(){{}} function _deferClearProgrammaticScroll(){{}}
@ -158,16 +160,19 @@ function _recentMessageTouchScrollIntent(){{return false;}}
function _restoreMessageScrollSnapshotSameFrameFallback(){{}} function _restoreMessageScrollSnapshotSameFrameFallback(){{}}
function requestAnimationFrame(callback){{callbacks.push(callback);}} function requestAnimationFrame(callback){{callbacks.push(callback);}}
const snapshot=_captureMessageScrollSnapshot(); const snapshot=_captureMessageScrollSnapshot();
const capturedPinned=snapshot.pinned===true;
{f"el._top=89577; _messageUserUnpinned=true; _scrollPinned=false;" if not move_to_bottom else ""}
const scrollRebuildGuard=_prepareLiveAnchorScrollRebuildGuard(snapshot);
_restoreMessageScrollSnapshotSameFrame(snapshot); _restoreMessageScrollSnapshotSameFrame(snapshot);
const initialWrites=writes.slice(); const initialWrites=writes.slice();
writes=[]; writes=[];
_restoreLiveAnchorScrollSnapshotAfterRebuild(snapshot,scrollRebuildGuard);
recordWrites=false; recordWrites=false;
_recordNonMessageScrollIntent({{target:el,deltaY:{target_delta}}}); _recordNonMessageScrollIntent({{target:el,deltaY:{target_delta}}});
el.scrollTop={final_top}; el.scrollTop={final_top};
recordWrites=true; recordWrites=true;
_restoreLiveAnchorScrollSnapshotAfterRebuild(snapshot,{{release(){{}}}});
callbacks.shift()(); callbacks.shift()();
console.log(JSON.stringify({{initialWrites,writes, finalScrollTop:el.scrollTop, console.log(JSON.stringify({{capturedPinned,guardQueued:!!(scrollRebuildGuard&&scrollRebuildGuard.release),initialWrites,writes, finalScrollTop:el.scrollTop,
messageUserUnpinned:_messageUserUnpinned, scrollPinned:_scrollPinned, messageUserUnpinned:_messageUserUnpinned, scrollPinned:_scrollPinned,
generation:_messageScrollInputGeneration}})); generation:_messageScrollInputGeneration}}));
""" """
@ -177,6 +182,8 @@ def test_live_render_queue_preserves_real_upward_input_from_pinned_capture():
"""The real producer and queued live-render callback preserve upward input.""" """The real producer and queued live-render callback preserve upward input."""
result = json.loads(_run_node(_live_render_ownership_harness(move_to_bottom=False))) result = json.loads(_run_node(_live_render_ownership_harness(move_to_bottom=False)))
assert result == { assert result == {
"capturedPinned": True,
"guardQueued": True,
"initialWrites": [90026], "initialWrites": [90026],
"writes": [], "writes": [],
"finalScrollTop": 89000, "finalScrollTop": 89000,
@ -190,6 +197,8 @@ def test_live_render_queue_repins_real_downward_input_at_bottom():
"""The real producer and queued live-render callback re-pin at true bottom.""" """The real producer and queued live-render callback re-pin at true bottom."""
result = json.loads(_run_node(_live_render_ownership_harness(move_to_bottom=True))) result = json.loads(_run_node(_live_render_ownership_harness(move_to_bottom=True)))
assert result == { assert result == {
"capturedPinned": False,
"guardQueued": True,
"initialWrites": [89577], "initialWrites": [89577],
"writes": [], "writes": [],
"finalScrollTop": 90026, "finalScrollTop": 90026,
@ -326,7 +335,8 @@ def _fallback_harness(*, snapshot_scroll_height, cur_scroll_height, snapshot_top
snapshot_pinned: bool = False, snapshot_pinned: bool = False,
anchor=None, anchor_row_content_pos=None, container_top: int = 0, anchor=None, anchor_row_content_pos=None, container_top: int = 0,
top_pad_now=None, input_generation: int = 0, top_pad_now=None, input_generation: int = 0,
delayed_input_generation=None, delayed_scroll_top: int = 89000) -> str: delayed_input_generation=None, delayed_scroll_top: int = 89000,
restore_fn: str = "_restoreMessageScrollSnapshotSameFrame") -> str:
"""Node harness for the absolute-fallback path of _restoreMessageScrollSnapshotSameFrame. """Node harness for the absolute-fallback path of _restoreMessageScrollSnapshotSameFrame.
SCROLL-DEPENDENT geometry (round-3 gate-cert requirement): the anchor row's SCROLL-DEPENDENT geometry (round-3 gate-cert requirement): the anchor row's
@ -401,9 +411,9 @@ const snapshot = {{ anchor: {anchor_js}, top: {snapshot_top}, bottom: 40,
eval(extractFunc('_desktopAnchorRealignDelta')); eval(extractFunc('_desktopAnchorRealignDelta'));
eval(extractFunc('_messageScrollSnapshotInputChanged')); eval(extractFunc('_messageScrollSnapshotInputChanged'));
eval(extractFunc('_abandonMessageScrollSnapshot')); eval(extractFunc('_abandonMessageScrollSnapshot'));
eval(extractFunc('_restoreMessageScrollSnapshotSameFrame')); eval(extractFunc({json.dumps(restore_fn)}));
_restoreMessageScrollSnapshotSameFrame(snapshot); {restore_fn}(snapshot);
{f"stTop = {delayed_scroll_top}; _messageScrollInputGeneration = {delayed_input_generation}; _restoreMessageScrollSnapshotSameFrame(snapshot);" if delayed_input_generation is not None else ""} {f"stTop = {delayed_scroll_top}; _messageScrollInputGeneration = {delayed_input_generation}; {restore_fn}(snapshot);" if delayed_input_generation is not None else ""}
console.log(JSON.stringify({{ wrote: writes.length, writes, console.log(JSON.stringify({{ wrote: writes.length, writes,
messageUserUnpinned: _messageUserUnpinned, scrollPinned: _scrollPinned, finalScrollTop: Math.round(stTop) }})); messageUserUnpinned: _messageUserUnpinned, scrollPinned: _scrollPinned, finalScrollTop: Math.round(stTop) }}));
""" """
@ -500,7 +510,11 @@ def test_delayed_restore_cannot_overwrite_new_desktop_input():
assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False
def test_delayed_pinned_restore_cannot_overwrite_new_upward_input(): @pytest.mark.parametrize("restore_fn", [
"_restoreMessageScrollSnapshot",
"_restoreMessageScrollSnapshotSameFrame",
])
def test_delayed_pinned_restore_cannot_overwrite_new_upward_input(restore_fn):
"""A pinned-at-capture snapshot must lose ownership before its tail write. """A pinned-at-capture snapshot must lose ownership before its tail write.
The reader moves upward before the delayed restore; the stale pinned snapshot The reader moves upward before the delayed restore; the stale pinned snapshot
@ -511,19 +525,25 @@ def test_delayed_pinned_restore_cannot_overwrite_new_upward_input():
active_intent=False, touch_like=False, snapshot_pinned=True, active_intent=False, touch_like=False, snapshot_pinned=True,
init_scroll_top=89577, input_generation=0, delayed_input_generation=1, init_scroll_top=89577, input_generation=0, delayed_input_generation=1,
delayed_scroll_top=89000, delayed_scroll_top=89000,
restore_fn=restore_fn,
))) )))
assert m["writes"] == [89986] assert m["writes"] == [89986]
assert m["finalScrollTop"] == 89000 assert m["finalScrollTop"] == 89000
assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False
def test_delayed_unpinned_restore_repins_reader_who_reached_bottom(): @pytest.mark.parametrize("restore_fn", [
"_restoreMessageScrollSnapshot",
"_restoreMessageScrollSnapshotSameFrame",
])
def test_delayed_unpinned_restore_repins_reader_who_reached_bottom(restore_fn):
"""Abandoning a stale unpinned snapshot must reconcile a live bottom position.""" """Abandoning a stale unpinned snapshot must reconcile a live bottom position."""
m = json.loads(_run_node(_fallback_harness( m = json.loads(_run_node(_fallback_harness(
snapshot_scroll_height=90453, cur_scroll_height=90453, snapshot_top=89577, snapshot_scroll_height=90453, cur_scroll_height=90453, snapshot_top=89577,
active_intent=False, touch_like=False, snapshot_user_unpinned=True, active_intent=False, touch_like=False, snapshot_user_unpinned=True,
init_scroll_top=89577, input_generation=0, delayed_input_generation=1, init_scroll_top=89577, input_generation=0, delayed_input_generation=1,
delayed_scroll_top=90026, delayed_scroll_top=90026,
restore_fn=restore_fn,
))) )))
assert m["writes"] == [89577] assert m["writes"] == [89577]
assert m["finalScrollTop"] == 90026 assert m["finalScrollTop"] == 90026

View file

@ -476,6 +476,7 @@ for(const name of [
'_liveAnchorReasoningRowForFallback','_updateLiveAnchorReasoningRowForFallback', '_liveAnchorReasoningRowForFallback','_updateLiveAnchorReasoningRowForFallback',
'_anchorSceneNodeForRow','_anchorSceneWorklogGroup','_renderAnchorSceneRowsIntoWorklog', '_anchorSceneNodeForRow','_anchorSceneWorklogGroup','_renderAnchorSceneRowsIntoWorklog',
'isLiveAnchorActivitySceneOwner','_projectLiveAnchorActivitySceneForStream', 'isLiveAnchorActivitySceneOwner','_projectLiveAnchorActivitySceneForStream',
'_restoreLiveAnchorScrollSnapshotAfterRebuild',
'_renderLiveAnchorActivitySceneTransparent','renderLiveAnchorActivityScene', '_renderLiveAnchorActivitySceneTransparent','renderLiveAnchorActivityScene',
'_renderLiveAnchorActivitySceneForStream','appendThinking','updateThinking', '_renderLiveAnchorActivitySceneForStream','appendThinking','updateThinking',
]) eval(extractFunc(uiSrc,name)); ]) eval(extractFunc(uiSrc,name));