diff --git a/static/ui.js b/static/ui.js index 8d09cccd..e2c7b690 100644 --- a/static/ui.js +++ b/static/ui.js @@ -15373,7 +15373,11 @@ function _loadedCompactionMarkerRawIdxs(messages){ // A settled compaction whose marker sits before the server-loaded tail must // remain visible at the top of that tail. Anchoring it to an old assistant/tool // turn can bury it inside a hidden worklog, which makes compaction look absent. -function _selectCompactionCardPlacements(markerRawIdxs,firstRenderedRawIdx){ +// `currentSummaryFallback` is true when the session's current summary matches +// no loaded marker and therefore renders as its own settled card. That card is +// the newest compaction the user can see, so it owns the preserved task cards +// and no marker card may attach them again. +function _selectCompactionCardPlacements(markerRawIdxs,firstRenderedRawIdx,currentSummaryFallback=false){ const preWindowMarkers=[]; const inlineMarkers=[]; const boundary=Number.isFinite(firstRenderedRawIdx)?firstRenderedRawIdx:-1; @@ -15383,11 +15387,13 @@ function _selectCompactionCardPlacements(markerRawIdxs,firstRenderedRawIdx){ if(rawIdx{ const markerMsg=S.messages[markerRawIdx]; let raw=''; @@ -17098,8 +17114,8 @@ function renderMessages(options){ }; const preWindowCompactionCards=compactionPlacements.preWindowMarkers.map(markerRawIdx=>_compactionCardEntry(markerRawIdx,'pre-window')); const compactionCardNodes=compactionPlacements.inlineMarkers.map(markerRawIdx=>_compactionCardEntry(markerRawIdx,'inline')); - const referenceNode=(!compressionState && loadedCompactionRawIdxs.length===0 && _shouldShowSettledCompressionReference(referenceText) && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey || sessionCompressionSummary)) - ? (()=>{const row=document.createElement('div');row.innerHTML=`
${_compressionReferenceCardHtml(referenceText,false)}${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}
`;const node=row.firstElementChild;if(node&&preservedCompressionTaskMessages.length) node.setAttribute('data-compaction-task-owner','1');return node;})() + const referenceNode=showCurrentSummaryFallback + ? (()=>{const row=document.createElement('div');row.innerHTML=`
${_compressionReferenceCardHtml(referenceText,false)}${referenceNodeOwnsTasks?_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages):''}
`;const node=row.firstElementChild;if(node&&referenceNodeOwnsTasks) node.setAttribute('data-compaction-task-owner','1');return node;})() : null; let referenceNodePinnedAtTop=false; let preservedCompressionTaskOwnerNode=null; @@ -17142,7 +17158,7 @@ function renderMessages(options){ // Keep the settled compacted-context card immediately visible in a long, // tail-loaded conversation. Put it in flow (not inside an old tool turn). referenceNodePinnedAtTop=_pinSettledCompressionReferenceAtTop(inner,referenceNode,referenceMessageRawIdx); - if(referenceNodePinnedAtTop&&referenceNode?.parentElement&&preservedCompressionTaskMessages.length){ + if(referenceNodePinnedAtTop&&referenceNode?.parentElement&&referenceNodeOwnsTasks){ preservedCompressionTaskOwnerNode=referenceNode; } } @@ -17780,7 +17796,7 @@ function renderMessages(options){ const referenceInserted=referenceNode&&referenceMessageRawIdx>=0 ?_insertCompressionLikeNodeByRawIdx(referenceNode,referenceMessageRawIdx) :_insertCompressionLikeNode(referenceNode); - if(referenceInserted&&referenceNode?.parentElement&&preservedCompressionTaskMessages.length){ + if(referenceInserted&&referenceNode?.parentElement&&referenceNodeOwnsTasks){ preservedCompressionTaskOwnerNode=referenceNode; } } diff --git a/tests/test_auto_compression_card.py b/tests/test_auto_compression_card.py index bbe16607..3e336343 100644 --- a/tests/test_auto_compression_card.py +++ b/tests/test_auto_compression_card.py @@ -1079,7 +1079,7 @@ def test_context_anchor_reference_uses_session_summary_fallback(): assert "const referenceText=(()=>{" in src assert "if(!referenceMessage) return sessionCompressionSummary;" in src assert "return segment!==null?segment:raw;" in src - assert "loadedCompactionRawIdxs.length===0 && _shouldShowSettledCompressionReference(referenceText)" in src + assert "referenceMessageRawIdx<0 && _shouldShowSettledCompressionReference(referenceText)" in src assert "!_isContextCompactionText(referenceText)" in src diff --git a/tests/test_compaction_card_display.py b/tests/test_compaction_card_display.py index f2595e25..5e5a8ff7 100644 --- a/tests/test_compaction_card_display.py +++ b/tests/test_compaction_card_display.py @@ -136,6 +136,20 @@ assert.deepStrictEqual( _selectCompactionCardPlacements([], 50), {{preWindowMarkers: [], inlineMarkers: [], taskOwner: null}}, ); +// A current-summary fallback (no loaded marker matches the session summary) +// owns the preserved tasks even when stale markers are still loaded. +assert.deepStrictEqual( + _selectCompactionCardPlacements([3, 11, 49, 88], 50, true), + {{ + preWindowMarkers: [3, 11, 49], + inlineMarkers: [88], + taskOwner: {{kind: 'current-summary', rawIdx: -1}}, + }}, +); +assert.deepStrictEqual( + _selectCompactionCardPlacements([], 50, true), + {{preWindowMarkers: [], inlineMarkers: [], taskOwner: {{kind: 'current-summary', rawIdx: -1}}}}, +); """ _run_node(script) diff --git a/tests/test_compaction_current_summary_fallback.py b/tests/test_compaction_current_summary_fallback.py new file mode 100644 index 00000000..d0ec6aaf --- /dev/null +++ b/tests/test_compaction_current_summary_fallback.py @@ -0,0 +1,528 @@ +"""Render-level regression: stale compaction markers must not hide the current summary. + +A session can carry historical ``[CONTEXT COMPACTION]`` markers in its loaded +transcript while ``session.compression_anchor_summary`` already describes a +newer compaction that no loaded marker references. ``renderMessages()`` must +still render that current summary as its own settled card (as ``master`` did) +and the preserved task cards must be attached to exactly one owner. + +The scenario drives the real ``renderMessages()`` through the same Node +harness used by ``tests/test_anchor_fallback_ownership.py``; only the DOM +shim gains a minimal ``innerHTML`` → ``firstElementChild`` parser so the +compaction card nodes built by ``renderMessages()`` are real elements. +""" + +from __future__ import annotations + +import json +import textwrap + +from tests.test_anchor_fallback_ownership import ( + _function_source, + _run_node_script, + _ui_js, +) + + +_REAL_FUNCTIONS = ( + "_transparentStreamOrderedParts", + "_legacySettledFallbackHasToolMetadata", + "_isContextCompactionText", + "_isContextCompactionMessage", + "_compactionSummarySegment", + "_isPreservedCompressionTaskListMarkerText", + "_isPreservedCompressionTaskListMessage", + "_preservedCompressionTaskListPreview", + "_latestTodoToolItems", + "_hasActiveTodoItems", + "_latestPreservedCompressionTaskListMessages", + "_compressionStatusCardHtml", + "_preservedCompressionTaskListCardHtml", + "_preservedCompressionTaskListCardsHtml", + "_latestCompressionReferenceMessage", + "_shouldShowSettledCompressionReference", + "_compactionDigestText", + "_compactionCardPreview", + "_compressionReferenceCardHtml", + "_loadedCompactionMarkerRawIdxs", + "_selectCompactionCardPlacements", + "_insertCompactionCardNodes", + "_insertPreservedCompressionTaskFallback", + "_pinCompactionCardAtTop", + "_pinSettledCompressionReferenceAtTop", + "renderMessages", +) + +_DOM_SHIM = r""" +class FakeClassList { + constructor(el) { this.el = el; } + _set() { return new Set(String(this.el.className || '').split(/\s+/).filter(Boolean)); } + contains(name) { return this._set().has(name); } + add(...names) { + const set = this._set(); + names.forEach((name) => set.add(name)); + this.el.className = Array.from(set).join(' '); + } + remove(...names) { + const set = this._set(); + names.forEach((name) => set.delete(name)); + this.el.className = Array.from(set).join(' '); + } + toggle(name) { + const set = this._set(); + if (set.has(name)) set.delete(name); else set.add(name); + this.el.className = Array.from(set).join(' '); + return set.has(name); + } +} +function unescapeHtml(value) { + return String(value) + .replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<') + .replace(/>/g, '>').replace(/&/g, '&'); +} +// Minimal HTML → FakeElement tree parser. Only used when renderMessages() +// reads `firstElementChild` after assigning innerHTML (the compaction / +// reference / preserved-task card nodes). Text nodes are dropped; the +// assertions below rely on data-* attributes only. +function parseHtmlInto(parent, html) { + const tagRe = /<(\/)?([A-Za-z][\w-]*)((?:\s+[\w:-]+(?:="[^"]*")?)*)\s*(\/)?>/g; + const stack = [parent]; + let match; + while ((match = tagRe.exec(html))) { + if (match[1]) { + if (stack.length > 1) stack.pop(); + continue; + } + const el = new FakeElement(match[2]); + const attrRe = /([\w:-]+)(?:="([^"]*)")?/g; + let attr; + while ((attr = attrRe.exec(match[3] || ''))) { + el.setAttribute(attr[1], attr[2] === undefined ? '' : unescapeHtml(attr[2])); + } + stack[stack.length - 1].appendChild(el); + if (!match[4]) stack.push(el); + } +} +class FakeElement { + constructor(tag = 'div') { + this.tagName = tag.toUpperCase(); + this.children = []; + this.parentElement = null; + this.dataset = {}; + this.attributes = {}; + this.className = ''; + this.id = ''; + this.hidden = false; + this._innerHTML = ''; + this._pendingHtml = null; + this.style = {}; + this.classList = new FakeClassList(this); + } + set innerHTML(value) { + this._innerHTML = String(value); + for (const child of this.children) child.parentElement = null; + this.children = []; + this._pendingHtml = this._innerHTML ? this._innerHTML : null; + } + get innerHTML() { return this._innerHTML; } + get firstElementChild() { + if (this._pendingHtml !== null) { + const html = this._pendingHtml; + this._pendingHtml = null; + parseHtmlInto(this, html); + } + return this.children[0] || null; + } + appendChild(child) { + if (child.parentElement) child.remove(); + child.parentElement = this; + this.children.push(child); + return child; + } + insertBefore(child, ref) { + if (child.parentElement) child.remove(); + child.parentElement = this; + const idx = this.children.indexOf(ref); + if (idx < 0) this.children.push(child); + else this.children.splice(idx, 0, child); + return child; + } + remove() { + if (!this.parentElement) return; + const idx = this.parentElement.children.indexOf(this); + if (idx >= 0) this.parentElement.children.splice(idx, 1); + this.parentElement = null; + } + setAttribute(name, value) { + this.attributes[name] = String(value); + if (name === 'id') this.id = String(value); + if (name === 'class') this.className = String(value); + if (name.startsWith('data-')) this.dataset[dataKey(name)] = String(value); + } + getAttribute(name) { + if (name === 'id') return this.id || null; + if (name === 'class') return this.className || null; + if (name.startsWith('data-')) { + const value = this.dataset[dataKey(name)]; + return value === undefined ? null : String(value); + } + return this.attributes[name] === undefined ? null : this.attributes[name]; + } + removeAttribute(name) { + delete this.attributes[name]; + if (name.startsWith('data-')) delete this.dataset[dataKey(name)]; + } + matches(selector) { return matchesSelector(this, selector); } + closest(selector) { + let node = this; + while (node) { + if (matchesSelector(node, selector)) return node; + node = node.parentElement; + } + return null; + } + querySelectorAll(selector) { + const found = []; + const visit = (node) => { + for (const child of node.children) { + if (matchesSelector(child, selector)) found.push(child); + visit(child); + } + }; + visit(this); + return found; + } + querySelector(selector) { + return this.querySelectorAll(selector)[0] || null; + } + insertAdjacentHTML() {} +} +function dataKey(name) { + return String(name).slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); +} +function matchesSelector(el, selector) { + return String(selector || '').split(',').some((part) => matchesSimple(el, part.trim())); +} +function matchesSimple(el, selector) { + if (!selector) return false; + const negated = []; + const baseSelector = selector.replace(/:not\(([^()]*)\)/g, (_, inner) => { + negated.push(String(inner || '').trim()); + return ''; + }).trim(); + if (negated.some((inner) => inner && matchesSimple(el, inner))) return false; + if (!baseSelector) return true; + const classMatches = [...baseSelector.matchAll(/\.([A-Za-z0-9_-]+)/g)].map((m) => m[1]); + if (classMatches.some((name) => !el.classList.contains(name))) return false; + const attrMatches = [...baseSelector.matchAll(/\[([^=\]]+)(?:=["']?([^"'\]]+)["']?)?\]/g)]; + for (const match of attrMatches) { + const value = el.getAttribute(match[1]); + if (value === null) return false; + if (match[2] !== undefined && String(value) !== String(match[2])) return false; + } + const idMatch = baseSelector.match(/#([A-Za-z0-9_-]+)/); + if (idMatch && el.id !== idMatch[1]) return false; + const tagMatch = baseSelector.match(/^[A-Za-z][A-Za-z0-9_-]*/); + if (tagMatch && el.tagName.toLowerCase() !== tagMatch[0].toLowerCase()) return false; + return true; +} + +const elements = { + msgInner: new FakeElement('div'), + emptyState: new FakeElement('div'), +}; +global.window = {}; +global.document = { + createElement: (tag) => new FakeElement(tag), + getElementById: (id) => elements[id] || null, +}; +global.performance = { now: () => 1 }; +global.requestAnimationFrame = (fn) => fn(); +global.setTimeout = (fn) => fn(); +function $(id) { return elements[id] || null; } +function isTransparentStream() { return false; } +function isCompactWorklogMode() { return true; } +function isSimplifiedToolCalling() { return true; } +function t(key) { return key; } +function li() { return ''; } +function esc(value) { + return String(value == null ? '' : value) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} +function msgContent(message) { + if (Array.isArray(message.content)) { + return message.content.filter((p) => p && p.type === 'text').map((p) => p.text || p.content || '').join('\n'); + } + return String(message.content || ''); +} +let S; +const INFLIGHT = {}; +let _loadingSessionId = null; +let _messageRenderWindowSid = null; +let _messageUserUnpinned = false; +let _programmaticScroll = false; +let _programmaticScrollSetAt = 0; +let _sessionHtmlCacheSid = null; +let _messagesTruncated = false; +let _oldestIdx = 0; +let virtualStart = 0; +const _sessionHtmlCache = new Map(); +const _recycleStash = new Map(); +const _msgNodeRecycleEnabled = false; +const _recycleResetAttrs = []; +const _ERR_MSG_RE = /__never__/; + +function _captureMessageScrollSnapshot() { return null; } +function _resetMessageRenderWindow(sid) { _messageRenderWindowSid = sid; } +function _getVisibleMessagesWithIdx() { + return S.messages.map((m, rawIdx) => ( + m && m.role !== 'tool' && !_isContextCompactionMessage(m) && !_isPreservedCompressionTaskListMessage(m) + ) ? { m, rawIdx } : null).filter(Boolean); +} +function _messageVirtualKeepTailCount() { return 20; } +function _currentMessageVirtualWindow(vis) { + const start = Math.min(virtualStart, vis.length); + return { + virtualized: start > 0, + start, + end: vis.length, + topPad: start > 0 ? 120 * start : 0, + bottomPad: 0, + total: vis.length, + tailStart: vis.length, + }; +} +function _messageVirtualWindowKeyFor() { return 'all'; } +function _messageRenderCacheSignature() { return 'sig'; } +function _compressionStateForCurrentSession() { return null; } +function clearCompressionUi() {} +function _handoffStateForCurrentSession() { return null; } +function _captureWorklogDetailDisclosureState() { return null; } +function _applySessionNavigationPrefs() {} +function _messageVirtualSpacer() { return new FakeElement('div'); } +function _wireMessageWindowLoadEarlierButton() {} +function _compressionAnchorIndex() { return null; } +function _assistantTurnFinalVisibleContentMap() { return new Map(); } +function _assistantTurnVisibleContentMap() { return new Map(); } +function _engineAwareCompressionCopy() { + return { label: 'context_compaction_label', preview: 'reference_only_label' }; +} +function _createAssistantTurn() { + const turn = new FakeElement('div'); + turn.className = 'assistant-turn'; + const blocks = new FakeElement('div'); + blocks.className = 'assistant-turn-blocks'; + turn.appendChild(blocks); + return turn; +} +function _assistantTurnBlocks(turn) { return turn ? turn.querySelector('.assistant-turn-blocks') : null; } +function _setLatestAssistantTurnLandmark() {} +function _assistantRoleHtml() { return ''; } +function _userMessageDomId(rawIdx) { return `user-${rawIdx}`; } +function _messageSessionIndexForRawIdx(rawIdx) { return rawIdx; } +function _messageViewportAnchorKeyForMessage() { return 'k'; } +function _stripAttachedFilesMarkerForDisplay(value) { return String(value || ''); } +function _stripWorkspaceDisplayPrefix(value) { return String(value || ''); } +function _stripLeadingAssistantThinkingMarkup(value) { return String(value || ''); } +function _getCachedRender(value) { return String(value || ''); } +function _formatInServerTz() { return ''; } +function _formatMessageFooterTimestamp() { return ''; } +function _questionJumpButtonHtml() { return ''; } +function _formatTurnTps() { return ''; } +function isTpsDisplayEnabled() { return false; } +function _renderAttachmentHtml() { return ''; } +function _isMarkerOnlyAssistantCompressionMessage() { return false; } +function _isAssistantEmptyPlaceholderContent() { return false; } +function _assistantTurnAnchorSettledFinalAnswer() { return null; } +function _worklogReasoningTextFromMessage() { return ''; } +function _assistantMessageBelongsInWorklog() { return false; } +function _assistantThinkingBelongsInWorklog() { return false; } +function _assistantReasoningPayloadText() { return ''; } +function _statusCardHtml() { return ''; } +function _collectHandoffSummaryStates() { return []; } +function _handoffCardsNode() { return null; } +function renderCompressionUi() {} +function _assistantToolAnchorIdxForMessage(messages, rawIdx) { return rawIdx; } +function _cliToolResultSnippet(value) { return String(value || ''); } +function _cliPatchSnippetFromArgs() { return ''; } +function _cliToolCardSnippet(value) { return String(value || ''); } +function _cliToolCardHasDiffSnippet() { return false; } +function _toolArgsSnapshot(args) { return args || {}; } +function _worklogReasonHtmlFromAnchor() { return ''; } +function _normalizeThinkingEchoCompare(value) { return String(value || ''); } +function _toolWorklogListEl(group) { return group; } +function ensureActivityGroup(parent) { + const group = new FakeElement('div'); + if (parent) parent.appendChild(group); + return group; +} +function _appendWorklogStep() {} +function _syncToolCallGroupSummary() {} +function _restoreWorklogDetailDisclosureState() {} +function _scrollAfterMessageRender() {} +function _maybeRecoverVirtualizedBlankViewport() { return false; } +function _updateMessageVirtualMeasurements() {} +function postProcessRenderedMessages() {} +function _postProcessWithAnchorSuppression() {} +function _formatGatewayModelLabel() { return ''; } +function _gatewayRoutingFailoverText() { return ''; } +function _gatewayModelWarningText() { return ''; } +function _usedModelTurnChipLabel() { return ''; } +function _formatTurnDuration() { return ''; } +function _renderSettledAnchorSceneForMessage() { return false; } +""" + +_SCENARIO = r""" +const TOTAL = 220; +const STALE_MARKER_IDXS = [40, 100, 160]; +const TASK_LIST_IDX = 161; +const CURRENT_TOKEN = 'CURRENT-SUMMARY-TOKEN-7124'; +const TASK_MARKER = '[Your active task list was preserved across context compression]'; + +function staleMarker(rawIdx) { + return '[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted into the summary below.\n' + + '## Historical Task Snapshot\nUser asked: "stale request ' + rawIdx + '"\n' + + '## Goal\nSTALE-MARKER-' + rawIdx + ' historical state\n--- END OF CONTEXT SUMMARY ---'; +} +function buildMessages() { + const messages = []; + for (let i = 0; i < TOTAL; i++) { + if (STALE_MARKER_IDXS.includes(i)) { + messages.push({ role: 'assistant', content: staleMarker(i) }); + } else if (i === TASK_LIST_IDX) { + messages.push({ role: 'user', content: TASK_MARKER + '\n- [ ] finish the compaction fix' }); + } else if (i % 2 === 0) { + messages.push({ role: 'user', content: 'question ' + i }); + } else { + messages.push({ role: 'assistant', content: 'answer ' + i }); + } + } + return messages; +} +function render(opts) { + elements.msgInner = new FakeElement('div'); + _sessionHtmlCache.clear(); + _sessionHtmlCacheSid = null; + virtualStart = opts.virtualStart || 0; + _messagesTruncated = !!opts.truncated; + _oldestIdx = opts.truncated ? 50 : 0; + S = { + session: { + session_id: opts.sid, + compression_anchor_summary: opts.summary, + }, + messages: buildMessages(), + toolCalls: [], + busy: false, + }; + renderMessages(); + const inner = elements.msgInner; + const cards = inner.querySelectorAll('[data-compression-card]'); + const rawText = (node) => String(node.getAttribute('data-raw-text') || ''); + const summaryCards = cards.filter((node) => rawText(node).includes(CURRENT_TOKEN)); + const taskCards = cards.filter((node) => rawText(node).startsWith(TASK_MARKER)); + const owners = inner.querySelectorAll('[data-compaction-task-owner]'); + const ownerHasSummary = owners.length === 1 + && owners[0].querySelectorAll('[data-compression-card]').some((node) => rawText(node).includes(CURRENT_TOKEN)); + const ownerHasTask = owners.length === 1 + && owners[0].querySelectorAll('[data-compression-card]').some((node) => rawText(node).startsWith(TASK_MARKER)); + const placements = inner.querySelectorAll('[data-compaction-placement]'); + const summaryTopIdx = summaryCards.length + ? inner.children.findIndex((child) => child.querySelectorAll('[data-compression-card]').some((node) => rawText(node).includes(CURRENT_TOKEN))) + : -1; + const loadOlderIdx = inner.children.findIndex((child) => child.id === 'loadOlderIndicator'); + return { + renderedRows: inner.querySelectorAll('[data-msg-idx]').length, + currentSummaryVisible: summaryCards.length > 0, + currentSummaryCards: summaryCards.length, + staleCards: placements.map((node) => node.getAttribute('data-compaction-placement')), + staleCardTexts: placements.map((node) => { + const card = node.querySelector('[data-compression-card]'); + return card ? (/STALE-MARKER-(\d+)/.exec(rawText(card))?.[1] || null) : null; + }), + taskCards: taskCards.length, + taskOwners: owners.length, + ownerHasSummary, + ownerHasTask, + standaloneFallbacks: inner.querySelectorAll('[data-compaction-task-fallback]').length, + summaryTopIdx, + loadOlderIdx, + }; +} + +const NEWER_SUMMARY = 'Compacted context: ' + CURRENT_TOKEN + ' — newest state after the last compaction'; +const fullWindow = render({ sid: 'full', summary: NEWER_SUMMARY }); +// Visible index 170 maps to raw idx >= 174 (three markers and the task-list +// message are not renderable rows), so every stale marker is pre-window. +const virtualWindow = render({ sid: 'virtual', summary: NEWER_SUMMARY, virtualStart: 170 }); +const truncatedTail = render({ sid: 'truncated', summary: NEWER_SUMMARY, truncated: true }); +// Control: the current summary IS referenced by the latest loaded marker, so +// the marker card renders it and no second (fallback) card may appear. +const matchingSummary = render({ sid: 'matching', summary: 'STALE-MARKER-160 historical state' }); +const matchingCards = elements.msgInner.querySelectorAll('[data-compression-card]') + .filter((node) => String(node.getAttribute('data-raw-text') || '').includes('STALE-MARKER-160')); +console.log(JSON.stringify({ + fullWindow, + virtualWindow, + truncatedTail, + matchingSummary: { + ...matchingSummary, + latestMarkerCards: matchingCards.length, + }, +})); +""" + + +def _scenario_script() -> str: + js = _ui_js() + evals = "\n".join( + f"eval({json.dumps(_function_source(js, name))});" for name in _REAL_FUNCTIONS + ) + return textwrap.dedent(_DOM_SHIM) + "\n" + evals + "\n" + textwrap.dedent(_SCENARIO) + + +def test_stale_markers_do_not_suppress_current_summary_and_task_owner_is_unique(): + result = json.loads(_run_node_script(_scenario_script())) + + full = result["fullWindow"] + assert full["renderedRows"] > 200 + assert full["currentSummaryVisible"] is True + assert full["currentSummaryCards"] == 1 + assert full["staleCards"] == ["inline", "inline", "inline"] + assert full["staleCardTexts"] == ["40", "100", "160"] + assert full["taskCards"] == 1 + assert full["taskOwners"] == 1 + assert full["ownerHasSummary"] is True + assert full["ownerHasTask"] is True + assert full["standaloneFallbacks"] == 0 + + virtual = result["virtualWindow"] + assert virtual["currentSummaryVisible"] is True + assert virtual["currentSummaryCards"] == 1 + assert virtual["staleCards"] == ["pre-window", "pre-window", "pre-window"] + assert virtual["staleCardTexts"] == ["40", "100", "160"] + assert virtual["taskCards"] == 1 + assert virtual["taskOwners"] == 1 + assert virtual["ownerHasSummary"] is True + assert virtual["standaloneFallbacks"] == 0 + + truncated = result["truncatedTail"] + assert truncated["currentSummaryVisible"] is True + assert truncated["currentSummaryCards"] == 1 + assert truncated["loadOlderIdx"] >= 0 + # The settled current-summary card is pinned directly under the + # load-earlier affordance, before the stale marker cards and rows. + assert truncated["summaryTopIdx"] == truncated["loadOlderIdx"] + 1 + assert truncated["taskCards"] == 1 + assert truncated["taskOwners"] == 1 + assert truncated["ownerHasSummary"] is True + assert truncated["standaloneFallbacks"] == 0 + + matching = result["matchingSummary"] + assert matching["currentSummaryVisible"] is False + assert matching["latestMarkerCards"] == 1 + assert matching["staleCards"] == ["inline", "inline", "inline"] + assert matching["taskCards"] == 1 + assert matching["taskOwners"] == 1 + assert matching["ownerHasTask"] is True + assert matching["standaloneFallbacks"] == 0