Adds a Preferences setting 'Virtualize long transcripts' (default ON, opt-out). When disabled, _currentMessageVirtualWindow returns a full non-virtualized window so the entire transcript renders at once (also short-circuits the virtual re-measure loop). Server-persisted (virtualize_transcript bool, default True), hot-applies via renderMessages on toggle. i18n in all 13 locales. Pairs with @rodboev's #4328 stream-end anchor scroll fix (cherry-picked into this branch) to fully resolve #4325.
576 lines
22 KiB
Python
576 lines
22 KiB
Python
"""Regression coverage for issue #500 transcript virtualization."""
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).parent.parent.resolve()
|
|
UI_JS_PATH = REPO_ROOT / "static" / "ui.js"
|
|
NODE = shutil.which("node")
|
|
|
|
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
|
|
|
|
def _run_node(source: str) -> str:
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", suffix=".cjs", encoding="utf-8", dir=REPO_ROOT, delete=False
|
|
) as script:
|
|
script.write(source)
|
|
script_path = Path(script.name)
|
|
try:
|
|
result = subprocess.run(
|
|
[NODE, str(script_path)],
|
|
cwd=str(REPO_ROOT),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
finally:
|
|
script_path.unlink(missing_ok=True)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(result.stderr)
|
|
return result.stdout.strip()
|
|
|
|
|
|
def _extract_func_script(js: str) -> str:
|
|
return f"""
|
|
const src = {js!r};
|
|
function extractFunc(name) {{
|
|
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
|
const start = src.search(re);
|
|
if (start < 0) throw new Error(name + ' not found');
|
|
let i = src.indexOf('{{', start);
|
|
let depth = 1; i++;
|
|
while (depth > 0 && i < src.length) {{
|
|
if (src[i] === '{{') depth++;
|
|
else if (src[i] === '}}') depth--;
|
|
i++;
|
|
}}
|
|
return src.slice(start, i);
|
|
}}
|
|
"""
|
|
|
|
|
|
def test_message_virtual_window_virtualizes_older_history_but_keeps_recent_tail():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
eval(extractFunc('_messageVirtualWindow'));
|
|
const metrics = _messageVirtualWindow({
|
|
total: 240,
|
|
scrollTop: 120 * 70,
|
|
viewportHeight: 720,
|
|
heights: Array.from({length: 240}, (_, i) => i >= 190 ? 220 : 120),
|
|
defaultHeight: 120,
|
|
bufferPx: 240,
|
|
threshold: 80,
|
|
keepTailCount: 50,
|
|
});
|
|
console.log(JSON.stringify(metrics));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["virtualized"] is True
|
|
assert 60 <= metrics["start"] <= 75
|
|
assert metrics["end"] <= metrics["tailStart"] == 190
|
|
assert metrics["topPad"] > 0
|
|
assert metrics["bottomPad"] > 0
|
|
|
|
|
|
def test_message_virtual_window_collapses_to_tail_only_near_bottom():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
eval(extractFunc('_messageVirtualWindow'));
|
|
const metrics = _messageVirtualWindow({
|
|
total: 240,
|
|
scrollTop: 120 * 260,
|
|
viewportHeight: 720,
|
|
heights: Array.from({length: 240}, () => 120),
|
|
defaultHeight: 120,
|
|
bufferPx: 240,
|
|
threshold: 80,
|
|
keepTailCount: 50,
|
|
});
|
|
console.log(JSON.stringify(metrics));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["virtualized"] is True
|
|
assert metrics["start"] == metrics["tailStart"] == 190
|
|
assert metrics["end"] == metrics["tailStart"]
|
|
assert metrics["bottomPad"] == 0
|
|
|
|
|
|
def test_render_messages_uses_virtual_window_and_spacer_measurement_path():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
render_start = js.index("function renderMessages(options)")
|
|
render_end = js.index("function _toolDisplayName", render_start)
|
|
render_body = js[render_start:render_end]
|
|
|
|
assert "_currentMessageVirtualWindow(visWithIdx,_messageVirtualKeepTailCount())" in render_body
|
|
assert "const renderVisibleIdxs=[" in render_body
|
|
assert "_messageVirtualSpacer(virtualWindow.topPad,'before')" in render_body
|
|
assert "_messageVirtualSpacer(virtualWindow.bottomPad,'after')" in render_body
|
|
assert "_updateMessageVirtualMeasurements(renderVisWithIdx, renderVisibleIdxs, virtualWindow);" in render_body
|
|
assert "const renderableRawIdxs=new Set(visWithIdx.map(e=>e.rawIdx));" in render_body
|
|
assert "if(virtualWindow.virtualized&&renderableRawIdxs.has(aIdx)&&!renderedRawIdxs.has(aIdx)) continue;" in render_body
|
|
assert "if(hasServerOlder){" in render_body
|
|
assert "_showEarlierRenderedMessages();" not in render_body
|
|
top_spacer_idx = render_body.index("_messageVirtualSpacer(virtualWindow.topPad,'before')")
|
|
indicator_idx = render_body.index("indicator.id='loadOlderIndicator';")
|
|
assert top_spacer_idx < indicator_idx, (
|
|
"renderMessages() must place the load-older affordance after the top "
|
|
"virtual spacer so it stays visible at the top of the rendered window."
|
|
)
|
|
gap_reset_idx = render_body.index("currentAssistantTurn=null;", render_body.index("_messageVirtualSpacer(virtualWindow.bottomPad,'after')") - 220)
|
|
gap_spacer_idx = render_body.index("_messageVirtualSpacer(virtualWindow.bottomPad,'after')")
|
|
assert gap_reset_idx < gap_spacer_idx, (
|
|
"renderMessages() must reset currentAssistantTurn before inserting the "
|
|
"virtual gap spacer so assistant bubbles do not merge across the head/tail boundary."
|
|
)
|
|
|
|
|
|
def test_measurement_uses_one_primary_row_and_adjacent_activity_siblings_only():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
eval(extractFunc('_measureMessageVirtualRow'));
|
|
const nextMessage = {
|
|
hasAttribute(name){ return name === 'data-msg-idx'; },
|
|
getBoundingClientRect(){ return {height: 999}; },
|
|
nextElementSibling: null,
|
|
};
|
|
const activityGroup = {
|
|
hasAttribute(){ return false; },
|
|
matches(selector){ return selector === '.tool-call-group,.tool-card-row,.agent-activity-thinking,.thinking-card-row'; },
|
|
getBoundingClientRect(){ return {height: 60}; },
|
|
nextElementSibling: {
|
|
hasAttribute(){ return false; },
|
|
matches(){ return false; },
|
|
getBoundingClientRect(){ return {height: 5000}; },
|
|
nextElementSibling: nextMessage,
|
|
},
|
|
};
|
|
const primary = {
|
|
classList: { contains(name){ return name === 'assistant-segment'; } },
|
|
getBoundingClientRect(){ return {height: 120}; },
|
|
nextElementSibling: activityGroup,
|
|
};
|
|
const inner = {
|
|
querySelector(selector){
|
|
if(selector === '[data-msg-idx="42"]') return primary;
|
|
return null;
|
|
},
|
|
};
|
|
console.log(JSON.stringify({
|
|
total: _measureMessageVirtualRow(inner, {rawIdx: 42}),
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["total"] == 180
|
|
|
|
|
|
def test_virtual_keep_tail_count_stays_bounded_after_history_expands_render_window():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
const MESSAGE_RENDER_WINDOW_DEFAULT = 50;
|
|
let _messageRenderWindowSize = 240;
|
|
eval(extractFunc('_currentMessageRenderWindowSize'));
|
|
eval(extractFunc('_messageVirtualKeepTailCount'));
|
|
console.log(JSON.stringify({
|
|
renderWindowSize: _currentMessageRenderWindowSize(),
|
|
keepTailCount: _messageVirtualKeepTailCount(),
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["renderWindowSize"] == 240
|
|
assert metrics["keepTailCount"] == 50
|
|
|
|
|
|
def test_virtual_prepended_height_delta_uses_prefix_cache_only_when_virtualized():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
let virtualized = true;
|
|
let _messageVirtualHeightCache = [0, 220, 180, 120];
|
|
let _messageVirtualEstimatedRowHeight = 140;
|
|
function _getVisibleMessagesWithIdx(){
|
|
return [{rawIdx: 0}, {rawIdx: 1}, {rawIdx: 2}, {rawIdx: 3}];
|
|
}
|
|
function _messageVirtualKeepTailCount(){ return 2; }
|
|
function _currentMessageVirtualWindow(){
|
|
return {virtualized};
|
|
}
|
|
eval(extractFunc('_messageVirtualPrependedHeightDelta'));
|
|
const active = _messageVirtualPrependedHeightDelta(3);
|
|
virtualized = false;
|
|
const inactive = _messageVirtualPrependedHeightDelta(3);
|
|
console.log(JSON.stringify({active, inactive}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["active"] == 540
|
|
assert metrics["inactive"] is None
|
|
|
|
|
|
def test_virtual_question_jump_scroll_target_uses_visible_index_height_prefix():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
let _messageVirtualHeightCache = [100, 120, 80, 140];
|
|
let _messageVirtualHeightCacheEntries = [];
|
|
let _messageVirtualHeightCacheLen = 4;
|
|
let _messageVirtualHeightCacheSrc = null;
|
|
let _messageVirtualEstimatedRowHeight = 110;
|
|
let _messageVirtualWindowKey = 'old';
|
|
let S = {messages: [{}, {}, {}, {}]};
|
|
function _messageIsRenderable(){ return true; }
|
|
eval(extractFunc('_messageVirtualHeightEntryMatches'));
|
|
eval(extractFunc('_syncMessageVirtualHeightCache'));
|
|
eval(extractFunc('_messageVisibleIndexForRawIdx'));
|
|
eval(extractFunc('_messageVirtualScrollTopForVisibleIdx'));
|
|
const visWithIdx = [
|
|
{rawIdx: 10, m: S.messages[0]},
|
|
{rawIdx: 12, m: S.messages[1]},
|
|
{rawIdx: 14, m: S.messages[2]},
|
|
{rawIdx: 16, m: S.messages[3]},
|
|
];
|
|
_messageVirtualHeightCacheEntries = visWithIdx;
|
|
_messageVirtualHeightCacheSrc = S.messages;
|
|
const visibleIdx = _messageVisibleIndexForRawIdx(14, visWithIdx);
|
|
const scrollTop = _messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, {clientHeight: 200});
|
|
console.log(JSON.stringify({visibleIdx, scrollTop}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["visibleIdx"] == 2
|
|
assert metrics["scrollTop"] == 150
|
|
|
|
|
|
def test_height_cache_preserves_measured_prefix_across_append_only_growth():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140;
|
|
let _messageVirtualHeightCache = [180, 220];
|
|
let _messageVirtualHeightCacheEntries = [];
|
|
let _messageVirtualHeightCacheLen = 2;
|
|
let _messageVirtualHeightCacheSrc = null;
|
|
let _messageVirtualEstimatedRowHeight = 200;
|
|
let _messageVirtualWindowKey = 'stale-key';
|
|
function _clearMessageVirtualHeightCache() {
|
|
_messageVirtualHeightCache = [];
|
|
_messageVirtualHeightCacheEntries = [];
|
|
_messageVirtualHeightCacheLen = 0;
|
|
_messageVirtualHeightCacheSrc = null;
|
|
_messageVirtualEstimatedRowHeight = MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT;
|
|
_messageVirtualWindowKey = '';
|
|
}
|
|
eval(extractFunc('_messageVirtualHeightEntryMatches'));
|
|
eval(extractFunc('_messageVirtualHeightPrefixEntryMatches'));
|
|
eval(extractFunc('_syncMessageVirtualHeightCache'));
|
|
const first = {id: 'first'};
|
|
const second = {id: 'second'};
|
|
let S = {messages: [first, second]};
|
|
_messageVirtualHeightCacheEntries = [
|
|
{rawIdx: 0, m: first},
|
|
{rawIdx: 1, m: second},
|
|
];
|
|
_messageVirtualHeightCacheSrc = S.messages;
|
|
S = {messages: [first, second, {id: 'third'}]};
|
|
_syncMessageVirtualHeightCache([
|
|
{rawIdx: 0, m: first},
|
|
{rawIdx: 1, m: second},
|
|
{rawIdx: 2, m: S.messages[2]},
|
|
]);
|
|
console.log(JSON.stringify({
|
|
cache: _messageVirtualHeightCache,
|
|
estimated: _messageVirtualEstimatedRowHeight,
|
|
windowKey: _messageVirtualWindowKey,
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["cache"][:2] == [180, 220]
|
|
assert len(metrics["cache"]) == 3
|
|
assert metrics["estimated"] == 200
|
|
assert metrics["windowKey"] == ""
|
|
|
|
|
|
def test_height_cache_preserves_measured_suffix_across_prepended_history():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140;
|
|
let _messageVirtualHeightCache = [180, 220];
|
|
let _messageVirtualHeightCacheEntries = [];
|
|
let _messageVirtualHeightCacheLen = 2;
|
|
let _messageVirtualHeightCacheSrc = null;
|
|
let _messageVirtualEstimatedRowHeight = 200;
|
|
let _messageVirtualWindowKey = 'stale-key';
|
|
function _clearMessageVirtualHeightCache() {
|
|
_messageVirtualHeightCache = [];
|
|
_messageVirtualHeightCacheEntries = [];
|
|
_messageVirtualHeightCacheLen = 0;
|
|
_messageVirtualHeightCacheSrc = null;
|
|
_messageVirtualEstimatedRowHeight = MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT;
|
|
_messageVirtualWindowKey = '';
|
|
}
|
|
eval(extractFunc('_messageVirtualHeightEntryMatches'));
|
|
eval(extractFunc('_messageVirtualHeightPrefixEntryMatches'));
|
|
eval(extractFunc('_syncMessageVirtualHeightCache'));
|
|
const first = {id: 'first'};
|
|
const second = {id: 'second'};
|
|
let S = {messages: [first, second]};
|
|
_messageVirtualHeightCacheEntries = [
|
|
{rawIdx: 0, m: first},
|
|
{rawIdx: 1, m: second},
|
|
];
|
|
_messageVirtualHeightCacheSrc = S.messages;
|
|
const olderA = {id: 'older-a'};
|
|
const olderB = {id: 'older-b'};
|
|
S = {messages: [olderA, olderB, first, second]};
|
|
_syncMessageVirtualHeightCache([
|
|
{rawIdx: 0, m: olderA},
|
|
{rawIdx: 1, m: olderB},
|
|
{rawIdx: 2, m: first},
|
|
{rawIdx: 3, m: second},
|
|
]);
|
|
console.log(JSON.stringify({
|
|
cache: _messageVirtualHeightCache,
|
|
estimated: _messageVirtualEstimatedRowHeight,
|
|
windowKey: _messageVirtualWindowKey,
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["cache"][2:] == [180, 220]
|
|
assert len(metrics["cache"]) == 4
|
|
assert metrics["estimated"] == 200
|
|
assert metrics["windowKey"] == ""
|
|
|
|
|
|
def test_measurement_refresh_budget_is_keyed_to_window_shape_not_pad_height():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
eval(extractFunc('_messageVirtualMeasurementCycleKeyFor'));
|
|
console.log(JSON.stringify({
|
|
a: _messageVirtualMeasurementCycleKeyFor({virtualized: true, start: 10, end: 20, topPad: 1000, bottomPad: 2000, tailStart: 190}),
|
|
b: _messageVirtualMeasurementCycleKeyFor({virtualized: true, start: 10, end: 20, topPad: 1001, bottomPad: 1999, tailStart: 190}),
|
|
}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["a"] == metrics["b"]
|
|
|
|
|
|
def test_tool_rows_do_not_carry_message_measurement_hook():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
build_start = js.index("function buildToolCard(tc){")
|
|
build_end = js.index("function _colorDiffLines", build_start)
|
|
build_body = js[build_start:build_end]
|
|
|
|
assert "row.dataset.msgIdx" not in build_body
|
|
assert "querySelectorAll(`[data-msg-idx=" not in js
|
|
|
|
|
|
def test_viewport_intersection_helper_detects_visible_rendered_rows_only():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
let rows = [];
|
|
const container = {
|
|
getBoundingClientRect(){ return {top: 100, bottom: 300}; },
|
|
querySelectorAll(selector){
|
|
if(selector === '[data-msg-idx]') return rows;
|
|
return [];
|
|
},
|
|
};
|
|
function $(id){ return id === 'messages' ? container : null; }
|
|
eval(extractFunc('_messageViewportIntersectsRenderedRow'));
|
|
rows = [
|
|
{ getBoundingClientRect(){ return {top: 10, bottom: 90}; } },
|
|
{ getBoundingClientRect(){ return {top: 320, bottom: 360}; } },
|
|
];
|
|
const blank = _messageViewportIntersectsRenderedRow();
|
|
rows = [
|
|
{ getBoundingClientRect(){ return {top: 120, bottom: 180}; } },
|
|
];
|
|
const visible = _messageViewportIntersectsRenderedRow();
|
|
console.log(JSON.stringify({blank, visible}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["blank"] is False
|
|
assert metrics["visible"] is True
|
|
|
|
|
|
def test_render_messages_has_one_shot_virtual_blank_viewport_fallback():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
render_start = js.index("function renderMessages(options)")
|
|
render_end = js.index("function _toolDisplayName", render_start)
|
|
render_body = js[render_start:render_end]
|
|
|
|
assert "const virtualFallback=!!(options&&options._virtualFallback);" in render_body
|
|
assert "const virtualWindow=virtualFallback" in render_body
|
|
assert "if(_maybeRecoverVirtualizedBlankViewport(options, preserveScroll, virtualWindow)) return;" in render_body
|
|
assert "if(_sessionHtmlCacheSid&&S.session&&S.session.session_id===_sessionHtmlCacheSid){" in js
|
|
assert "_sessionHtmlCache.delete(_sessionHtmlCacheSid);" in js
|
|
assert "renderMessages({preserveScroll:true,_virtualFallback:true});" in js
|
|
|
|
|
|
def test_virtual_blank_viewport_recovery_evicts_stale_cache_before_fallback():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
let deletes = [];
|
|
let renderCalls = [];
|
|
const _sessionHtmlCache = {
|
|
delete(sid){ deletes.push(sid); }
|
|
};
|
|
let _sessionHtmlCacheSid = 'sid-123';
|
|
const S = { session: { session_id: 'sid-123' } };
|
|
function _messageViewportIntersectsRenderedRow(){ return false; }
|
|
function renderMessages(options){ renderCalls.push(options); }
|
|
eval(extractFunc('_maybeRecoverVirtualizedBlankViewport'));
|
|
const recovered = _maybeRecoverVirtualizedBlankViewport({preserveScroll:false, someFlag:true}, true, {virtualized:true});
|
|
console.log(JSON.stringify({recovered, deletes, renderCalls}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert metrics["recovered"] is True
|
|
assert metrics["deletes"] == ["sid-123"]
|
|
assert metrics["renderCalls"] == [{"preserveScroll": True, "_virtualFallback": True}]
|
|
|
|
|
|
def test_same_frame_restore_nudges_virtual_window_when_anchor_row_is_missing():
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + r"""
|
|
const ROW_HEIGHT = 120;
|
|
const TOTAL = 60;
|
|
|
|
// Rows 10-59 are mounted (tail window); rows 0-9 are virtualized out.
|
|
// Anchor points to rawIdx=5, which is in the virtualized zone.
|
|
let mountedStart = 10;
|
|
let scrollTopValue = 0;
|
|
let renderCalls = [];
|
|
let scrollTopHistory = [];
|
|
let _messageVirtualWindowKey = 'stale-key';
|
|
let _programmaticScroll = false;
|
|
let _lastScrollTop = 0;
|
|
let _messageUserUnpinned = true;
|
|
let _scrollPinned = false;
|
|
let _nearBottomCount = 0;
|
|
let _messageVirtualHeightCache = Array.from({length: TOTAL}, () => ROW_HEIGHT);
|
|
let _messageVirtualHeightCacheEntries = [];
|
|
let _messageVirtualHeightCacheLen = TOTAL;
|
|
let _messageVirtualHeightCacheSrc = null;
|
|
let _messageVirtualEstimatedRowHeight = ROW_HEIGHT;
|
|
|
|
function _clearMessageVirtualHeightCache(){}
|
|
function _syncMessageVirtualHeightCache(){}
|
|
|
|
const container = {
|
|
get scrollTop(){ return scrollTopValue; },
|
|
set scrollTop(v){ scrollTopHistory.push(v); scrollTopValue = v; },
|
|
get scrollHeight(){ return TOTAL * ROW_HEIGHT; },
|
|
get clientHeight(){ return 600; },
|
|
getBoundingClientRect(){ return {top: 0, bottom: 600}; },
|
|
querySelector(selector){
|
|
const m = selector && selector.match(/\[data-msg-idx="(\d+)"\]/);
|
|
if(!m) return null;
|
|
const idx = Number(m[1]);
|
|
if(idx < mountedStart || idx >= TOTAL) return null;
|
|
const top = (idx - mountedStart) * ROW_HEIGHT;
|
|
return { getBoundingClientRect(){ return {top, bottom: top + ROW_HEIGHT}; } };
|
|
},
|
|
};
|
|
function $(id){ return id === 'messages' ? container : null; }
|
|
|
|
function _getVisibleMessagesWithIdx(){
|
|
return Array.from({length: TOTAL}, (_, i) => ({rawIdx: i}));
|
|
}
|
|
|
|
function renderMessages(opts){
|
|
renderCalls.push(JSON.parse(JSON.stringify(opts)));
|
|
mountedStart = 0;
|
|
}
|
|
|
|
function _restoreMessageViewportAnchor(anchor, delta){
|
|
const idx = Number(anchor.rawIdx) + Number(delta||0);
|
|
const row = container.querySelector(`[data-msg-idx="${idx}"]`);
|
|
if(!row) return false;
|
|
_programmaticScroll = true;
|
|
return true;
|
|
}
|
|
|
|
eval(extractFunc('_messageVisibleIndexForRawIdx'));
|
|
eval(extractFunc('_messageVirtualScrollTopForVisibleIdx'));
|
|
eval(extractFunc('_restoreMessageScrollSnapshotSameFrame'));
|
|
|
|
const snapshot = {
|
|
anchor: {rawIdx: 5, topOffset: 50},
|
|
top: 100,
|
|
bottom: 6600,
|
|
scrollHeight: 7200,
|
|
pinned: false,
|
|
userUnpinned: true,
|
|
};
|
|
_restoreMessageScrollSnapshotSameFrame(snapshot);
|
|
console.log(JSON.stringify({renderCalls, scrollTopHistory}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
assert len(metrics["renderCalls"]) == 1, (
|
|
"_restoreMessageScrollSnapshotSameFrame must call renderMessages to mount the virtualized-out anchor row"
|
|
)
|
|
assert metrics["renderCalls"][0].get("preserveScroll") is True, (
|
|
"re-render must use preserveScroll:true to avoid scrolling to bottom"
|
|
)
|
|
assert len(metrics["scrollTopHistory"]) >= 1, (
|
|
"scrollTop must be adjusted before re-render to place anchor row in the virtual window"
|
|
)
|
|
# rawIdx=5, visIdx=5: offset=5*120=600, viewport=600, scrollTop=round(600-600*0.35)=390
|
|
assert metrics["scrollTopHistory"][0] == 390
|
|
|
|
|
|
def test_virtualize_transcript_opt_out_forces_full_render_window():
|
|
"""#4325: when window._virtualizeTranscript===false, _currentMessageVirtualWindow
|
|
must return a non-virtualized full window even for a long (>threshold) transcript,
|
|
so the whole transcript renders. When true/undefined it virtualizes as before."""
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
source = _extract_func_script(js) + """
|
|
const MESSAGE_VIRTUAL_THRESHOLD_ROWS = 80;
|
|
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140;
|
|
const MESSAGE_VIRTUAL_BUFFER_PX = 900;
|
|
let _messageVirtualHeightCache = [];
|
|
let _messageVirtualEstimatedRowHeight = 140;
|
|
function _syncMessageVirtualHeightCache(){ /* no-op for the test */ }
|
|
function $(id){ return {scrollTop: 5000, clientHeight: 720}; }
|
|
const window = {};
|
|
eval(extractFunc('_messageVirtualWindow'));
|
|
eval(extractFunc('_currentMessageVirtualWindow'));
|
|
// 200 visible messages — well over the 80 threshold
|
|
const visWithIdx = Array.from({length: 200}, (_, i) => ({rawIdx: i}));
|
|
// OFF: opt-out → full render
|
|
window._virtualizeTranscript = false;
|
|
const off = _currentMessageVirtualWindow(visWithIdx, 50);
|
|
// ON (default): virtualizes
|
|
window._virtualizeTranscript = true;
|
|
const on = _currentMessageVirtualWindow(visWithIdx, 50);
|
|
// UNDEFINED: also virtualizes (opt-out only when explicitly false)
|
|
delete window._virtualizeTranscript;
|
|
const undef = _currentMessageVirtualWindow(visWithIdx, 50);
|
|
console.log(JSON.stringify({off, on, undef}));
|
|
"""
|
|
metrics = json.loads(_run_node(source))
|
|
# OFF → full, non-virtualized window covering every row
|
|
assert metrics["off"]["virtualized"] is False
|
|
assert metrics["off"]["start"] == 0
|
|
assert metrics["off"]["end"] == 200
|
|
assert metrics["off"]["topPad"] == 0
|
|
assert metrics["off"]["bottomPad"] == 0
|
|
# ON → virtualized (only a window of the 200 rows)
|
|
assert metrics["on"]["virtualized"] is True
|
|
assert metrics["on"]["end"] - metrics["on"]["start"] < 200
|
|
# UNDEFINED → still virtualizes (opt-out is explicit-false only)
|
|
assert metrics["undef"]["virtualized"] is True
|
|
|
|
|
|
def test_virtualize_transcript_gate_present_in_current_window_fn():
|
|
"""The opt-out gate must live in _currentMessageVirtualWindow (the single
|
|
chokepoint), guarding on window._virtualizeTranscript===false."""
|
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
|
start = js.index("function _currentMessageVirtualWindow(")
|
|
body = js[start:start + 900]
|
|
assert "_virtualizeTranscript===false" in body, (
|
|
"opt-out gate must check window._virtualizeTranscript===false in "
|
|
"_currentMessageVirtualWindow"
|
|
)
|
|
assert "virtualized:false" in body, "gate must return a non-virtualized window when opted out"
|