Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion devel/0350.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 如何测试

Expand All @@ -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,打开一个普通文档标签页
Expand Down Expand Up @@ -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` 辅助函数,
及上述两个回归测试。
17 changes: 16 additions & 1 deletion src/Plugins/Qt/QTMTabPage.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -481,7 +493,10 @@ QTMTabPageContainer::replaceTabPages (QList<QAction*>* 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 注释),此处须手动释放,否则每次重建都
Expand Down
6 changes: 6 additions & 0 deletions src/Plugins/Qt/QTMTabPage.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
82 changes: 82 additions & 0 deletions tests/Plugins/Qt/qt_tab_page_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,30 @@
******************************************************************************/

#include "Qt/QTMTabPage.hpp"
#include "Qt/qt_utilities.hpp"
#include "base.hpp"
#include <QApplication>
#include <QList>
#include <QMouseEvent>
#include <QtTest/QtTest>

namespace {
// 构造 carrier 列表:url 与显示标题可独立指定,模拟 SLOT_TAB_PAGES 喂给
// replaceTabPages 的输入(标题带尾部 ` *` 表示未保存)。
QList<QAction*>*
makeCarrierList (const QList<QPair<QString, QString>>& urlTitlePairs) {
auto* list= new QList<QAction*> ();
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

Expand Down Expand Up @@ -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)
Expand Down
Loading