From 26994648d2e765fa2c046719e342b9f4527248dd Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Sat, 22 Aug 2026 19:41:44 +0200 Subject: [PATCH] Lock down conversation viewport invariants --- tests/ConversationLayoutTest.cpp | 69 +++++++++++++++++++++++++++----- tests/Phase1ThreadTurnUxTest.cpp | 22 ++++++++-- 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/tests/ConversationLayoutTest.cpp b/tests/ConversationLayoutTest.cpp index fedbe25..204a585 100644 --- a/tests/ConversationLayoutTest.cpp +++ b/tests/ConversationLayoutTest.cpp @@ -41,7 +41,6 @@ struct ConversationWidgetTestAccess ConversationWidget& conversation, QWidget* anchor, int anchorY) { auto* scroll = conversation.scrollArea->verticalScrollBar(); - scroll->setValue(scroll->minimum()); conversation.followingLatest = false; conversation.pinLatestDuringLayout = true; conversation.layoutSettleTimer->start(60'000); @@ -80,6 +79,14 @@ struct ConversationWidgetTestAccess conversation.pendingTimelineShrink = false; conversation.scrollArea->viewport()->setUpdatesEnabled(true); } + + static void settleAnchoredReconciliation(ConversationWidget& conversation) + { + conversation.layoutSettleTimer->stop(); + conversation.layoutSettleTimer->setInterval(16); + conversation.pinLatestDuringLayout = false; + conversation.settleTimelineLayout(); + } }; } // namespace codexui @@ -1056,6 +1063,9 @@ bool testPointerPreservingAppend() beforeFixture.turns.front().messages.push_back( {"item-append-257", frontend::ThreadItemKind::AgentMessage, "final answer"}); const client::State after = makeState({beforeFixture}); + ThreadFixture latestFixture = beforeFixture; + latestFixture.turns.front().messages.back().text = "updated final answer"; + const client::State latest = makeState({latestFixture}); int latestPresentationRequests = 0; QObject::connect(&conversation, &codexui::ConversationWidget::latestPresentationRequested, @@ -1063,6 +1073,8 @@ bool testPointerPreservingAppend() [&latestPresentationRequests] { ++latestPresentationRequests; }); conversation.render(after, QStringLiteral("append")); settleTimeline(); + conversation.render(latest, QStringLiteral("append")); + settleTimeline(); QWidget* host = timeline(conversation); const int frozenAnchorY = scroll && readingAnchor @@ -1072,14 +1084,15 @@ bool testPointerPreservingAppend() passed &= expect(readingHistory && evicted && survivor && survivor.data() == survivorAddress && qAbs(frozenAnchorY - anchorYBefore) <= 2 - && !segment(conversation, QStringLiteral("message:item-append-256")), - "an off-bottom reader must see a completely frozen presentation while canonical state advances"); + && !segment(conversation, QStringLiteral("message:item-append-256")) + && !hasLabel(conversation, QStringLiteral("updated final answer")), + "an off-bottom reader must see a completely frozen presentation across repeated canonical updates"); if (scroll) scroll->verticalScrollBar()->setValue(scroll->verticalScrollBar()->maximum()); settleEvents(); passed &= expect(latestPresentationRequests == 1, "returning to the tail must request exactly one latest authoritative presentation"); - conversation.render(after, QStringLiteral("append")); + conversation.render(latest, QStringLiteral("append")); settleTimeline(); passed &= expect(!evicted && survivor && survivor.data() == survivorAddress @@ -1088,7 +1101,7 @@ bool testPointerPreservingAppend() passed &= expect(segment(conversation, QStringLiteral("message:item-append-256")) != nullptr && segment(conversation, QStringLiteral("message:item-append-257")) != nullptr && hasLabel(conversation, QStringLiteral("reflected prompt")) - && hasLabel(conversation, QStringLiteral("final answer")), + && hasLabel(conversation, QStringLiteral("updated final answer")), "the reflected prompt and final answer must append at the timeline tail"); passed &= expect(host && host->property("renderedTimelineItems").toLongLong() <= host->property("maximumRenderedItems").toLongLong(), @@ -1099,13 +1112,24 @@ bool testPointerPreservingAppend() followingConversation.show(); followingConversation.render(before, QStringLiteral("append")); settleTimeline(); + QPointer followedSurvivor = segment( + followingConversation, QStringLiteral("message:item-append-10")); + QWidget* const followedSurvivorAddress = followedSurvivor.data(); followingConversation.render(after, QStringLiteral("append")); + followingConversation.render(latest, QStringLiteral("append")); settleTimeline(); QScrollArea* followingScroll = followingConversation.findChild(); passed &= expect(followingScroll && followingScroll->verticalScrollBar()->value() - == followingScroll->verticalScrollBar()->maximum(), - "a followed append must settle smoothly at the newest timeline content"); + == followingScroll->verticalScrollBar()->maximum() + && followedSurvivor + && followedSurvivor.data() == followedSurvivorAddress + && followedSurvivor.data() + == segment(followingConversation, + QStringLiteral("message:item-append-10")) + && hasLabel(followingConversation, + QStringLiteral("updated final answer")), + "rapid followed updates must retain widgets and settle at the newest timeline content"); return passed; } @@ -1236,7 +1260,19 @@ bool testKeyedSegmentInsertion() && activityLayout->indexOf(activity.data()) == 1, "reordering unchanged segment keys must move the original QWidgets into the new order"); - constexpr int anchoredY = 37; + QScrollArea* activityScroll = activityConversation.findChild(); + if (activityScroll && activity) + { + const int currentY = activityScroll->viewport()->mapFromGlobal( + activity->mapToGlobal(QPoint{})).y(); + auto* bar = activityScroll->verticalScrollBar(); + bar->setValue(qBound(0, bar->value() + currentY - 96, bar->maximum())); + settleEvents(); + } + const int anchoredY = activityScroll && activity + ? activityScroll->viewport()->mapFromGlobal( + activity->mapToGlobal(QPoint{})).y() + : 0; const bool anchorPathAvailable = codexui::ConversationWidgetTestAccess::primeViewportAnchor( activityConversation, activity.data(), anchoredY); @@ -1247,15 +1283,26 @@ bool testKeyedSegmentInsertion() activityConversation.render( makeState({activityFixture}), QStringLiteral("keyed-activity-growth")); - passed &= expect( + const bool survivingAnchorTracked = anchorPathAvailable && codexui::ConversationWidgetTestAccess::viewportAnchor( activityConversation) == activity.data() && codexui::ConversationWidgetTestAccess::viewportAnchorY( activityConversation) - == anchoredY, - "reordering a surviving segment must preserve its pending viewport anchor and offset"); + == anchoredY; + codexui::ConversationWidgetTestAccess::settleAnchoredReconciliation( + activityConversation); + const int settledAnchorY = activityScroll && activity + ? activityScroll->viewport()->mapFromGlobal( + activity->mapToGlobal(QPoint{})).y() + : 0; + passed &= expect( + survivingAnchorTracked && activity + && qAbs(settledAnchorY - anchoredY) <= 2 + && !codexui::ConversationWidgetTestAccess::viewportAnchor( + activityConversation), + "reordering a surviving segment must preserve its widget and observable viewport position"); codexui::ConversationWidgetTestAccess::primeViewportAnchor( activityConversation, activity.data(), anchoredY); diff --git a/tests/Phase1ThreadTurnUxTest.cpp b/tests/Phase1ThreadTurnUxTest.cpp index fb99156..1ae4e2b 100644 --- a/tests/Phase1ThreadTurnUxTest.cpp +++ b/tests/Phase1ThreadTurnUxTest.cpp @@ -186,9 +186,12 @@ bool testUpcomingTurnCanonicalRebase() auto* cwd = dock.findChild(QStringLiteral("upcomingWorkspace")); auto* settings = dock.findChild(QStringLiteral("upcomingTurnSettings")); auto* composer = dock.findChild(QStringLiteral("upcomingComposer")); + auto* editor = dock.findChild(QStringLiteral("upcomingPromptEditor")); + auto* send = dock.findChild(QStringLiteral("upcomingSendButton")); + const auto stableControlTree = dock.findChildren(); bool passed = expect(model && effort && cwd, "the upcoming-turn canonical controls must be discoverable"); - if (!model || !effort || !cwd || !settings || !composer) + if (!model || !effort || !cwd || !settings || !composer || !editor || !send) return false; const int stableBaseHeight = dock.height(); const int stableComposerTop = composer->geometry().top(); @@ -228,6 +231,13 @@ bool testUpcomingTurnCanonicalRebase() && cwd->text() == QStringLiteral("/workspace/refreshed") && effort->currentData().toString() == QStringLiteral("xhigh"), "same-thread refreshes must rebase untouched controls without overwriting a user change"); + passed &= expect(model == dock.findChild(QStringLiteral("upcomingModel")) + && effort == dock.findChild(QStringLiteral("upcomingReasoning")) + && cwd == dock.findChild(QStringLiteral("upcomingWorkspace")) + && editor == dock.findChild(QStringLiteral("upcomingPromptEditor")) + && send == dock.findChild(QStringLiteral("upcomingSendButton")) + && stableControlTree == dock.findChildren(), + "a harmless same-thread refresh must retain the complete upcoming-turn control tree"); passed &= expect(dock.draft().effort.hasValue() && dock.draft().effort->value == "xhigh", "a same-thread state update must preserve the pending typed override"); @@ -606,15 +616,17 @@ bool testUpcomingTurnActionStates() "a running turn must permit steering and stopping while locking execution settings"); QString shortcutPrompt; bool shortcutSteering = false; + int submissionCount = 0; QObject::connect(&dock, &codexui::UpcomingTurnDock::sendRequested, - [&shortcutPrompt, &shortcutSteering](const QString& prompt, bool steering) { + [&shortcutPrompt, &shortcutSteering, &submissionCount](const QString& prompt, bool steering) { + ++submissionCount; shortcutPrompt = prompt; shortcutSteering = steering; }); QKeyEvent submitShortcut(QEvent::KeyPress, Qt::Key_Return, Qt::ControlModifier); QCoreApplication::sendEvent(editor, &submitShortcut); passed &= expect(shortcutPrompt == QStringLiteral("redirect the active turn") - && shortcutSteering, + && shortcutSteering && submissionCount == 1, "the extracted prompt editor must preserve Ctrl+Enter submission semantics"); dock.setActionState(false, false, @@ -641,6 +653,10 @@ bool testUpcomingTurnActionStates() && !send->isEnabled() && !send->toolTip().isEmpty() && status && status->text().contains(QStringLiteral("previous active turn")), "a steering draft must stay bound to its exact active turn and remain blocked after turn rollover"); + QCoreApplication::sendEvent(editor, &submitShortcut); + send->click(); + passed &= expect(submissionCount == 1, + "an obsolete turn-bound control state must not submit through click or Ctrl+Enter"); editor->insertPlainText(QStringLiteral(" ")); passed &= expect(send->isEnabled() && send->toolTip().isEmpty() && status && status->text() == QStringLiteral("Ctrl+Enter to steer"),