diff --git a/src/widgets/window.cpp b/src/widgets/window.cpp index e6935445..46b26158 100644 --- a/src/widgets/window.cpp +++ b/src/widgets/window.cpp @@ -3654,14 +3654,16 @@ void Window::handleFindKeyword(const QString &keyword, bool state) m_keywordForSearch = keyword; wrapper->textEditor()->saveMarkStatus(); wrapper->textEditor()->updateCursorKeywordSelection(m_keywordForSearch, state); + // 点击"下一个/上一个"可能是本会话首个查找动作(未经过回车路径 + // handleUpdateSearchKeyword 注册查找词)。若此时按不等分支清空 + // m_keywordForSearchAll,渲染页(MarkdownView)将收不到 + // highlightSearch 推送,只读视图下命中处无任何高亮(BUG-380705)。 + // 对齐回车路径的注册行为:查找词变更时注册当前词并同步高亮。 if (QString::compare(m_keywordForSearch, m_keywordForSearchAll, Qt::CaseInsensitive) != 0) { - qCDebug(appLog) << "m_keywordForSearchAll is not equal to m_keywordForSearch, clear it"; - m_keywordForSearchAll.clear(); - wrapper->textEditor()->clearFindMatchSelections(); - } else { - qCDebug(appLog) << "m_keywordForSearchAll is equal to m_keywordForSearch, highlight it"; - wrapper->textEditor()->highlightKeywordInView(m_keywordForSearchAll); + qCDebug(appLog) << "m_keywordForSearchAll is not equal to m_keywordForSearch, register current keyword"; + m_keywordForSearchAll = m_keywordForSearch; } + wrapper->textEditor()->highlightKeywordInView(m_keywordForSearchAll); wrapper->textEditor()->markAllKeywordInView(); diff --git a/tests/editor_wrapper/test_editwrapper.cpp b/tests/editor_wrapper/test_editwrapper.cpp index 6f9673bd..79539265 100644 --- a/tests/editor_wrapper/test_editwrapper.cpp +++ b/tests/editor_wrapper/test_editwrapper.cpp @@ -194,6 +194,7 @@ class RecordingRenderer : public IMarkdownRenderer { { ++scrollCalls; lastLine = line; + scrollLog << QString::number(line, 'f', 1); } void highlightSearch(const QString &keyword, int currentIndex, bool caseSensitive) override { @@ -215,6 +216,7 @@ class RecordingRenderer : public IMarkdownRenderer { int lastMaxWidth = -1; bool lastCenter = false; double lastLine = -1.0; + QStringList scrollLog; QString lastSearchKeyword; int lastSearchIndex = -2; bool lastSearchCaseSensitive = false; @@ -644,17 +646,22 @@ TEST_F(EditWrapperTest, PlainTextEmpty_DefaultTrue_AfterInsertFalse) // 断言(修复后期望):1) 渲染器收到 scrollToLine; // 2) 同步行号与匹配行距离 ≤ 半屏(居中语义),保证渲染页顶部对齐后匹配仍在屏内 // ============================================================================ -static void v6705FindNextScenario(EditWrapper *wrapper, RecordingRenderer *renderer, - int winW, int winH, const char *tag) +// 真实应用时序变体:flow 控制进入只读视图的路径 +// flow=0: Edit → 直接 ReadView(编辑页全程全宽) +// flow=1: Edit → LivePreview(编辑页半宽布局)→ ReadView(保留半宽陈旧几何) +// flow=2: 同 flow=1,但切 ReadView 后再把窗口放大(隐藏页几何完全陈旧) +static void v6705Setup(EditWrapper *wrapper, RecordingRenderer *renderer, + int winW, int winH, int flow) { renderer->ready = true; wrapper->setMarkdownRendererForTest(renderer); - // 长文档:500 行,needle 只在第 400 行(0 基块号 399) QString text; for (int i = 1; i <= 500; ++i) - text += (i == 400 ? QString("line %1 needle here\n").arg(i) - : QString("line %1 plain text content\n").arg(i)); + text += (i % 20 == 0 ? QString("# heading %1\n").arg(i) + : QString("plain line %1 lorem ipsum dolor sit amet consectetur\n").arg(i)) + + ((i == 150 || i == 300 || i == 450) ? QString("line %1 needlexx target here\n").arg(i) + : QString()); QTemporaryDir dir; const QString mdPath = dir.filePath("v6705.md"); @@ -667,32 +674,62 @@ static void v6705FindNextScenario(EditWrapper *wrapper, RecordingRenderer *rende wrapper->updatePath(mdPath, mdPath); wrapper->textEditor()->setPlainText(text); - // 真实应用时序:编辑页先以真实尺寸布局(Edit 模式可见),再切 ReadView 隐藏。 - // 未 show 的 widget 不派发 Resize 事件,隐藏页几何不会更新,必须先 show 顶层容器。 - if (QWidget *top = wrapper->parentWidget() ? wrapper->parentWidget() : wrapper) - top->resize(winW, winH); + QWidget *top = wrapper->parentWidget() ? wrapper->parentWidget() : wrapper; + top->resize(winW, winH); wrapper->resize(winW, winH); - if (wrapper->parentWidget()) - wrapper->parentWidget()->show(); - else - wrapper->show(); + top->show(); QApplication::processEvents(); wrapper->updateMarkdownRecognition(QStringLiteral("v6705.md"), QString()); + if (flow >= 1) + ASSERT_EQ(wrapper->viewMode(), ViewMode::LivePreview); wrapper->setViewMode(ViewMode::ReadView); ASSERT_EQ(wrapper->viewMode(), ViewMode::ReadView); + if (flow == 2) { + // 只读视图下放大窗口:隐藏的编辑页收不到 Resize 事件,几何停留在旧尺寸 + top->resize(winW + 500, winH + 300); + wrapper->resize(winW + 500, winH + 300); + QApplication::processEvents(); + } +} + +static void v6705FindNextScenario(EditWrapper *wrapper, RecordingRenderer *renderer, + int winW, int winH, const char *tag, int path, int flow = 0) +{ + v6705Setup(wrapper, renderer, winW, winH, flow); TextEdit *ed = wrapper->textEditor(); - ASSERT_EQ(ed->toPlainText().split('\n').size(), 501); // 500 行 + 末尾空串 ed->verticalScrollBar()->setValue(0); QApplication::processEvents(); renderer->scrollCalls = 0; renderer->lastLine = -1.0; - - // Act:真实查找路径(Window::handleFindKeyword → updateCursorKeywordSelection) - ed->updateCursorKeywordSelection(QStringLiteral("needle"), true); - QApplication::processEvents(); + renderer->scrollLog.clear(); + + // Act:三种真实路径 + // direct: updateCursorKeywordSelection 直调(两路径共享的核心) + // enter : 回车键路径 Window::handleUpdateSearchKeyword → TextEdit::highlightKeyword + // click : "下一个"按钮路径 Window::handleFindKeyword 的完整调用序列 + if (path == 0) { + ed->updateCursorKeywordSelection(QStringLiteral("needle"), true); + } else if (path == 1) { + ed->highlightKeyword(QStringLiteral("needle"), 0, Qt::CaseSensitive); + } else { + ed->saveMarkStatus(); + ed->updateCursorKeywordSelection(QStringLiteral("needle"), true); + // Window::handleFindKeyword 修复后(BUG-380705):无论 m_keywordForSearchAll + // 是否已注册,均注册当前词并调用 highlightKeywordInView(向渲染页推送 + // highlightSearch),此处对齐该调用序列 + ed->highlightKeywordInView(QStringLiteral("needle")); + ed->markAllKeywordInView(); + ed->renderAllSelections(); + ed->restoreMarkStatus(); + ed->updateLeftAreaWidget(); + wrapper->OnUpdateHighlighter(); + } + // QueuedConnection 的滚动条 lambda(OnUpdateHighlighter/高亮刷新)需跑满事件循环 + for (int i = 0; i < 3; ++i) + QApplication::processEvents(); const int matchBlock = ed->textCursor().blockNumber(); const int lineHeight = ed->cursorRect().height(); @@ -700,9 +737,11 @@ static void v6705FindNextScenario(EditWrapper *wrapper, RecordingRenderer *rende const int visibleLines = lineHeight > 0 ? viewportH / lineHeight : 0; const double dist = matchBlock - renderer->lastLine; - qInfo("[%s] win=%dx%d scrollCalls=%d lastLine=%.1f matchBlock=%d vbarValue=%d " - "topVisibleBlock=%d viewportH=%d lineHeight=%d visibleLines=%d matchToSyncTopDist=%.1f", - tag, winW, winH, renderer->scrollCalls, renderer->lastLine, matchBlock, + qInfo("[%s] win=%dx%d scrollCalls=%d scrollLog=[%s] lastLine=%.1f matchBlock=%d " + "vbarValue=%d topVisibleBlock=%d viewportH=%d lineHeight=%d visibleLines=%d " + "matchToSyncTopDist=%.1f", + tag, winW, winH, renderer->scrollCalls, + qPrintable(renderer->scrollLog.join(",")), renderer->lastLine, matchBlock, ed->verticalScrollBar()->value(), ed->topVisibleBlockNumber(), viewportH, lineHeight, visibleLines, dist); @@ -714,12 +753,35 @@ static void v6705FindNextScenario(EditWrapper *wrapper, RecordingRenderer *rende TEST_F(EditWrapperTest, V6705_ReadViewFindNext_SyncsRendererNearMatch_Maximized) { - v6705FindNextScenario(m_wrapper, &m_renderer, 1920, 1040, "maximized"); + v6705FindNextScenario(m_wrapper, &m_renderer, 1920, 1040, "maximized-direct", 0); } TEST_F(EditWrapperTest, V6705_ReadViewFindNext_SyncsRendererNearMatch_Normal) { - v6705FindNextScenario(m_wrapper, &m_renderer, 900, 600, "normal"); + v6705FindNextScenario(m_wrapper, &m_renderer, 900, 600, "normal-direct", 0); +} + +TEST_F(EditWrapperTest, V6705_ReadViewFindNext_EnterPath_Maximized) +{ + v6705FindNextScenario(m_wrapper, &m_renderer, 1920, 1040, "maximized-ENTER", 1); +} + +TEST_F(EditWrapperTest, V6705_ReadViewFindNext_ClickPath_Maximized) +{ + v6705FindNextScenario(m_wrapper, &m_renderer, 1920, 1040, "maximized-CLICK", 2); +} + +// 真实应用流程复刻:md 打开先进 LivePreview(编辑页半宽布局),再切只读视图。 +// 隐藏的编辑页保留 LivePreview 时代的半宽几何(行折叠更密)。 +TEST_F(EditWrapperTest, V6705_Flow1_LivePreviewThenReadView_Maximized_Click) +{ + v6705FindNextScenario(m_wrapper, &m_renderer, 1920, 1040, "flow1-LPthenRV-max-CLICK", 2, 1); +} + +// 真实应用流程复刻:普通窗口进只读视图后再最大化(隐藏编辑页几何完全陈旧)。 +TEST_F(EditWrapperTest, V6705_Flow2_MaximizeAfterReadView_Click) +{ + v6705FindNextScenario(m_wrapper, &m_renderer, 1000, 600, "flow2-maxAfterRV-CLICK", 2, 2); } // ============================================================================ diff --git a/tests/widgets_ut/test_window.cpp b/tests/widgets_ut/test_window.cpp index 16cd8932..54288092 100644 --- a/tests/widgets_ut/test_window.cpp +++ b/tests/widgets_ut/test_window.cpp @@ -61,6 +61,7 @@ #include "controls/jumplinebar.h" #include "editor/editwrapper.h" #include "editor/dtextedit.h" +#include "editor/markdown/imarkdownrenderer.h" #include "common/settings.h" #include "common/utils.h" #include "common/iflytek_ai_assistant.h" @@ -1646,11 +1647,11 @@ TEST_F(WindowTest, HandleFindKeywords_NextPrev_UpdateState) // Assert: 关键词记录 EXPECT_EQ(m_win->getKeywordForSearch(), QString("alpha")); - // Act: 向前查找(与 all 不同 → 清空 all 分支) + // Act: 向前查找(与 all 不同 → 注册当前词分支,BUG-380705:与回车路径对齐) m_win->m_keywordForSearchAll = QStringLiteral("other"); m_win->handleFindPrevSearchKeyword(QStringLiteral("beta")); EXPECT_EQ(m_win->getKeywordForSearch(), QString("beta")); - EXPECT_TRUE(m_win->m_keywordForSearchAll.isEmpty()); + EXPECT_EQ(m_win->m_keywordForSearchAll, QString("beta")); } TEST_F(WindowTest, HandleFindKeyword_MatchAllBranch_HighlightsAll) @@ -1667,6 +1668,57 @@ TEST_F(WindowTest, HandleFindKeyword_MatchAllBranch_HighlightsAll) EXPECT_EQ(m_win->m_keywordForSearchAll, QString("x")); } +// BUG-380705 残留缺陷回归:点击"下一个"作为会话内首个查找动作(未经回车注册 +// 查找词)时,渲染页必须收到 highlightSearch 推送,否则只读视图下命中处无高亮。 +// 记录型渲染器桩:仅记录 highlightSearch 入参。 +namespace { +class V6705RecordingRenderer : public IMarkdownRenderer { +public: + bool isReady() const override { return true; } + void setMarkdown(const QString &) override {} + void setMode(int) override {} + void applyTheme(const QVariantMap &) override {} + void setLayout(int, bool) override {} + void scrollToLine(double) override {} + void highlightSearch(const QString &keyword, int currentIndex, bool caseSensitive) override + { + ++calls; + lastKeyword = keyword; + lastIndex = currentIndex; + lastCaseSensitive = caseSensitive; + } + int calls = 0; + QString lastKeyword; + int lastIndex = -2; + bool lastCaseSensitive = false; +}; +} // namespace + +TEST_F(WindowTest, HandleFindKeyword_ClickFirst_PushesRendererHighlight) +{ + // Arrange: 全新查找会话(m_keywordForSearchAll 为空),直接点击"下一个" + const QString path = addFileTab(QStringLiteral("v6705.txt"), QByteArray("aa bb aa bb aa\n")); + EditWrapper *wrapper = m_win->currentWrapper(); + ASSERT_NE(wrapper, nullptr); + V6705RecordingRenderer renderer; + wrapper->setMarkdownRendererForTest(&renderer); + + // Act: 首个动作即点击下一个(未经过回车路径注册) + m_win->handleFindNextSearchKeyword(QStringLiteral("aa")); + + // Assert: 查找词已注册(滚动联动高亮可用) + EXPECT_EQ(m_win->getKeywordForSearchAll(), QString("aa")); + // 渲染页收到 highlightSearch 推送(修复前该路径从不推送 → 渲染页无高亮) + EXPECT_GE(renderer.calls, 1); + EXPECT_EQ(renderer.lastKeyword, QString("aa")); + // 当前命中为首个(0 基序号) + EXPECT_EQ(renderer.lastIndex, 0); + + // Act 2: 再次点击下一个 → 当前命中推进到第二个 + m_win->handleFindNextSearchKeyword(QStringLiteral("aa")); + EXPECT_EQ(renderer.lastIndex, 1); +} + TEST_F(WindowTest, HandleReplaceAll_ReplacesDocumentText) { // Arrange