diff --git a/devel/0350.md b/devel/0350.md index 97532ffeb2..43a4e27216 100644 --- a/devel/0350.md +++ b/devel/0350.md @@ -6,7 +6,7 @@ ## 2 任务相关的代码文件 - `src/Plugins/Qt/QTMTabPage.hpp` - 标签页 dirty 状态和关闭区域悬浮状态定义 - `src/Plugins/Qt/QTMTabPage.cpp` - 消费标题尾部 `*`、在关闭按钮位置绘制未保存标志、处理关闭区域悬浮切换 -- `tests/Plugins/Qt/qt_tab_page_test.cpp` - 回归测试,验证 dirty 标志从标题尾部移到关闭按钮位置 +- `tests/Plugins/Qt/qt_tab_page_test.cpp` - dirty 标志解析、关闭位 `*` 显示、复用刷新等回归测试 ## 3 如何测试 @@ -15,6 +15,9 @@ xmake build qt_tab_page_test env QT_QPA_PLATFORM=offscreen xmake run qt_tab_page_test ``` +注:`test_replaceTabPages_refreshes_dirty_on_reuse` 的断言依赖 `debug_findTab`, +仅在 `LIII_DEBUG` 构建(debug / releasedbg,见 `xmake f -m releasedbg`)下生效, +release 构建会 `QSKIP`。 ### 3.2 非确定性测试(文档验证) 1. 启动 Mogan STEM,打开一个普通文档标签页 @@ -61,3 +64,46 @@ env QT_QPA_PLATFORM=offscreen xmake run qt_tab_page_test - 带尾部 `*` 的标题会被清洗成纯文件名 - 标签页内部 dirty 状态会被正确设置 - 鼠标移动到关闭区域后会切换到关闭按钮显示 + +## 8 回归修复:复用 tab 时 dirty 状态不刷新(0350 + 2014 交互缺陷) + +### 现象 +用户反馈:实际编辑文档时,标签页关闭按钮位置的 `*` 不出现;保存后也不消失。 +底层标脏逻辑(archiver `require_save`/`conform_save`)经 `[2015]` 日志验证是好的, +问题出在 **Qt 显示层**:标题刷新到了,但内部 `m_isDirty` 没跟着翻。 + +### 根因 +0350 的 `m_isDirty` **只在 `QTMTabPage` 构造时**经 `applyDisplayTitle` 解析标题 +尾部 `*` 设置一次。之后 2014(`[2014] 修复切换 tab 触发整条标签栏重建`)把 +`replaceTabPages` 从"全量重建 tab"改成"按 view-url 增量复用"——复用路径 +(`QTMTabPage.cpp` 原第 484 行)只调 `tab->setText(srcTab->text())`,**不更新 +`m_isDirty`**。 + +后果:tab 一旦被复用,`m_isDirty` 永远停留在首次构造时的值(通常是 false), +编辑标脏 / 保存去脏都不会反映到关闭按钮位置的 `*` 上。全量重建时代因为每次 +重新构造、重新 `applyDisplayTitle` 而恰好掩盖了这个缺陷;2014 改成复用后才暴露。 + +### 修复 +1. 新增 `QTMTabPage::syncDisplay(cleanTitle, dirty)`:同时同步干净标题与 + dirty 标志,变化时触发 `updateCloseButtonVisibility()` + `update()` 重画。 +2. `replaceTabPages` 复用 tab 时改调 + `tab->syncDisplay(srcTab->text(), srcTab->isDirty())`(srcTab 构造时已 + `applyDisplayTitle` 解析过,其 `text()` 是干净标题、`isDirty()` 是最新脏状态)。 + +### 测试(两层互补) +1. **逻辑层** `test_sync_display_updates_dirty_state`:直接调 `syncDisplay`, + 验证 dirty 能从 false 翻 true、再翻回 false。任何构建都跑,覆盖修复点本身。 +2. **端到端** `test_replaceTabPages_refreshes_dirty_on_reuse`:构造 container, + 喂带 `*` 标题的 carrier 触发复用,断言**同一 tab 指针**的 `isDirty()` 被刷新。 + 这才是真正覆盖原 bug 触发路径的测试。依赖 `debug_findTab`,仅 `LIII_DEBUG` + 下断言,release 构建自动 `QSKIP`(与 `qt_tabpage_rebuild_test` 同惯例)。 + +已验证端到端测试能抓到回归:把 `replaceTabPages` 复用分支临时还原成 `setText`, +该测试在 `reused->isDirty()` 处 FAIL;恢复 `syncDisplay` 后 6 个用例全 PASS。 + +### 涉及文件 +- `src/Plugins/Qt/QTMTabPage.hpp`:新增 public `syncDisplay` 声明。 +- `src/Plugins/Qt/QTMTabPage.cpp`:实现 `syncDisplay`;`replaceTabPages` 复用 + 分支改用 `syncDisplay`。 +- `tests/Plugins/Qt/qt_tab_page_test.cpp`:新增 `makeCarrierList` 辅助函数, + 及上述两个回归测试。 diff --git a/src/Plugins/Qt/QTMTabPage.cpp b/src/Plugins/Qt/QTMTabPage.cpp index 9f89759619..910e12344d 100644 --- a/src/Plugins/Qt/QTMTabPage.cpp +++ b/src/Plugins/Qt/QTMTabPage.cpp @@ -199,6 +199,18 @@ QTMTabPage::applyDisplayTitle (const QString& rawTitle) { setText (cleanTitle); } +void +QTMTabPage::syncDisplay (const QString& cleanTitle, bool dirty) { + // dirty 变化或标题变化都需要重画:前者改关闭按钮位置的 `*`,后者改文本。 + bool changed= (m_isDirty != dirty) || (text () != cleanTitle); + m_isDirty = dirty; + setText (cleanTitle); + if (changed) { + updateCloseButtonVisibility (); + update (); + } +} + void QTMTabPage::initializeCloseButton (QAction* closeAction) { m_closeBtn= new QWK::WindowButton (this); @@ -481,7 +493,10 @@ QTMTabPageContainer::replaceTabPages (QList* p_src) { // 维护)。 QTMTabPage* tab= it.value (); existing.erase (it); - tab->setText (srcTab->text ()); + // srcTab 构造时已 applyDisplayTitle 解析过尾部 `*`:其 text() 为干净 + // 标题、isDirty() 为最新脏状态。复用 tab 必须同步这两者,否则 m_isDirty + // 停留在首次构造的旧值,编辑标脏/保存去脏都不会反映到 `*` 显示。 + tab->syncDisplay (srcTab->text (), srcTab->isDirty ()); next.append (tab); // srcTab 是本次 carrier 新建的 widget,未被接管。QTMTabPageAction 的 // dtor 不会删 m_widget(见 hpp 注释),此处须手动释放,否则每次重建都 diff --git a/src/Plugins/Qt/QTMTabPage.hpp b/src/Plugins/Qt/QTMTabPage.hpp index 0f9249afc4..cf8ac83d73 100644 --- a/src/Plugins/Qt/QTMTabPage.hpp +++ b/src/Plugins/Qt/QTMTabPage.hpp @@ -45,6 +45,12 @@ class QTMTabPage : public QToolButton { explicit QTMTabPage (); virtual void paintEvent (QPaintEvent*) override; bool isDirty () const { return m_isDirty; } + /*! 同步已解析好的显示状态(干净标题 + dirty 标志)。 + * replaceTabPages 复用既有 tab 时调用:srcTab 构造时已 applyDisplayTitle + * 解析过尾部 `*`,其 text() 是干净标题、isDirty() 是最新脏状态。复用的 + * tab 必须同步这两者,否则 m_isDirty 停留在首次构造的旧值,编辑标脏/ + * 保存去脏都不会反映到关闭按钮位置的 `*` 上。 */ + void syncDisplay (const QString& cleanTitle, bool dirty); public slots: void setChecked (bool checked); diff --git a/tests/Plugins/Qt/qt_tab_page_test.cpp b/tests/Plugins/Qt/qt_tab_page_test.cpp index f727e15945..81d0b40641 100644 --- a/tests/Plugins/Qt/qt_tab_page_test.cpp +++ b/tests/Plugins/Qt/qt_tab_page_test.cpp @@ -5,11 +5,30 @@ ******************************************************************************/ #include "Qt/QTMTabPage.hpp" +#include "Qt/qt_utilities.hpp" #include "base.hpp" #include +#include #include #include +namespace { +// 构造 carrier 列表:url 与显示标题可独立指定,模拟 SLOT_TAB_PAGES 喂给 +// replaceTabPages 的输入(标题带尾部 ` *` 表示未保存)。 +QList* +makeCarrierList (const QList>& urlTitlePairs) { + auto* list= new QList (); + for (const auto& p : urlTitlePairs) { + auto* title = new QAction (p.second); + auto* closeBtn= new QAction ("Close"); + auto* tab= + new QTMTabPage (url (from_qstring (p.first)), title, closeBtn, false); + list->append (new QTMTabPageAction (tab)); + } + return list; +} +} // namespace + class TestQTMTabPage : public QObject { Q_OBJECT @@ -58,6 +77,69 @@ private slots: QCOMPARE (tab.text (), QString::fromUtf8 ("clean-file.tm")); QVERIFY (!tab.isDirty ()); } + + // 回归:replaceTabPages 复用既有 tab 时,dirty 状态必须随标题刷新。 + // 0350 把 `*` 从标题尾部移到关闭按钮位置,m_isDirty 只在构造时解析一次; + // 2014 把全量重建改成增量复用后,复用路径只 setText 不更新 m_isDirty, + // 导致编辑标脏/保存去脏都不反映到 `*` 显示。syncDisplay 是该路径的修复点。 + void test_sync_display_updates_dirty_state () { + QAction titleAction ("doc.tm", nullptr); // 构造时干净 + QAction closeAction ("Close", nullptr); + QTMTabPage tab (url ("file:///tmp/doc.tm"), &titleAction, &closeAction, + false); + tab.resize (220, 32); + tab.show (); + QVERIFY (QTest::qWaitForWindowExposed (&tab)); + QVERIFY (!tab.isDirty ()); + QCOMPARE (tab.text (), QString::fromUtf8 ("doc.tm")); + + // 模拟 replaceTabPages 复用:srcTab 已解析过尾部 `*`,传入干净标题 + + // dirty。 + tab.syncDisplay (QString::fromUtf8 ("doc.tm"), true); + QVERIFY (tab.isDirty ()); + QCOMPARE (tab.text (), QString::fromUtf8 ("doc.tm")); + + // 保存去脏:dirty 翻回 false。 + tab.syncDisplay (QString::fromUtf8 ("doc.tm"), false); + QVERIFY (!tab.isDirty ()); + } + + // 端到端:replaceTabPages 复用 tab 时,新标题的尾部 `*` 必须刷新到既有 + // tab 的 dirty 状态(而非停留在构造时解析的旧值)。这正是 0350+2014 回归 + // bug 的真实触发路径:编辑标脏后上层重发带 `*` 的标题,复用分支需把 dirty + // 同步过去,关闭按钮位置才会画 `*`。 + // debug_findTab 仅 LIII_DEBUG 下存在,release 构建跳过。 + void test_replaceTabPages_refreshes_dirty_on_reuse () { + QWidget host; + QTMTabPageContainer container (&host); + container.setRowHeight (32); + host.resize (400, 40); + host.show (); + QVERIFY (QTest::qWaitForWindowExposed (&host)); + + // 首次:干净标题,tab 构造时 dirty=false。 + container.replaceTabPages (makeCarrierList ({{"tmfs://view/1", "doc.tm"}})); +#ifndef LIII_DEBUG + QSKIP ("debug_findTab 仅 LIII_DEBUG 构建可用"); +#else + QTMTabPage* tab= container.debug_findTab (url ("tmfs://view/1")); + QVERIFY (tab != nullptr); + QVERIFY (!tab->isDirty ()); + + // 同一 url 再次喂入,但标题改为带 ` *`(模拟编辑标脏后上层重发)。 + // 必须复用同一 tab 对象,且其 dirty 翻为 true。 + container.replaceTabPages ( + makeCarrierList ({{"tmfs://view/1", "doc.tm *"}})); + QTMTabPage* reused= container.debug_findTab (url ("tmfs://view/1")); + QCOMPARE (reused, tab); // 指针不变 => 复用而非重建 + QVERIFY (reused->isDirty ()); + QCOMPARE (reused->text (), QString::fromUtf8 ("doc.tm")); + + // 第三次:标题去掉 `*`(模拟保存去脏),dirty 翻回 false。 + container.replaceTabPages (makeCarrierList ({{"tmfs://view/1", "doc.tm"}})); + QVERIFY (!container.debug_findTab (url ("tmfs://view/1"))->isDirty ()); +#endif + } }; QTEST_MAIN (TestQTMTabPage)