Skip to content

Commit 9f36cb5

Browse files
authored
[0350] 修复复用 tab 时 dirty 状态不刷新 (#3850)
1 parent 1feee1a commit 9f36cb5

4 files changed

Lines changed: 151 additions & 2 deletions

File tree

devel/0350.md

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
## 2 任务相关的代码文件
77
- `src/Plugins/Qt/QTMTabPage.hpp` - 标签页 dirty 状态和关闭区域悬浮状态定义
88
- `src/Plugins/Qt/QTMTabPage.cpp` - 消费标题尾部 `*`、在关闭按钮位置绘制未保存标志、处理关闭区域悬浮切换
9-
- `tests/Plugins/Qt/qt_tab_page_test.cpp` - 回归测试,验证 dirty 标志从标题尾部移到关闭按钮位置
9+
- `tests/Plugins/Qt/qt_tab_page_test.cpp` - dirty 标志解析、关闭位 `*` 显示、复用刷新等回归测试
1010

1111
## 3 如何测试
1212

@@ -15,6 +15,9 @@
1515
xmake build qt_tab_page_test
1616
env QT_QPA_PLATFORM=offscreen xmake run qt_tab_page_test
1717
```
18+
注:`test_replaceTabPages_refreshes_dirty_on_reuse` 的断言依赖 `debug_findTab`
19+
仅在 `LIII_DEBUG` 构建(debug / releasedbg,见 `xmake f -m releasedbg`)下生效,
20+
release 构建会 `QSKIP`
1821

1922
### 3.2 非确定性测试(文档验证)
2023
1. 启动 Mogan STEM,打开一个普通文档标签页
@@ -61,3 +64,46 @@ env QT_QPA_PLATFORM=offscreen xmake run qt_tab_page_test
6164
- 带尾部 `*` 的标题会被清洗成纯文件名
6265
- 标签页内部 dirty 状态会被正确设置
6366
- 鼠标移动到关闭区域后会切换到关闭按钮显示
67+
68+
## 8 回归修复:复用 tab 时 dirty 状态不刷新(0350 + 2014 交互缺陷)
69+
70+
### 现象
71+
用户反馈:实际编辑文档时,标签页关闭按钮位置的 `*` 不出现;保存后也不消失。
72+
底层标脏逻辑(archiver `require_save`/`conform_save`)经 `[2015]` 日志验证是好的,
73+
问题出在 **Qt 显示层**:标题刷新到了,但内部 `m_isDirty` 没跟着翻。
74+
75+
### 根因
76+
0350 的 `m_isDirty` **只在 `QTMTabPage` 构造时**`applyDisplayTitle` 解析标题
77+
尾部 `*` 设置一次。之后 2014(`[2014] 修复切换 tab 触发整条标签栏重建`)把
78+
`replaceTabPages` 从"全量重建 tab"改成"按 view-url 增量复用"——复用路径
79+
`QTMTabPage.cpp` 原第 484 行)只调 `tab->setText(srcTab->text())`**不更新
80+
`m_isDirty`**
81+
82+
后果:tab 一旦被复用,`m_isDirty` 永远停留在首次构造时的值(通常是 false),
83+
编辑标脏 / 保存去脏都不会反映到关闭按钮位置的 `*` 上。全量重建时代因为每次
84+
重新构造、重新 `applyDisplayTitle` 而恰好掩盖了这个缺陷;2014 改成复用后才暴露。
85+
86+
### 修复
87+
1. 新增 `QTMTabPage::syncDisplay(cleanTitle, dirty)`:同时同步干净标题与
88+
dirty 标志,变化时触发 `updateCloseButtonVisibility()` + `update()` 重画。
89+
2. `replaceTabPages` 复用 tab 时改调
90+
`tab->syncDisplay(srcTab->text(), srcTab->isDirty())`(srcTab 构造时已
91+
`applyDisplayTitle` 解析过,其 `text()` 是干净标题、`isDirty()` 是最新脏状态)。
92+
93+
### 测试(两层互补)
94+
1. **逻辑层** `test_sync_display_updates_dirty_state`:直接调 `syncDisplay`
95+
验证 dirty 能从 false 翻 true、再翻回 false。任何构建都跑,覆盖修复点本身。
96+
2. **端到端** `test_replaceTabPages_refreshes_dirty_on_reuse`:构造 container,
97+
喂带 `*` 标题的 carrier 触发复用,断言**同一 tab 指针**`isDirty()` 被刷新。
98+
这才是真正覆盖原 bug 触发路径的测试。依赖 `debug_findTab`,仅 `LIII_DEBUG`
99+
下断言,release 构建自动 `QSKIP`(与 `qt_tabpage_rebuild_test` 同惯例)。
100+
101+
已验证端到端测试能抓到回归:把 `replaceTabPages` 复用分支临时还原成 `setText`
102+
该测试在 `reused->isDirty()` 处 FAIL;恢复 `syncDisplay` 后 6 个用例全 PASS。
103+
104+
### 涉及文件
105+
- `src/Plugins/Qt/QTMTabPage.hpp`:新增 public `syncDisplay` 声明。
106+
- `src/Plugins/Qt/QTMTabPage.cpp`:实现 `syncDisplay``replaceTabPages` 复用
107+
分支改用 `syncDisplay`
108+
- `tests/Plugins/Qt/qt_tab_page_test.cpp`:新增 `makeCarrierList` 辅助函数,
109+
及上述两个回归测试。

src/Plugins/Qt/QTMTabPage.cpp

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,18 @@ QTMTabPage::applyDisplayTitle (const QString& rawTitle) {
199199
setText (cleanTitle);
200200
}
201201

202+
void
203+
QTMTabPage::syncDisplay (const QString& cleanTitle, bool dirty) {
204+
// dirty 变化或标题变化都需要重画:前者改关闭按钮位置的 `*`,后者改文本。
205+
bool changed= (m_isDirty != dirty) || (text () != cleanTitle);
206+
m_isDirty = dirty;
207+
setText (cleanTitle);
208+
if (changed) {
209+
updateCloseButtonVisibility ();
210+
update ();
211+
}
212+
}
213+
202214
void
203215
QTMTabPage::initializeCloseButton (QAction* closeAction) {
204216
m_closeBtn= new QWK::WindowButton (this);
@@ -481,7 +493,10 @@ QTMTabPageContainer::replaceTabPages (QList<QAction*>* p_src) {
481493
// 维护)。
482494
QTMTabPage* tab= it.value ();
483495
existing.erase (it);
484-
tab->setText (srcTab->text ());
496+
// srcTab 构造时已 applyDisplayTitle 解析过尾部 `*`:其 text() 为干净
497+
// 标题、isDirty() 为最新脏状态。复用 tab 必须同步这两者,否则 m_isDirty
498+
// 停留在首次构造的旧值,编辑标脏/保存去脏都不会反映到 `*` 显示。
499+
tab->syncDisplay (srcTab->text (), srcTab->isDirty ());
485500
next.append (tab);
486501
// srcTab 是本次 carrier 新建的 widget,未被接管。QTMTabPageAction 的
487502
// dtor 不会删 m_widget(见 hpp 注释),此处须手动释放,否则每次重建都

src/Plugins/Qt/QTMTabPage.hpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ class QTMTabPage : public QToolButton {
4545
explicit QTMTabPage ();
4646
virtual void paintEvent (QPaintEvent*) override;
4747
bool isDirty () const { return m_isDirty; }
48+
/*! 同步已解析好的显示状态(干净标题 + dirty 标志)。
49+
* replaceTabPages 复用既有 tab 时调用:srcTab 构造时已 applyDisplayTitle
50+
* 解析过尾部 `*`,其 text() 是干净标题、isDirty() 是最新脏状态。复用的
51+
* tab 必须同步这两者,否则 m_isDirty 停留在首次构造的旧值,编辑标脏/
52+
* 保存去脏都不会反映到关闭按钮位置的 `*` 上。 */
53+
void syncDisplay (const QString& cleanTitle, bool dirty);
4854

4955
public slots:
5056
void setChecked (bool checked);

tests/Plugins/Qt/qt_tab_page_test.cpp

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,30 @@
55
******************************************************************************/
66

77
#include "Qt/QTMTabPage.hpp"
8+
#include "Qt/qt_utilities.hpp"
89
#include "base.hpp"
910
#include <QApplication>
11+
#include <QList>
1012
#include <QMouseEvent>
1113
#include <QtTest/QtTest>
1214

15+
namespace {
16+
// 构造 carrier 列表:url 与显示标题可独立指定,模拟 SLOT_TAB_PAGES 喂给
17+
// replaceTabPages 的输入(标题带尾部 ` *` 表示未保存)。
18+
QList<QAction*>*
19+
makeCarrierList (const QList<QPair<QString, QString>>& urlTitlePairs) {
20+
auto* list= new QList<QAction*> ();
21+
for (const auto& p : urlTitlePairs) {
22+
auto* title = new QAction (p.second);
23+
auto* closeBtn= new QAction ("Close");
24+
auto* tab=
25+
new QTMTabPage (url (from_qstring (p.first)), title, closeBtn, false);
26+
list->append (new QTMTabPageAction (tab));
27+
}
28+
return list;
29+
}
30+
} // namespace
31+
1332
class TestQTMTabPage : public QObject {
1433
Q_OBJECT
1534

@@ -58,6 +77,69 @@ private slots:
5877
QCOMPARE (tab.text (), QString::fromUtf8 ("clean-file.tm"));
5978
QVERIFY (!tab.isDirty ());
6079
}
80+
81+
// 回归:replaceTabPages 复用既有 tab 时,dirty 状态必须随标题刷新。
82+
// 0350 把 `*` 从标题尾部移到关闭按钮位置,m_isDirty 只在构造时解析一次;
83+
// 2014 把全量重建改成增量复用后,复用路径只 setText 不更新 m_isDirty,
84+
// 导致编辑标脏/保存去脏都不反映到 `*` 显示。syncDisplay 是该路径的修复点。
85+
void test_sync_display_updates_dirty_state () {
86+
QAction titleAction ("doc.tm", nullptr); // 构造时干净
87+
QAction closeAction ("Close", nullptr);
88+
QTMTabPage tab (url ("file:///tmp/doc.tm"), &titleAction, &closeAction,
89+
false);
90+
tab.resize (220, 32);
91+
tab.show ();
92+
QVERIFY (QTest::qWaitForWindowExposed (&tab));
93+
QVERIFY (!tab.isDirty ());
94+
QCOMPARE (tab.text (), QString::fromUtf8 ("doc.tm"));
95+
96+
// 模拟 replaceTabPages 复用:srcTab 已解析过尾部 `*`,传入干净标题 +
97+
// dirty。
98+
tab.syncDisplay (QString::fromUtf8 ("doc.tm"), true);
99+
QVERIFY (tab.isDirty ());
100+
QCOMPARE (tab.text (), QString::fromUtf8 ("doc.tm"));
101+
102+
// 保存去脏:dirty 翻回 false。
103+
tab.syncDisplay (QString::fromUtf8 ("doc.tm"), false);
104+
QVERIFY (!tab.isDirty ());
105+
}
106+
107+
// 端到端:replaceTabPages 复用 tab 时,新标题的尾部 `*` 必须刷新到既有
108+
// tab 的 dirty 状态(而非停留在构造时解析的旧值)。这正是 0350+2014 回归
109+
// bug 的真实触发路径:编辑标脏后上层重发带 `*` 的标题,复用分支需把 dirty
110+
// 同步过去,关闭按钮位置才会画 `*`。
111+
// debug_findTab 仅 LIII_DEBUG 下存在,release 构建跳过。
112+
void test_replaceTabPages_refreshes_dirty_on_reuse () {
113+
QWidget host;
114+
QTMTabPageContainer container (&host);
115+
container.setRowHeight (32);
116+
host.resize (400, 40);
117+
host.show ();
118+
QVERIFY (QTest::qWaitForWindowExposed (&host));
119+
120+
// 首次:干净标题,tab 构造时 dirty=false。
121+
container.replaceTabPages (makeCarrierList ({{"tmfs://view/1", "doc.tm"}}));
122+
#ifndef LIII_DEBUG
123+
QSKIP ("debug_findTab 仅 LIII_DEBUG 构建可用");
124+
#else
125+
QTMTabPage* tab= container.debug_findTab (url ("tmfs://view/1"));
126+
QVERIFY (tab != nullptr);
127+
QVERIFY (!tab->isDirty ());
128+
129+
// 同一 url 再次喂入,但标题改为带 ` *`(模拟编辑标脏后上层重发)。
130+
// 必须复用同一 tab 对象,且其 dirty 翻为 true。
131+
container.replaceTabPages (
132+
makeCarrierList ({{"tmfs://view/1", "doc.tm *"}}));
133+
QTMTabPage* reused= container.debug_findTab (url ("tmfs://view/1"));
134+
QCOMPARE (reused, tab); // 指针不变 => 复用而非重建
135+
QVERIFY (reused->isDirty ());
136+
QCOMPARE (reused->text (), QString::fromUtf8 ("doc.tm"));
137+
138+
// 第三次:标题去掉 `*`(模拟保存去脏),dirty 翻回 false。
139+
container.replaceTabPages (makeCarrierList ({{"tmfs://view/1", "doc.tm"}}));
140+
QVERIFY (!container.debug_findTab (url ("tmfs://view/1"))->isDirty ());
141+
#endif
142+
}
61143
};
62144

63145
QTEST_MAIN (TestQTMTabPage)

0 commit comments

Comments
 (0)