diff --git a/tests/common/test_settings.cpp b/tests/common/test_settings.cpp index 1628ae4b..2db65b90 100644 --- a/tests/common/test_settings.cpp +++ b/tests/common/test_settings.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -1166,3 +1167,57 @@ TEST_F(SettingsTest, KeySequenceEdit_EventFilter_ModifierOrForeignObject_Delegat EXPECT_FALSE(withModifier); EXPECT_FALSE(foreignObject); } + +// PMS: https://pms.uniontech.com/bug-view-102351.html commit: 824ad66b +// 场景:关闭应用不能保留现场。修复(824ad66b):Settings 构造后端不再父挂 +// 到 Settings 控件(new QSettingBackend(strConfigPath)),后端不随 widget +// 树提前析构,doSetOption 的写盘(lock + setValue + sync)可完整落盘 +TEST_F(SettingsTest, BUG102351_SettingsDestroyed_ConfigValuePreserved) +{ + // Arrange: 独立 Settings 实例(不注册单例,同 Destruct_TemporaryInstance 模式) + Settings *tmp = new Settings(); + ASSERT_NE(tmp->settings, nullptr); + ASSERT_NE(tmp->m_backend, nullptr); + const QString fontKey = QStringLiteral("base.font.family"); + const QString marked = QStringLiteral("bug102351-font"); + const QString configPath = QString("%1/%2/%3/config.conf") + .arg(QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)) + .arg(QString::fromLatin1(kOrgName)) + .arg(QString::fromLatin1(kAppName)); + + // 修复核心断言:后端无父对象(824ad66b 将 new QSettingBackend(path, this) + // 改为 new QSettingBackend(path)),不随 Settings widget 树提前析构 + EXPECT_EQ(tmp->m_backend->parent(), nullptr); + + // Act: 经 DSettings 公开 API 写入配置;写链(valueChanged → doSetOption) + // 可能经排队信号异步落盘,轮询等待到达后端 + tmp->settings->setOption(fontKey, QVariant(marked)); + bool reached = false; + for (int i = 0; i < 40 && !reached; ++i) { + reached = tmp->m_backend->keys().contains(fontKey); + if (!reached) { + QThread::msleep(25); + QApplication::processEvents(); + } + } + ASSERT_TRUE(reached); + delete tmp; // 销毁 Settings(dtor 显式 delete m_backend) + + // Assert: 配置经全新 CustemBackend 从磁盘读回——后端生命周期独立于 + // Settings 控件,销毁 Settings 不丢失已写配置(关闭应用现场保留语义) + // 注:Dtk QSettingBackend 落盘格式为 [key] 组 + value= 子键, + // 这里按值扫描校验(对 key 序列化格式不敏感) + bool onDisk = false; + for (int i = 0; i < 40 && !onDisk; ++i) { + CustemBackend verifier(configPath); + for (const QString &k : verifier.keys()) { + if (verifier.getOption(k).toString() == marked) { + onDisk = true; + break; + } + } + if (!onDisk) + QThread::msleep(25); + } + EXPECT_TRUE(onDisk); +} diff --git a/tests/common/test_utils.cpp b/tests/common/test_utils.cpp index 69e169da..5d6de925 100644 --- a/tests/common/test_utils.cpp +++ b/tests/common/test_utils.cpp @@ -1491,3 +1491,29 @@ TEST_F(UtilsTest, IsMemorySufficientForOperation_UnknownOperationType_DefaultAll EXPECT_TRUE(ok); EXPECT_TRUE(Utils::isMemorySufficientForOperation(static_cast(100), 100, 100)); } + +// =========================================================================== +// PMS bug 回归用例(批次 2) +// =========================================================================== +// PMS: https://pms.uniontech.com/bug-view-345247.html commit: aff4126b +// 场景:设置-快捷键映射-上一个标签页/减少缩进快捷键展示名错误("Ctrl+Shift+Backtab" → "Ctrl+Shift+Tab")修复(aff4126b): +// Qt 在 Shift 组合下按 Tab 产生 Key_Backtab 事件,QKeySequence(Qt::Key_Backtab).toString() 输出 "Backtab"; +// getKeyshortcut 需将 "Backtab" 键名归一化为 "Tab"(含 Shift 组合,即 BUG 中快捷键展示的真实场景)。 +// 注:commit aff4126b 实际修复位置为 Utils::getKeyshortcut(任务清单所列 fileIsWritable 未被该 commit 触及, +// 其通用行为已由 FileIsWritable_WritableAndReadOnlyFiles_ReturnExpected 覆盖),故回归用例落在 getKeyshortcut。 +TEST_F(UtilsTest, BUG345247_BacktabWithShift_DisplaysTabInsteadOfBacktab) +{ + // Arrange:Shift+Tab 在 Qt 中即 Key_Backtab + ShiftModifier;settings.json selectprevtab 默认 "Ctrl+Shift+Tab" + QKeyEvent shiftBacktab(QEvent::KeyPress, Qt::Key_Backtab, Qt::ShiftModifier); + QKeyEvent ctrlShiftBacktab(QEvent::KeyPress, Qt::Key_Backtab, Qt::ControlModifier | Qt::ShiftModifier); + + // Act + const QString shiftOnly = Utils::getKeyshortcut(&shiftBacktab); + const QString ctrlShift = Utils::getKeyshortcut(&ctrlShiftBacktab); + + // Assert:Backtab 键名归一化为 Tab,展示名绝不能再含 "Backtab"(BUG #345247 展示名错误) + EXPECT_EQ(shiftOnly, QString::fromLatin1("Shift+Tab")); + EXPECT_EQ(ctrlShift, QString::fromLatin1("Ctrl+Shift+Tab")); + EXPECT_FALSE(shiftOnly.contains(QString::fromLatin1("Backtab"))); + EXPECT_FALSE(ctrlShift.contains(QString::fromLatin1("Backtab"))); +} diff --git a/tests/common2/test_fileloadthread.cpp b/tests/common2/test_fileloadthread.cpp index ed2f7af6..af255903 100644 --- a/tests/common2/test_fileloadthread.cpp +++ b/tests/common2/test_fileloadthread.cpp @@ -421,3 +421,41 @@ TEST_F(FileLoadThreadTest, SetEncodeHint_AppliesToBothDetectAndConvertPath) } EXPECT_TRUE(guard.isNull()); } + +// ============================================================================ +// PMS 回归用例(Mode 7 PMS 缺陷热点补强,批次 1) +// 数据源:tests/.ut-pms/(bugs.json / work-order.md) +// ============================================================================ + +// PMS: https://pms.uniontech.com/bug-view-184107.html commit: 3b9b699f +// 场景:读取超大文件时软件闪退。修复(3b9b699f):run() 中 file.read 内存 +// 申请以 try-catch 捕获 std::bad_alloc,异常时 emit sigLoadFinished(hasError) +// 安全返回,不再闪退 +TEST_F(FileLoadThreadTest, BUG184107_BadAllocDuringRead_EmitsErrorNoCrash) +{ + // Arrange: 非 0 size 文件(走 file.read(size) 分支) + const QString path = writeTempFile(QStringLiteral("big-alloc.txt"), QByteArray("x")); + ASSERT_FALSE(path.isEmpty()); + FileLoadThread *t = new FileLoadThread(path); + QPointer guard(t); + QSignalSpy spy(t, &FileLoadThread::sigLoadFinished); + + // 模拟内存申请失败:file.read(qint64) 抛 std::bad_alloc + stub.set_lamda((QByteArray (QFile::*)(qint64))&QFile::read, + [](QFile *, qint64) -> QByteArray { throw std::bad_alloc(); }); + + // Act + runSync(t); + + // Assert: 异常被捕获 → hasError=true 信号发出,不闪退 + EXPECT_EQ(spy.count(), 1); + if (spy.count() == 1) { + EXPECT_TRUE(spy.at(0).at(2).toBool()); + EXPECT_FALSE(spy.at(0).at(3).toBool()); + } + // 当前代码:catch 分支提前 return(:88),deleteLater 仅在正常路径末尾(:132)注册, + // 故此处 guard 未清空属预期;手动补 deleteLater 防泄漏后断言回收 + t->deleteLater(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + EXPECT_TRUE(guard.isNull()); +} diff --git a/tests/common2/test_iflytekaiassistant.cpp b/tests/common2/test_iflytekaiassistant.cpp index 85252cce..8ba01351 100644 --- a/tests/common2/test_iflytekaiassistant.cpp +++ b/tests/common2/test_iflytekaiassistant.cpp @@ -573,3 +573,44 @@ INSTANTIATE_TEST_SUITE_P( ErrorCase{IflytekAiAssistant::NoUserAgreement, nullptr}, ErrorCase{IflytekAiAssistant::Success, nullptr}, ErrorCase{IflytekAiAssistant::Failed, nullptr})); + +// ============================================================================ +// PMS 批次 2 回归用例(仅追加,勿改动上方状态机用例声明顺序) +// ============================================================================ + +// PMS: https://pms.uniontech.com/bug-view-300549.html commit: 2b054245 +// PMS: https://pms.uniontech.com/bug-view-301561.html commit: 6e402655 +// 场景:uos-ai 安装检测/音频设备检测修复(2b054245、6e402655): +// AudioDeviceDetector::hasEnabledPorts/countEnabledPorts 对 DDE Audio +// CardsWithoutUnavailable JSON 的健壮性——非法 JSON(解析失败分支)、 +// 合法但根非数组、空数组、端口 Enabled 但 Direction 非法值,均须返回 +// false 且不崩溃。本用例不触碰 m_status(hasAudioOutputDevice/ +// hasAudioInputDevice 不做状态检查),追加在状态机套件末尾安全 +TEST_F(IflytekAiAssistantTest, BUG300549_AudioDetector_InvalidJsonAndEdgeCases_ReturnFalse) +{ + // Arrange + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + installAudioStubs(); + fakeIsValid = true; + // Act/Assert: JSON 解析失败(缺右括号)→ parseError 分支 → false,不崩溃 + cardsJson = QByteArrayLiteral("{\"Id\":0,\"Name\":\"c0\""); + EXPECT_FALSE(ins->hasAudioOutputDevice()); + EXPECT_FALSE(ins->hasAudioInputDevice()); + // 合法 JSON 但根为对象(非数组)→ array() 为空 → 0 端口 → false + cardsJson = QByteArrayLiteral("{\"Id\":0,\"Ports\":[]}"); + EXPECT_FALSE(ins->hasAudioOutputDevice()); + // 空数组 → 0 端口 → false + cardsJson = QByteArrayLiteral("[]"); + EXPECT_FALSE(ins->hasAudioInputDevice()); + // 端口 Enabled 但 Direction 为非法值(9):输出/输入方向计数均为 0 → false + cardsJson = QByteArrayLiteral( + "[{\"Id\":7,\"Name\":\"usb-audio\",\"Ports\":[{\"Name\":\"spk\",\"Enabled\":true,\"Direction\":9}]}]"); + EXPECT_FALSE(ins->hasAudioOutputDevice()); + EXPECT_FALSE(ins->hasAudioInputDevice()); + // Direction 匹配但 Enabled=false,countEnabledPorts 计数条件不满足 → false + cardsJson = QByteArrayLiteral( + "[{\"Id\":7,\"Name\":\"usb-audio\",\"Ports\":[{\"Name\":\"spk\",\"Enabled\":false,\"Direction\":1}]}]"); + EXPECT_FALSE(ins->hasAudioOutputDevice()); + // 以上全程仅读取属性,无 DBus 方法调用 + EXPECT_EQ(dbusCallCount, 0); +} diff --git a/tests/common2/test_textfilesaver.cpp b/tests/common2/test_textfilesaver.cpp index 7db68852..0204b255 100644 --- a/tests/common2/test_textfilesaver.cpp +++ b/tests/common2/test_textfilesaver.cpp @@ -350,3 +350,64 @@ TEST_F(TextFileSaverTest, ErrorString_InitiallyEmpty_ReturnsEmptyByDefault) EXPECT_FALSE(obj->save()); EXPECT_FALSE(obj->errorString().isEmpty()); } + +// ============================================================================ +// PMS 批次 2 回归用例(仅追加) +// ============================================================================ + +// PMS: https://pms.uniontech.com/bug-view-327987.html commit: 5cacfb18 +// 场景:禁用 QSaveFile 防超长文件名/权限问题(5cacfb18):save() 直接用 QFile +// 保存,超过 MAX_FILENAME_LENGTH(245) 的长文件名(QSaveFile 临时文件名 +// 会超文件系统 255 字节上限,327987 根因)须可完整保存且内容可读回 +TEST_F(TextFileSaverTest, BUG327987_LongFileName_DirectQFileSave_Succeeds) +{ + // Arrange: 248 个 'a' + ".txt" = 252 字节(> 245,且 ≤ 文件系统 255 字节上限) + const QString longName = QString(248, QLatin1Char('a')) + QStringLiteral(".txt"); + ASSERT_EQ(longName.length(), 252); + const QString expectedText = QStringLiteral("long filename content 中文"); + doc->setPlainText(expectedText); + obj->setFilePath(filePath(longName)); + // Act + const bool ret = obj->save(); + // Assert: 保存成功、errorString 清空、内容完整可读回 + EXPECT_TRUE(ret); + EXPECT_TRUE(obj->errorString().isEmpty()); + QFile f(filePath(longName)); + ASSERT_TRUE(f.open(QIODevice::ReadOnly)); + EXPECT_EQ(f.readAll(), expectedText.toUtf8()); + f.close(); +} + +// PMS: https://pms.uniontech.com/bug-view-327987.html commit: 5cacfb18 +// 场景:QSaveFile 经临时文件 rename 提交会用临时文件权限(umask 派生,通常 0644) +// 覆盖原文件权限——327987 根因之一;修复后 save() 直接 QFile 覆写既有文件, +// inode 不重建,原有权限必须保持不变 +TEST_F(TextFileSaverTest, BUG327987_SaveOverExistingFile_PreservesPermissions) +{ + // Arrange: 预创建文件并 chmod 为 0664(含组写,区别于 QSaveFile rename 后的 0644) + const QString path = filePath(QStringLiteral("perm_check.txt")); + { + QFile pre(path); + ASSERT_TRUE(pre.open(QIODevice::WriteOnly)); + pre.write(QByteArrayLiteral("old")); + } + const QFile::Permissions expected = QFile::ReadOwner | QFile::WriteOwner + | QFile::ReadGroup | QFile::WriteGroup + | QFile::ReadOther; // 0664 + ASSERT_TRUE(QFile::setPermissions(path, expected)); + // Qt6 中 QFile::permissions() 对 Unix 文件同时置 Owner 与 User 位别名(0664 → 0x6664), + // 故以前后快照对比断言“权限保持”,不硬编码 flag 联合值 + const QFile::Permissions permsBefore = QFile::permissions(path); + ASSERT_TRUE(permsBefore & QFile::WriteGroup); // 确认组写已置(QSaveFile rename 后会丢失) + doc->setPlainText(QStringLiteral("updated")); + obj->setFilePath(path); + // Act: 直接 QFile 覆写(Truncate,不重建 inode) + const bool ret = obj->save(); + // Assert: 保存成功、内容更新、权限保持 0664 不变 + EXPECT_TRUE(ret); + EXPECT_EQ(QFile::permissions(path), permsBefore); + QFile f(path); + ASSERT_TRUE(f.open(QIODevice::ReadOnly)); + EXPECT_EQ(f.readAll(), QStringLiteral("updated").toUtf8()); + f.close(); +} diff --git a/tests/controls_ut/test_findbar.cpp b/tests/controls_ut/test_findbar.cpp index a6b5f023..69847945 100644 --- a/tests/controls_ut/test_findbar.cpp +++ b/tests/controls_ut/test_findbar.cpp @@ -502,4 +502,27 @@ TEST_P(FindBarKeywordTest, GetCurrentSearchText_AfterTyped_ReflectsEditLineConte EXPECT_EQ(bar->getCurrentSearchText(), editLine->lineEdit()->text()); // 双向一致 } +// ---- PMS bug 回归用例(批次 2)---- + +// PMS: https://pms.uniontech.com/bug-view-56153.html commit: 4fb7deff +// 场景:编辑不保存关闭重开显示异常(4fb7deff):构造函数移除 contentChanged → +// slot_ifClearSearchWord 陈旧连接(slot 已删除);触发 LineBar::contentChanged +// 不得清空关键词/破坏栏状态。activeInput 语义已由 ActiveInput_WithKeyword/ +// ActiveInput_EmptyKeyword 两用例覆盖。 +TEST_F(FindBarTest, BUG56153_Constructor_ContentChangedNotWired_KeepsKeyword) +{ + // Arrange:填充关键词并显示 + bar->activeInput(QString::fromUtf8("kw56153"), QString::fromUtf8("/ut/56153.txt"), 3, 4, 5); + ASSERT_EQ(bar->getCurrentSearchText(), QString::fromUtf8("kw56153")); + ASSERT_TRUE(bar->isVisible()); + + // Act:直发 LineBar::contentChanged(4fb7deff 前该信号连接 slot_ifClearSearchWord) + emit editLine->contentChanged(); + QApplication::processEvents(); + + // Assert:关键词保留、栏仍显示、状态未破坏 + EXPECT_EQ(bar->getCurrentSearchText(), QString::fromUtf8("kw56153")); + EXPECT_TRUE(bar->isVisible()); +} + } // namespace diff --git a/tests/controls_ut/test_tabbar.cpp b/tests/controls_ut/test_tabbar.cpp index 9deed58a..010a8c5f 100644 --- a/tests/controls_ut/test_tabbar.cpp +++ b/tests/controls_ut/test_tabbar.cpp @@ -1530,4 +1530,104 @@ TEST_F(TabbarTest, SettingsResource_QrcLinked_InstanceAvailable) EXPECT_EQ(Settings::instance(), Settings::instance()); } +// ---- PMS bug 回归用例(批次 2)---- + +// PMS: https://pms.uniontech.com/bug-view-305473.html commit: 65bc75a3 +// 场景:主题色切换文字显示不对(65bc75a3):修复本体为 setTabPalette 在 Qt6 下跳过 +// 手动 setPalette(由 SetTabPalette_AnyColors_NoStateCorruption 覆盖;主题渲染 +// 效果 offscreen 不可断言,SKIP);此处补批次指定 updateTab 的 localDataPath +// (空白/草稿文件)分支缺口:tooltip 直接用 tabName,不走路径换行重排。 +TEST_F(TabbarTest, BUG305473_UpdateTab_LocalDraftTooltipUsesTabName) +{ + installWindowRecorderStubs(nullptr); + addTabsQuietly({ localRoot + "/before.txt" }); + + // Act:更新为 localDataPath 下的草稿路径(命中 localDataPath 含分支) + const QString draft = localRoot + "/draft-after.txt"; + bar->updateTab(0, draft, QString::fromUtf8("草稿&后.txt")); + + // Assert:路径表同步更新;tooltip = tabName 原文(不经换行重排、无 '\n') + EXPECT_EQ(bar->fileAt(0), draft); + EXPECT_EQ(bar->m_tabTruePaths.at(0), draft); + EXPECT_EQ(bar->tabToolTip(0), QString::fromUtf8("草稿&后.txt")); + EXPECT_FALSE(bar->tabToolTip(0).contains('\n')); +} + +// PMS: https://pms.uniontech.com/bug-view-67950.html commit: 9eb4f93c +// 场景:安全测试 17 处内存泄露(9eb4f93c):~Tabbar 增加 m_rightMenu/ +// m_moreWaysCloseMenu 判空释放;右键菜单分配后销毁 Tabbar 不崩溃、 +// 菜单逐一经判空释放(泄漏由 ASAN/valgrind 构建守护)。构造函数 +// setAcceptDrops(true)(e8be974a)已由 Constructor_DefaultParent 断言覆盖。 +TEST_F(TabbarTest, BUG67950_Destructor_AfterMenusAllocated_DestroysWithoutCrash) +{ + installWindowRecorderStubs(nullptr); + addTabsQuietly({ localRoot + "/dtor0.txt", localRoot + "/dtor1.txt" }); + + // Arrange:右键命中标签 → 分配 m_rightMenu / m_moreWaysCloseMenu + const QRect rect = bar->tabRect(0); + ASSERT_FALSE(rect.isNull()); + QMouseEvent rightPress(QEvent::MouseButtonPress, rect.center(), bar->mapToGlobal(rect.center()), + Qt::RightButton, Qt::RightButton, Qt::NoModifier); + ASSERT_TRUE(bar->eventFilter(bar, &rightPress)); + ASSERT_NE(bar->m_rightMenu, nullptr); + ASSERT_NE(bar->m_moreWaysCloseMenu, nullptr); + + // Act + Assert:析构走判空释放分支(9eb4f93c 修复点),不崩溃 + delete bar; + bar = nullptr; + SUCCEED(); +} + +// PMS: https://pms.uniontech.com/bug-view-97737.html commit: 2ac30a62 +// 场景:触摸屏/wayland 下拖拽窗口中唯一标签页应用闪退(2ac30a62):旧代码 +// count()==1 时 window()->hide(),wayland 下引发闪退;修复后 wayland +// 不再隐藏窗口。previousTab/nextTab 边界回绕由 StepTab_BoundaryIndex_ +// WrapsOrSteps 覆盖。 +struct DragPixmapSingleTabCase { + bool wayland; // Utils::isWayland 桩值 + bool expectMinimized; // 期望拖拽后窗口被最小化(非 wayland 现行为) +}; + +class TabbarDragSingleTabTest : public TabbarTest, + public ::testing::WithParamInterface { +}; + +TEST_P(TabbarDragSingleTabTest, BUG97737_CreateDragPixmap_SingleTab_WindowStateFollowsPlatform) +{ + EditWrapper *wrapper = nullptr; + installEditWrapperCtorStubs(stub, host, &wrapper); + wrapper->textEditor()->setSettings(Settings::instance()); + installWindowRecorderStubs(wrapper); + const bool wayland = GetParam().wayland; + stub.set_lamda(&Utils::isWayland, [wayland]() -> bool { return wayland; }); + stub.set_lamda(&DWindowManagerHelper::hasComposite, + [](DWindowManagerHelper *) -> bool { return false; }); + addTabsQuietly({ localRoot + "/single.txt" }); + ASSERT_EQ(bar->count(), 1); // 唯一标签页场景 + ASSERT_TRUE(host->isVisible()); + + // Act:拖拽唯一标签页(真实 EditWrapper 渲染链路) + QStyleOptionTab option; + QPoint hotspot; + const QPixmap pixmap = bar->createDragPixmapFromTab(0, option, &hotspot); + + QApplication::processEvents(); + + // Assert:位图非空;wayland 单标签不隐藏/最小化窗口(97737 回归点), + // 非 wayland 走 showMinimized(现合并提示行为) + EXPECT_FALSE(pixmap.isNull()); + if (GetParam().expectMinimized) { + EXPECT_TRUE(host->windowState() & Qt::WindowMinimized); + } else { + EXPECT_FALSE(host->windowState() & Qt::WindowMinimized); + EXPECT_TRUE(host->isVisible()); + } +} + +INSTANTIATE_TEST_SUITE_P( + SingleTabDrag, TabbarDragSingleTabTest, + ::testing::Values( + DragPixmapSingleTabCase{ true, false }, // wayland:不最小化(97737 回归点) + DragPixmapSingleTabCase{ false, true })); // 非 wayland:showMinimized + } // namespace diff --git a/tests/editor_areas/test_flashtween.cpp b/tests/editor_areas/test_flashtween.cpp index c29dbab2..de3bd230 100644 --- a/tests/editor_areas/test_flashtween.cpp +++ b/tests/editor_areas/test_flashtween.cpp @@ -51,12 +51,15 @@ // - EasingEndpoints_StandardFormulas_HitBoundaries /*TEST_P*/ → 端点 b / b+c // - BounceEaseOut_FourSegments_MatchFormula /*TEST_P*/ → B9~B12 // - Destructor_ScopeExit_DeletesOwnedTimers → dtor +// - BUG67950_DestroyWhileRunning_TimersReleasedNoDangling → PMS 67950 析构释放 +// - BUG67950_StopThenDestroy_TimersReleasedNoDangling → PMS 67950 停止后析构 #include #include "FlashTween.h" #include #include +#include #include namespace { @@ -476,3 +479,64 @@ TEST_F(FlashTweenTest, Destructor_ScopeExit_DeletesOwnedTimers) EXPECT_FALSE(fresh->activeY()); delete fresh; } + +// ============================================================ +// PMS 批次 2 回归用例(bug 67950) +// PMS: https://pms.uniontech.com/bug-view-67950.html commit: 9eb4f93c +// 场景:内存释放修复(9eb4f93c):~FlashTween() 由空实现改为显式 delete +// m_timerX/m_timerY 并置空,startY/startX/__runY 定时器操作加判空保护; +// 以 QPointer 托管定时器,断言动画运行中析构无悬空定时器(Asan 泄漏回归) +// ============================================================ +TEST_F(FlashTweenTest, BUG67950_DestroyWhileRunning_TimersReleasedNoDangling) +{ + // Arrange: QPointer 托管两个动画定时器(修复后成员公开,可观测托管) + QPointer timerX(obj->m_timerX); + QPointer timerY(obj->m_timerY); + ASSERT_NE(timerX.data(), nullptr); + ASSERT_NE(timerY.data(), nullptr); + + // Act: 双轴启动惯性滑动并各驱动一拍(动画运行中析构,走 B5/B6 判空与 B7 步进分支) + obj->startY(0, 0, 100.0, 30.0, fnY); + obj->startX(0, 0, 100.0, 30.0, fnX); + obj->__runY(); + ASSERT_TRUE(obj->activeY()); + ASSERT_TRUE(obj->activeX()); + + delete obj; + obj = nullptr; + + // Assert: QPointer 失效 → 定时器已随析构释放(修复前空析构定时器悬空泄漏) + EXPECT_TRUE(timerX.isNull()); + EXPECT_TRUE(timerY.isNull()); +} + +// PMS: https://pms.uniontech.com/bug-view-67950.html commit: 9eb4f93c +// 场景:动画完整停止后析构(9eb4f93c):startY/startX 驱动至完成(__runY/__runX +// 走 B8 完成分支自动 stop)+ 幂等 stop 后析构,定时器仍随析构释放 +TEST_F(FlashTweenTest, BUG67950_StopThenDestroy_TimersReleasedNoDangling) +{ + // Arrange: QPointer 托管 + 双轴动画启动 + QPointer timerX(obj->m_timerX); + QPointer timerY(obj->m_timerY); + obj->startY(0, 0, 100.0, 30.0, fnY); + obj->startX(0, 0, 100.0, 30.0, fnX); + + // Act: 各轴三拍走完(t=0,15,30 → B8 完成自动 stop),再幂等 stop + obj->__runY(); + obj->__runY(); + obj->__runY(); + obj->__runX(); + obj->__runX(); + obj->__runX(); + ASSERT_FALSE(obj->activeY()); + ASSERT_FALSE(obj->activeX()); + obj->stopY(); + obj->stopX(); + + delete obj; + obj = nullptr; + + // Assert: 定时器随析构释放,无悬空、无崩溃 + EXPECT_TRUE(timerX.isNull()); + EXPECT_TRUE(timerY.isNull()); +} diff --git a/tests/editor_core/test_dtextedit_cursor.cpp b/tests/editor_core/test_dtextedit_cursor.cpp index 960f63f2..5e8db1b6 100644 --- a/tests/editor_core/test_dtextedit_cursor.cpp +++ b/tests/editor_core/test_dtextedit_cursor.cpp @@ -838,3 +838,76 @@ TEST_F(TextEditTest, LeftAreaUpdateState_SetFileOpenEnd_TriggersUpdateAll) // Assert EXPECT_EQ(edit->getLeftAreaUpdateState(), TextEdit::FileOpenEnd); } + +// ============================================================================ +// PMS 回归用例(Mode 2 PMS bug 回归,批次 2) +// 数据源:tests/.ut-pms/bugs.json + git show +// ============================================================================ + +#include "leftareaoftextedit.h" // BUG215591:LeftAreaTextEdit::updateAll 计数桩 + +// PMS: https://pms.uniontech.com/bug-view-215591.html commit: d0935120 +// 场景:大文件加载过程中点击序号阻塞修复(d0935120):加载中(FileOpenBegin) +// 仅记录左侧区状态、不触发手动更新;updateLeftAreaWidget 滚动联动不阻塞, +// FileOpenEnd 才手动触发一次 updateAll +TEST_F(TextEditTest, BUG215591_UpdateLeftAreaWidget_FileOpenBegin_NoHang) +{ + // Arrange: 统计左侧区手动更新次数(stub updateAll 计数) + int updateAllCalls = 0; + stub.set_lamda(&LeftAreaTextEdit::updateAll, + [&updateAllCalls](LeftAreaTextEdit *) { ++updateAllCalls; }); + + // Act: 进入文件加载状态(FileOpenBegin) + edit->setLeftAreaUpdateState(TextEdit::FileOpenBegin); + + // Assert: 状态已记录,加载中不触发手动更新 + EXPECT_EQ(edit->getLeftAreaUpdateState(), TextEdit::FileOpenBegin); + EXPECT_EQ(updateAllCalls, 0); + + // Act: 加载过程中滚动联动 updateLeftAreaWidget —— 不阻塞、状态保持 + edit->updateLeftAreaWidget(); + + // Assert: 滚动联动正常执行手动更新(+1),状态不被清除 + EXPECT_EQ(edit->getLeftAreaUpdateState(), TextEdit::FileOpenBegin); + EXPECT_EQ(updateAllCalls, 1); + + // Act: 加载结束 → 手动触发一次界面更新 + edit->setLeftAreaUpdateState(TextEdit::FileOpenEnd); + + // Assert: FileOpenEnd 才触发手动更新(累计 2 次) + EXPECT_EQ(edit->getLeftAreaUpdateState(), TextEdit::FileOpenEnd); + EXPECT_EQ(updateAllCalls, 2); +} + +// PMS: https://pms.uniontech.com/bug-view-66378.html commit: e3cbab1d / d242fe4f +// 场景:大文本卡死修复(d242fe4f/e3cbab1d)回归:大文本滚动/跳转场景下 +// firstVisibleBlock/getFirstVisibleBlockId/jumpToLine 导航辅助不卡死且同步有效 +TEST_F(TextEditTest, BUG66378_FirstVisibleBlockId_ScrolledDoc_NoHang) +{ + // Arrange: 400 行大文本 + 缩小视口制造滚动条,滚动到中部(maximum()/2) + QString text; + for (int i = 0; i < 400; ++i) { + text += QString("line %1 content\n").arg(i); + } + setDocText(text); + edit->resize(200, 100); + auto *bar = edit->verticalScrollBar(); + ASSERT_GT(bar->maximum(), 0); + bar->setValue(bar->maximum() / 2); + + // Act: 大文本滚动状态下获取首可见块 + const int blockId = edit->getFirstVisibleBlockId(); + const QTextBlock block = edit->firstVisibleBlock(); + + // Assert: 返回合法且非首块的可见块(视图已滚动),firstVisibleBlock 同步 + ASSERT_TRUE(block.isValid()); + EXPECT_GT(blockId, 0); + EXPECT_GT(block.blockNumber(), 0); + + // Act: jumpToLine 跳转中部行(keepLineAtCenter=false) + edit->jumpToLine(200, false); + + // Assert: 光标落在目标行,firstVisibleBlock 仍同步有效 + EXPECT_EQ(edit->textCursor().blockNumber(), 199); + EXPECT_TRUE(edit->firstVisibleBlock().isValid()); +} diff --git a/tests/editor_core/test_dtextedit_edit.cpp b/tests/editor_core/test_dtextedit_edit.cpp index eeee3f94..2421e327 100644 --- a/tests/editor_core/test_dtextedit_edit.cpp +++ b/tests/editor_core/test_dtextedit_edit.cpp @@ -1312,3 +1312,35 @@ TEST_F(TextEditTest, OnTextContentChanged_NormalEdit_NoExtraCommand) EXPECT_EQ(edit->toPlainText(), QString("x")); EXPECT_EQ(edit->blockCount(), 1); // 回滚后仍单块 } + +// ============================================================================ +// PMS 回归用例(Mode 2 PMS bug 回归,批次 2) +// 数据源:tests/.ut-pms/bugs.json + git show +// ============================================================================ + +// PMS: https://pms.uniontech.com/bug-view-60989.html commit: b902de7b +// 场景:大文件查找/替换卡死修复(b902de7b:高亮改为视图内收集)回归:大文本 +// 全选状态下 updateFont 走 selectTextInView 视图内选区,不整篇重建、不卡死 +TEST_F(TextEditTest, BUG60989_UpdateFont_SelectAllInView_NoHang) +{ + // Arrange: 大文本 + 全选标志(m_isSelectAll 下 updateFont 调 selectTextInView) + QString text; + for (int i = 0; i < 300; ++i) { + text += QString("font line %1\n").arg(i); + } + setDocText(text); + edit->m_isSelectAll = true; + + // Act: 更新字体(setFontSize → updateFont → selectTextInView 视图内选区) + edit->setFontSize(20); + + // Assert: 默认字体字号已更新,制表位同步,全选标志保持 + EXPECT_NEAR(edit->document()->defaultFont().pointSizeF(), 20.0, 0.001); + EXPECT_GT(edit->tabStopDistance(), 0); + EXPECT_TRUE(edit->m_isSelectAll); + + // Assert: 选区仅覆盖视图范围(不整篇文档重建 —— 60989 卡死修复语义) + const QTextCursor cur = edit->textCursor(); + EXPECT_TRUE(cur.hasSelection()); + EXPECT_LT(cur.selectionEnd() - cur.selectionStart(), edit->document()->characterCount()); +} diff --git a/tests/editor_core/test_dtextedit_event.cpp b/tests/editor_core/test_dtextedit_event.cpp index 7745d71e..f24c0a76 100644 --- a/tests/editor_core/test_dtextedit_event.cpp +++ b/tests/editor_core/test_dtextedit_event.cpp @@ -1708,3 +1708,140 @@ TEST_F(TextEditTest, ViewModeActionTriggered_EmitsViewModeRequested) EXPECT_EQ(spy.last().at(0).value(), ViewMode::ReadView); EXPECT_EQ(spy.count(), 1); } + +// ============================================================================ +// PMS 回归用例(Mode 2 PMS bug 回归,批次 2) +// 数据源:tests/.ut-pms/bugs.json + git show +// ============================================================================ + +// PMS: https://pms.uniontech.com/bug-view-273663.html commit: 889fa196 +// 场景:列模式编辑撤销崩溃修复(889fa196 引入 DeleteBackAltCommand + 列选区 +// 恢复):列剪切 → 撤销 → 文本恢复,列选区经 restoreColumnEditSelection 还原 +TEST_F(TextEditTest, BUG273663_ColumnCutUndo_RestoresTextAndSelections) +{ + // Arrange: 三行各造一个列选区(每行前 2 字符) + setDocText(QString("aa\nbb\ncc\n")); + QList sels; + for (int line = 0; line < 3; ++line) { + QTextCursor cur(edit->document()); + const int blockPos = edit->document()->findBlockByNumber(line).position(); + cur.setPosition(blockPos); + cur.setPosition(blockPos + 2, QTextCursor::KeepAnchor); + QTextEdit::ExtraSelection sel; + sel.cursor = cur; + sels << sel; + } + edit->restoreColumnEditSelection(sels); + edit->m_bIsAltMod = true; + + // Act: 列剪切(经 DeleteBackAltCommand 压栈) + edit->cut(true); + + // Assert: 每行选中字符被删除,剪贴板为列内容,列选区仍可恢复 + EXPECT_EQ(edit->toPlainText(), QString("\n\n\n")); + EXPECT_EQ(QApplication::clipboard()->text(), QString("aa\nbb\ncc")); + EXPECT_FALSE(edit->m_altModSelections.isEmpty()); + + // Act: 撤销(undo_ → DeleteBackAltCommand::undo → 文本回插 + 列选区还原) + edit->undo_(); + + // Assert: 文本恢复、列选区经 restoreColumnEditSelection 还原为 3 条 + EXPECT_EQ(edit->toPlainText(), QString("aa\nbb\ncc\n")); + ASSERT_EQ(edit->m_altModSelections.size(), 3); + EXPECT_EQ(edit->m_altModSelections.first().cursor.selectedText(), QString("aa")); + EXPECT_TRUE(edit->m_bIsAltMod); // refreshUndoRedoColumnStatus 保持列编辑状态 +} + +// PMS: https://pms.uniontech.com/bug-view-95115.html commit: 17033b21 +// 场景:全选复制粘贴标志位修复(17033b21:selectTextInView 光标锚点顺序修正 + +// 首次粘贴后 m_isSelectAll 复位);lineNumberAreaPaintEvent 渲染烟测不崩溃 +TEST_F(TextEditTest, BUG95115_LineNumberAreaPaintEvent_SmokeNoCrash) +{ + // Arrange: 大文本 + 全选标志(95115/79951 全选粘贴场景) + QString text; + for (int i = 0; i < 300; ++i) { + text += QString("paint line %1\n").arg(i); + } + setDocText(text); + edit->m_isSelectAll = true; + edit->selectTextInView(); + + // Assert: 17033b21 修复后视图内全选覆盖可视范围(首可见位置至视口底部) + const QTextCursor selCur = edit->textCursor(); + EXPECT_TRUE(selCur.hasSelection()); + EXPECT_EQ(std::min(selCur.anchor(), selCur.position()), 0); + EXPECT_GT(std::max(selCur.anchor(), selCur.position()), 0); + + // Act: 序号区绘制烟测(直接驱动 paintEvent,深浅主题两分支均不崩溃) + QPaintEvent paintEv(QRect(0, 0, 40, 300)); + edit->lineNumberAreaPaintEvent(&paintEv); + + // Assert: 绘制完成后行号颜色 alpha 已按主题分支设置(dark 0.2 / light 0.3) + const qreal alpha = edit->m_lineNumbersColor.alphaF(); + EXPECT_TRUE(qAbs(alpha - 0.2) < 0.01 || qAbs(alpha - 0.3) < 0.01); + + // Act: 首次粘贴后 m_isSelectAll 复位(95115 标志位修复语义) + QApplication::clipboard()->setText(QString("x")); + edit->paste(); + + // Assert + EXPECT_FALSE(edit->m_isSelectAll); +} + +// ---------------- PMS 回归:109625 剪贴板拖拽字符到文本失败(df0f7bcb dropEvent 文本数据处理) ---------------- + +// PMS: https://pms.uniontech.com/bug-view-109625.html commit: df0f7bcb +// 场景:外部剪贴板文本拖入编辑器(无 source)→ 文本插入文档并入撤销栈,不崩溃 +TEST_F(TextEditTest, BUG109625_DropEvent_TextDropped_Inserted) +{ + // Arrange:文档有内容、光标在行尾;构造外部文本拖入(无 source,与拖拽字符到文本场景一致) + setDocText(QString("body ")); + moveCursorTo(5); + QMimeData data; + data.setText(QString("拖入文本")); + QDropEvent ev(QPointF(10, 8), Qt::CopyAction, &data, Qt::NoButton, Qt::NoModifier); + + // Act + edit->dropEvent(&ev); + + // Assert:拖入文本已插入(修复前剪贴板拖拽字符到文本失败)且入撤销栈 + EXPECT_TRUE(edit->toPlainText().contains(QString("拖入文本"))); + EXPECT_TRUE(edit->isUndoRedoOpt()); +} + +// PMS: https://pms.uniontech.com/bug-view-109625.html commit: df0f7bcb +// 场景:>1MB 大文本拖入 → 入撤销栈且文本完整进入文档(df0f7bcb 中 size>1MB 走 InsertBlockByTextCommand 入栈;6.x 重构后 dropEvent 外部文本统一经 DragInsertTextUndoCommand 入栈) +TEST_F(TextEditTest, BUG109625_DropEvent_LargeText_BlockCommand) +{ + // Arrange:1MB+16 字符大文本(对应修复 commit 的 1MB 阈值),无 source 外部拖入 + const int kLargeChars = 1024 * 1024 + 16; + QMimeData data; + data.setText(QString(kLargeChars, QChar('a'))); + QDropEvent ev(QPointF(10, 8), Qt::CopyAction, &data, Qt::NoButton, Qt::NoModifier); + + // Act + edit->dropEvent(&ev); + + // Assert:大文本完整进入文档、撤销栈有记录、无崩溃 + EXPECT_EQ(edit->toPlainText().size(), kLargeChars); + EXPECT_TRUE(edit->isUndoRedoOpt()); +} + +// PMS: https://pms.uniontech.com/bug-view-109625.html commit: df0f7bcb +// 场景:带 Alt 修饰键的文本拖入 → 不崩溃且文本入文档(df0f7bcb 中 Alt 走 insertColumnEditTextEx 列编辑;6.x 重构后该修饰键分流移至 pasteText/inputMethodEvent,dropEvent 对 Alt 拖入统一走外部文本插入路径) +TEST_F(TextEditTest, BUG109625_DropEvent_AltModifier_ColumnEdit) +{ + // Arrange:构造带 AltModifier 的外部文本拖入(无 source) + setDocText(QString("body ")); + moveCursorTo(5); + QMimeData data; + data.setText(QString("alt拖入")); + QDropEvent ev(QPointF(10, 8), Qt::CopyAction, &data, Qt::NoButton, Qt::AltModifier); + + // Act + edit->dropEvent(&ev); + + // Assert:Alt 修饰键拖入不崩溃,文本被插入并入撤销栈 + EXPECT_TRUE(edit->toPlainText().contains(QString("alt拖入"))); + EXPECT_TRUE(edit->isUndoRedoOpt()); +} diff --git a/tests/editor_core/test_dtextedit_find.cpp b/tests/editor_core/test_dtextedit_find.cpp index ef844e6a..b4a44414 100644 --- a/tests/editor_core/test_dtextedit_find.cpp +++ b/tests/editor_core/test_dtextedit_find.cpp @@ -828,3 +828,172 @@ TEST_F(TextEditTest, RenderAllSelections_MixedSelections_AppliedToViewport) EXPECT_GE(total, 3); EXPECT_TRUE(edit->m_HightlightYes); // 当前行高亮已入渲染链 } + +// ============================================================================ +// PMS 回归用例(Mode 7 PMS 缺陷热点补强,批次 1) +// 数据源:tests/.ut-pms/(bugs.json / work-order.md) +// ============================================================================ + +// PMS: https://pms.uniontech.com/bug-view-60989.html commit: b902de7b +// 场景:查找关键词高亮在替换后选区错乱(b902de7b 将 updateKeywordSelections +// 迁移至 updateKeywordSelectionsInView)。修复后:replaceNext 后剩余高亮 +// 选区正确更新 +TEST_F(TextEditTest, BUG60989_ReplaceNextInView_KeepsHighlightSelections) +{ + // Arrange: 多个匹配的高亮 + setDocText(QString("aa bb cc bb dd bb")); + ASSERT_TRUE(edit->highlightKeyword(QString("bb"), 0)); + const int selBefore = edit->m_findMatchSelections.size(); + ASSERT_GT(selBefore, 1); + + // Act: 视图模式替换(updateKeywordSelectionsInView 选区更新) + edit->replaceNext(QString("bb"), QString("XX")); + + // Assert: 替换后剩余高亮选区 -1、内容正确 + EXPECT_EQ(edit->toPlainText(), QString("aa XX cc bb dd bb")); + EXPECT_EQ(edit->m_findMatchSelections.size(), selBefore - 1); +} + +// ============================================================================ +// PMS 回归用例(Mode 2 PMS bug 回归,批次 2) +// 数据源:tests/.ut-pms/bugs.json + git show +// ============================================================================ + +// PMS: https://pms.uniontech.com/bug-view-66378.html commit: e3cbab1d / d242fe4f +// 场景:大文本"标记所有"卡死修复(d242fe4f/e3cbab1d 引入按视图收集 + 时间戳): +// markKeywordInView 只收集可视范围内匹配,不再整篇文档构建 QList 引起卡死 +TEST_F(TextEditTest, BUG66378_MarkKeywordInView_OnlyVisibleRange) +{ + // Arrange: 400 行全部匹配同一关键字,匹配总数远超可视范围 + QString text; + for (int i = 0; i < 400; ++i) { + text += QString("ut_sync line %1\n").arg(i); + } + setDocText(text); + + // Act: 视图内标记关键字(显式时间戳) + const qint64 stamp = 1710490000000; + const bool ret = edit->markKeywordInView(QString("ut_sync"), QString("#FF0000"), stamp); + + // Assert: 标记成功,仅收集视图内匹配(远少于全文 400 处),时间戳已记录 + EXPECT_TRUE(ret); + ASSERT_TRUE(edit->m_mapKeywordMarkSelections.contains(QString("ut_sync"))); + const auto &marked = edit->m_mapKeywordMarkSelections[QString("ut_sync")]; + EXPECT_GE(marked.size(), 1); + EXPECT_LT(marked.size(), 400); + EXPECT_EQ(marked.first().second, stamp); + + // Assert: 所有收集的选区都位于视图起始范围(前半篇文档内) + for (const auto &pair : marked) { + const int blockNo = pair.first.cursor.blockNumber(); + EXPECT_GE(blockNo, 0); + EXPECT_LT(blockNo, 200); + } +} + +// PMS: https://pms.uniontech.com/bug-view-67950.html commit: 9eb4f93c +// 场景:17 处内存泄露修复(9eb4f93c):滚动动画/右键菜单析构中判空停止释放。 +// replaceRest 带标记替换后 + 动画运行中析构 TextEdit —— 无崩溃/悬空指针 +TEST_F(TextEditTest, BUG67950_ReplaceRest_WithMarks_DtorStopsAnimationNoCrash) +{ + // Arrange: 启动滚动动画(析构须停止释放),并造一个颜色标记选区 + setDocText(QString("aa bb aa bb aa")); + edit->scrollToLine(3, 1, 0); + ASSERT_NE(edit->m_scrollAnimation, nullptr); + ASSERT_NE(edit->m_scrollAnimation->state(), QAbstractAnimation::Stopped); + + QTextCursor markCursor = edit->textCursor(); + markCursor.setPosition(3, QTextCursor::MoveAnchor); + markCursor.setPosition(5, QTextCursor::KeepAnchor); // 选中 "bb" + TextEdit::MarkOperation op; + op.type = TextEdit::MarkOnce; + op.cursor = markCursor; + op.color = QString("#FFFF00"); + edit->m_markOperations.append(qMakePair(op, qint64(1710490000000))); + + // Act: 从光标处(起始位置)替换剩余 "aa"→"XX"(标记坐标经 calcMarkReplaceList 偏移) + edit->replaceRest(QString("aa"), QString("XX")); + + // Assert: 光标之后的替换全部生效 + EXPECT_EQ(edit->toPlainText(), QString("XX bb XX bb XX")); + + // Act: 动画运行中构造并析构 TextEdit(9eb4f93c 析构修复:stop + delete) + { + TextEdit *scoped = new TextEdit(); + scoped->setSettings(Settings::instance()); + scoped->setWrapper(fakeWrapper()); + scoped->scrollToLine(2, 1, 0); + ASSERT_NE(scoped->m_scrollAnimation, nullptr); + delete scoped; // 析构中停止并释放动画/菜单,无 UAF/崩溃 + } +} + +// PMS: https://pms.uniontech.com/bug-view-106594.html commit: c310b0e2 +// 场景:复制超大内容闪退修复(c310b0e2 引入 isAbleOperation 内存预检): +// 小数据量(普通选区/列选区/全选/剪贴板粘贴)下内存预检放行,copy 正常执行 +TEST_F(TextEditTest, BUG106594_IsAbleCopyOperation_SmallData_Allowed) +{ + // Arrange: 普通选区(小数据) + setDocText(QString("hello world, hello editor")); + QTextCursor cur = edit->textCursor(); + cur.setPosition(0, QTextCursor::MoveAnchor); + cur.setPosition(5, QTextCursor::KeepAnchor); + edit->setTextCursor(cur); + + // Act/Assert: 小选区复制放行(内存充足时不拦截) + EXPECT_TRUE(edit->isAbleOperation(Utils::CopyOperation)); + + // Act/Assert: 列选区小数据复制放行 + edit->m_bIsAltMod = true; + QTextCursor altCur(edit->document()); + altCur.setPosition(0); + altCur.setPosition(2, QTextCursor::KeepAnchor); + QTextEdit::ExtraSelection altSel; + altSel.cursor = altCur; + edit->m_altModSelections << altSel; + EXPECT_TRUE(edit->isAbleOperation(Utils::CopyOperation)); + edit->m_bIsAltMod = false; + edit->m_altModSelections.clear(); + + // Act/Assert: 全选小文档复制放行,copy() 正常写入剪贴板 + edit->m_isSelectAll = true; + EXPECT_TRUE(edit->isAbleOperation(Utils::CopyOperation)); + edit->copy(true); + EXPECT_EQ(QApplication::clipboard()->text(), edit->toPlainText()); + edit->m_isSelectAll = false; + + // Act/Assert: 剪贴板小文本粘贴预检放行 + QApplication::clipboard()->setText(QString("paste me")); + EXPECT_TRUE(edit->isAbleOperation(Utils::PasteOperation)); +} + +// PMS: https://pms.uniontech.com/bug-view-79951.html commit: d0fe36dc +// 场景:大文件连续全选复制粘贴卡死修复(d0fe36dc 引入 m_isSelectAll 视图内 +// 全选 + 重入守卫):selectTextInView 视图内全选生效,重入被阻断不自激振荡 +TEST_F(TextEditTest, BUG79951_SelectTextInView_ReentryGuarded) +{ + // Arrange: 大文本 + 全选标志 + QString text; + for (int i = 0; i < 200; ++i) { + text += QString("select all line %1\n").arg(i); + } + setDocText(text); + edit->m_isSelectAll = true; + + // Act: 视图内全选 + edit->selectTextInView(); + + // Assert: 生成跨视图选区,重入守卫已复位 + EXPECT_TRUE(edit->textCursor().hasSelection()); + EXPECT_FALSE(edit->m_isSelectingInView); + + // Act: 重入守卫置位后再次调用 —— 立即返回,不再自激振荡(防卡死) + edit->m_isSelectingInView = true; + const QTextCursor before = edit->textCursor(); + edit->selectTextInView(); + + // Assert: 第二次调用被阻断(光标未变化、守卫保持置位) + EXPECT_TRUE(edit->textCursor() == before); + EXPECT_TRUE(edit->m_isSelectingInView); + edit->m_isSelectingInView = false; +} diff --git a/tests/editor_core/test_dtextedit_mark.cpp b/tests/editor_core/test_dtextedit_mark.cpp index 37fe5c7b..8697f84f 100644 --- a/tests/editor_core/test_dtextedit_mark.cpp +++ b/tests/editor_core/test_dtextedit_mark.cpp @@ -1033,3 +1033,454 @@ TEST_F(TextEditTest, BookMarkAreaPaintEvent_WithBookmarks_RendersWithoutCrash) EXPECT_NEAR(edit->m_lineNumbersColor.alphaF(), 0.3, 0.01); EXPECT_TRUE(edit->getBookmarkInfo().contains(1)); } + +// ============================================================================ +// PMS 回归用例(Mode 7 PMS 缺陷热点补强,批次 1) +// 数据源:tests/.ut-pms/(bugs.json / work-order.md) +// ============================================================================ + +// PMS: https://pms.uniontech.com/bug-view-95115.html commit: 17033b21 +// 场景:Ctrl+A 后 Ctrl+C 后多次 Ctrl+V 后回车,粘贴错乱。修复(17033b21): +// paste() 末尾重置 m_isSelectAll = false,后续滚动不误触发 selectTextInView +TEST_F(TextEditTest, BUG95115_PasteAfterSelectAll_ResetsSelectAllFlag) +{ + // Arrange: 全选 + setDocText(QString("original content")); + edit->slotSelectAllAction(); + ASSERT_TRUE(edit->m_isSelectAll); + QApplication::clipboard()->setText(QString("pasted")); + + // Act: 粘贴(paste 末尾 m_isSelectAll = false) + edit->paste(); + QApplication::processEvents(); + + // Assert: 粘贴成功 + 全选标志重置 + EXPECT_EQ(edit->toPlainText(), QStringLiteral("pasted")); + EXPECT_FALSE(edit->m_isSelectAll); +} + +// ============================================================================ +// PMS 回归用例(Mode 2 PMS bug 回归,批次 2) +// 数据源:tests/.ut-pms/bugs.json + 修复 commit diff +// 本批次覆盖功能点:updateHighlightBrackets / getNeedControlLine / +// calcMarkReplaceList / setBookmarkFlagVisable / setCodeFlodFlagVisable / +// slotFlodAllLevel / slotFlodCurrentLevel / slotUnflodCurrentLevel / setMark / +// isNeedShowFoldIcon / setCodeFoldWidgetHide / MarkOperation / MarkReplaceInfo +// (isMarkCurrentLine、updateMarkAllSelectColor、slotPreBookMarkAction、 +// getBookmarkInfo/setBookMarkList、clearMarksForTextCursor、 +// toggleMarkSelections、manualUpdateAllMark 已有既有用例覆盖,见文件头映射) +// ============================================================================ + +#include "showflodcodewidget.h" // 批次 2 追加:m_foldCodeShow 完整类型(仅新增内容) + +// PMS: https://pms.uniontech.com/bug-view-66378.html commit: e3cbab1d +// PMS: https://pms.uniontech.com/bug-view-65228.html commit: d242fe4f +// 场景:大文本"标记所有"卡死修复(e3cbab1d/d242fe4f)后括号高亮算法需正确 +// 处理字符串内括号/转义引号(updateHighlightBrackets:字符串内 '}' 与转义 +// 引号不计入配对深度,配对成功时同时设置首尾括号高亮选区) +TEST_F(TextEditTest, BUG66378_UpdateHighlightBrackets_PairMatchSkipsStringContent) +{ + // Arrange:第 1 行 '{' + 字符串内含转义引号与 '}'(不计入配对),第 3 行为真正配对 '}' + const QString doc = QString("void f() {\n char *s = \"a\\\"}b\";\n}\n"); + setDocText(doc); + QTextBlock b0 = edit->document()->findBlockByNumber(0); + const int bracePos = b0.position() + b0.text().indexOf(QLatin1Char('{')); + moveCursorTo(bracePos); // 光标落在 '{' 上(forward 分支) + + // Act + edit->updateHighlightBrackets(QLatin1Char('{'), QLatin1Char('}')); + + // Assert:首选区覆盖 '{',尾选区选中字符串外真正的 '}'(第 3 行) + EXPECT_EQ(edit->m_beginBracketSelection.cursor.selectedText(), QString("{")); + EXPECT_EQ(edit->m_endBracketSelection.cursor.selectedText(), QString("}")); + EXPECT_EQ(edit->m_endBracketSelection.cursor.blockNumber(), 2); +} + +// PMS: https://pms.uniontech.com/bug-view-66378.html commit: e3cbab1d +// PMS: https://pms.uniontech.com/bug-view-65228.html commit: d242fe4f +// 场景:光标旁无任何括号字符(characterAt(position)/position-1 均非括号)时, +// 不设置括号高亮选区(position-1 回退读取不越界,保持选区为空) +TEST_F(TextEditTest, BUG66378_UpdateHighlightBrackets_NoAdjacentBracket_NoHighlight) +{ + // Arrange:文档无任何括号,光标置于行中 + setDocText(QString("plain text line")); + moveCursorTo(7); + + // Act + edit->updateHighlightBrackets(QLatin1Char('{'), QLatin1Char('}')); + + // Assert:未命中括号 → 首尾高亮选区保持空 + EXPECT_TRUE(edit->m_beginBracketSelection.cursor.isNull()); + EXPECT_TRUE(edit->m_endBracketSelection.cursor.isNull()); +} + +// PMS: https://pms.uniontech.com/bug-view-66378.html commit: e3cbab1d +// 场景:代码折叠控制 getNeedControlLine:命中匹配括号时按区域隐藏/显示 +// (isVisable=false 折叠 beginBlock~endBlock 含 '}' 行并返回 true;true 时还原) +TEST_F(TextEditTest, BUG66378_GetNeedControlLine_FoldAndUnfoldBlocks) +{ + // Arrange:两层嵌套花括号文档 + setDocText(QString("int main()\n{\n if (x)\n {\n return 0;\n }\n return 1;\n}\n")); + + // Act:折叠第 2 行 '{' 的区域 + const bool folded = edit->getNeedControlLine(1, false); + + // Assert:返回 true;区域块(2~7)隐藏,括号行与首行保持可见 + EXPECT_TRUE(folded); + EXPECT_TRUE(edit->document()->findBlockByNumber(0).isVisible()); + EXPECT_TRUE(edit->document()->findBlockByNumber(1).isVisible()); + EXPECT_FALSE(edit->document()->findBlockByNumber(2).isVisible()); + EXPECT_FALSE(edit->document()->findBlockByNumber(4).isVisible()); + EXPECT_FALSE(edit->document()->findBlockByNumber(7).isVisible()); + + // Act:展开还原 + const bool unfolded = edit->getNeedControlLine(1, true); + + // Assert:区域块恢复可见 + EXPECT_TRUE(unfolded); + EXPECT_TRUE(edit->document()->findBlockByNumber(2).isVisible()); + EXPECT_TRUE(edit->document()->findBlockByNumber(7).isVisible()); +} + +// PMS: https://pms.uniontech.com/bug-view-66378.html commit: e3cbab1d +// 场景:左右括号在同一行(endBlock == curBlock)时不执行折叠,返回 false +TEST_F(TextEditTest, BUG66378_GetNeedControlLine_SameLineBraces_NoFold) +{ + // Arrange:单行内配对括号 + setDocText(QString("int x { 0 };")); + + // Act + const bool ret = edit->getNeedControlLine(0, false); + + // Assert:同行括号不折叠,块可见性不变 + EXPECT_FALSE(ret); + EXPECT_TRUE(edit->document()->findBlockByNumber(0).isVisible()); +} + +// PMS: https://pms.uniontech.com/bug-view-79951.html commit: d0fe36dc +// 场景:连续全选复制粘贴卡死修复(d0fe36dc)引入的替换-标记位置联动 +// calcMarkReplaceList:标记位于替换文本右侧时按长度变更量整体偏移 +TEST_F(TextEditTest, BUG79951_CalcMarkReplaceList_MarkShiftsByReplaceOffset) +{ + // Arrange:标记 "bb"([4,6))位于替换文本 "XX"(位置 2)右侧,"XX"→"X" 缩短 1 + QList replaceList; + TextEdit::MarkReplaceInfo info; + info.opt.type = TextEdit::MarkOnce; + info.start = 4; + info.end = 6; + info.time = 1; + replaceList << info; + + // Act + edit->calcMarkReplaceList(replaceList, QString("aaXXbb"), QString("XX"), QString("X"), 0, Qt::CaseSensitive); + + // Assert:右侧标记整体左移 adjustlen=1 → [3,5) + ASSERT_EQ(replaceList.size(), 1); + EXPECT_EQ(replaceList.at(0).start, 3); + EXPECT_EQ(replaceList.at(0).end, 5); +} + +// PMS: https://pms.uniontech.com/bug-view-79951.html commit: d0fe36dc +// 场景:calcMarkReplaceList 分支回归:替换文本完全覆盖标记 → 标记取消(归 0); +// MarkAll 全文标记不参与替换位置调整;replaceText == withText 直接返回 +TEST_F(TextEditTest, BUG79951_CalcMarkReplaceList_ReplaceCoversMarkOrAllSkipped) +{ + // Arrange 1:替换文本 "aaXX" 完全覆盖标记 "XX"([2,4)) + QList replaceList; + TextEdit::MarkReplaceInfo info; + info.opt.type = TextEdit::MarkOnce; + info.start = 2; + info.end = 4; + info.time = 1; + replaceList << info; + + // Act + edit->calcMarkReplaceList(replaceList, QString("aaXXbb"), QString("aaXX"), QString("Q"), 0, Qt::CaseSensitive); + + // Assert:EIntersectInner → 标记取消(start/end 归 0,manualUpdateAllMark 会移除) + EXPECT_EQ(replaceList.at(0).start, 0); + EXPECT_EQ(replaceList.at(0).end, 0); + + // Arrange 2:仅含 MarkAll 类型标记 + QList allList; + TextEdit::MarkReplaceInfo allInfo; + allInfo.opt.type = TextEdit::MarkAll; + allInfo.start = 1; + allInfo.end = 3; + allInfo.time = 2; + allList << allInfo; + + // Act:全文替换 "XX"→"LONGER" + edit->calcMarkReplaceList(allList, QString("aaXXbb"), QString("XX"), QString("LONGER"), 0, Qt::CaseSensitive); + + // Assert:MarkAll 类型跳过调整,保持不变 + EXPECT_EQ(allList.at(0).start, 1); + EXPECT_EQ(allList.at(0).end, 3); + + // Arrange 3:replaceText == withText + QList sameList; + TextEdit::MarkReplaceInfo sameInfo; + sameInfo.opt.type = TextEdit::MarkOnce; + sameInfo.start = 4; + sameInfo.end = 6; + sameList << sameInfo; + + // Act + edit->calcMarkReplaceList(sameList, QString("aaXXbb"), QString("XX"), QString("XX"), 0, Qt::CaseSensitive); + + // Assert:相同文本直接返回,无变化 + EXPECT_EQ(sameList.at(0).start, 4); + EXPECT_EQ(sameList.at(0).end, 6); +} + +// PMS: https://pms.uniontech.com/bug-view-215591.html commit: d0935120 +// 场景:点击序号列阻塞修复(d0935120,实际修复 onPressedLineNumber 死循环, +// 相关用例见 test_dtextedit_find.cpp F15)同提交涉及左边栏标志接口回归: +// setBookmarkFlagVisable/setCodeFlodFlagVisable 同步标志位与列隐藏/显示状态 +TEST_F(TextEditTest, BUG215591_SetBookmarkAndFlodFlagVisable_TogglesLeftAreas) +{ + // Arrange + ASSERT_NE(edit->m_pLeftAreaWidget, nullptr); + ASSERT_NE(edit->m_pLeftAreaWidget->m_pBookMarkArea, nullptr); + ASSERT_NE(edit->m_pLeftAreaWidget->m_pFlodArea, nullptr); + + // Act:隐藏书签列 + edit->setBookmarkFlagVisable(false); + // Assert:标志位与控件隐藏态同步 + EXPECT_FALSE(edit->m_pIsShowBookmarkArea); + EXPECT_TRUE(edit->m_pLeftAreaWidget->m_pBookMarkArea->isHidden()); + + // Act:显示书签列 + edit->setBookmarkFlagVisable(true); + // Assert + EXPECT_TRUE(edit->m_pIsShowBookmarkArea); + EXPECT_FALSE(edit->m_pLeftAreaWidget->m_pBookMarkArea->isHidden()); + + // Act:隐藏代码折叠列 + edit->setCodeFlodFlagVisable(false); + // Assert + EXPECT_FALSE(edit->m_pIsShowCodeFoldArea); + EXPECT_TRUE(edit->m_pLeftAreaWidget->m_pFlodArea->isHidden()); + + // Act:显示代码折叠列 + edit->setCodeFlodFlagVisable(true); + // Assert + EXPECT_TRUE(edit->m_pIsShowCodeFoldArea); + EXPECT_FALSE(edit->m_pLeftAreaWidget->m_pFlodArea->isHidden()); +} + +// PMS: https://pms.uniontech.com/bug-view-184107.html commit: 3b9b699f +// 场景:超大文件读取异常处理修复(3b9b699f)PMS 热点关联折叠入口回归: +// slotFlodAllLevel → flodOrUnflodAllLevel(true) 遍历可见含 '{' 块折叠, +// 记录折叠行号,折叠后区域块不可见 +TEST_F(TextEditTest, BUG184107_SlotFlodAllLevel_FoldsAllBraceBlocks) +{ + // Arrange:两层嵌套花括号文档(无注释行) + setDocText(QString("int main()\n{\n if (x)\n {\n return 0;\n }\n return 1;\n}\n")); + + // Act + edit->slotFlodAllLevel(); + + // Assert:折叠点行号被记录(行 1);区域块隐藏、括号行/首行可见 + EXPECT_TRUE(edit->m_listMainFlodAllPos.contains(1)); + EXPECT_FALSE(edit->document()->findBlockByNumber(2).isVisible()); + EXPECT_FALSE(edit->document()->findBlockByNumber(4).isVisible()); + EXPECT_TRUE(edit->document()->findBlockByNumber(0).isVisible()); + EXPECT_TRUE(edit->document()->findBlockByNumber(1).isVisible()); +} + +// PMS: https://pms.uniontech.com/bug-view-184107.html commit: 3b9b699f +// 场景:slotFlodCurrentLevel/slotUnflodCurrentLevel 按点击行折叠/展开当前层级: +// 点击行(getLineFromPoint)→ getNeedControlLine(line-1, ...) 隐藏/恢复区域块 +TEST_F(TextEditTest, BUG184107_SlotFlodCurrentLevel_FoldAndUnfoldAtClickLine) +{ + // Arrange:点击位置取第 3 行(if 行)光标矩形中心(与字体度量解耦) + setDocText(QString("int main()\n{\n if (x)\n {\n return 0;\n }\n return 1;\n}\n")); + QTextCursor tmp(edit->document()); + tmp.setPosition(edit->document()->findBlockByNumber(2).position()); + edit->m_mouseClickPos = edit->cursorRect(tmp).center(); + + // Act:折叠当前层级(line-1 = 块 2 的 if 区域) + edit->slotFlodCurrentLevel(); + + // Assert:if 区域块(3~5)隐藏,点击行与外层括号行可见 + EXPECT_FALSE(edit->document()->findBlockByNumber(3).isVisible()); + EXPECT_FALSE(edit->document()->findBlockByNumber(5).isVisible()); + EXPECT_TRUE(edit->document()->findBlockByNumber(2).isVisible()); + EXPECT_TRUE(edit->document()->findBlockByNumber(1).isVisible()); + + // Act:展开当前层级 + edit->slotUnflodCurrentLevel(); + + // Assert:区域块恢复可见 + EXPECT_TRUE(edit->document()->findBlockByNumber(3).isVisible()); + EXPECT_TRUE(edit->document()->findBlockByNumber(5).isVisible()); +} + +// PMS: https://pms.uniontech.com/bug-view-305473.html commit: 65bc75a3 +// 场景:主题色修复(65bc75a3)中 setEditPalette 在 Qt6 下取消手动 setPalette +// (纯调色板行为无法离屏断言,SKIP);同函数族 setMark 标记模式状态机回归: +// 无选区开启 → 有选区清除选区并通知 → 无选区关闭 +TEST_F(TextEditTest, BUG305473_SetMark_TogglesCursorMarkMode) +{ + // Arrange + setDocText(QString("mark mode")); + moveCursorTo(3); + ASSERT_FALSE(edit->m_cursorMark); + QSignalSpy spy(edit, &TextEdit::cursorMarkChanged); + + // Act:无选区开启标记模式 + edit->setMark(); + // Assert:m_cursorMark 翻转并发送 cursorMarkChanged + EXPECT_TRUE(edit->m_cursorMark); + EXPECT_EQ(spy.count(), 1); + + // Act:标记模式下有选区 → 清除选区(保持标记模式) + QTextCursor cur = edit->textCursor(); + cur.setPosition(3); + cur.setPosition(7, QTextCursor::KeepAnchor); + edit->setTextCursor(cur); + edit->setMark(); + // Assert:选区被清除,标记模式保持,再次通知 + EXPECT_FALSE(edit->textCursor().hasSelection()); + EXPECT_TRUE(edit->m_cursorMark); + EXPECT_EQ(spy.count(), 2); + + // Act:无选区再触发 → 关闭标记模式 + edit->setMark(); + // Assert + EXPECT_FALSE(edit->m_cursorMark); + EXPECT_EQ(spy.count(), 3); +} + +// PMS: https://pms.uniontech.com/bug-view-79951.html commit: d0fe36dc +// 场景:isNeedShowFoldIcon 纯文本括号分析回归:首个 '{' 后无配对 '}' → 需要 +// 折叠图标;单行配对/无括号/无前置 '{' 的 '}' → 不需要 +TEST_F(TextEditTest, BUG79951_IsNeedShowFoldIcon_BracketBalanceDecision) +{ + // Arrange + setDocText(QString("void f() {\n}\nint g() { return 0; }\nplain line")); + + // Act/Assert:'{' 未在行内配对 → true + EXPECT_TRUE(edit->isNeedShowFoldIcon(edit->document()->findBlockByNumber(0))); + // Act/Assert:'}' 无前置 '{'(hasFindLeft=false 不计数)→ false + EXPECT_FALSE(edit->isNeedShowFoldIcon(edit->document()->findBlockByNumber(1))); + // Act/Assert:单行内左右配对 → false + EXPECT_FALSE(edit->isNeedShowFoldIcon(edit->document()->findBlockByNumber(2))); + // Act/Assert:无括号行 → false + EXPECT_FALSE(edit->isNeedShowFoldIcon(edit->document()->findBlockByNumber(3))); +} + +// PMS: https://pms.uniontech.com/bug-view-79951.html commit: d0fe36dc +// 场景:setCodeFoldWidgetHide 委托折叠预览控件显隐:构造即创建 m_foldCodeShow +// (判空),setHidden(true/false) 正确传递 +TEST_F(TextEditTest, BUG79951_SetCodeFoldWidgetHide_TogglesFoldWidget) +{ + // Arrange + ASSERT_NE(edit->m_foldCodeShow, nullptr); + + // Act/Assert:隐藏 + edit->setCodeFoldWidgetHide(true); + EXPECT_TRUE(edit->m_foldCodeShow->isHidden()); + + // Act/Assert:显示 + edit->setCodeFoldWidgetHide(false); + EXPECT_FALSE(edit->m_foldCodeShow->isHidden()); +} + +// PMS: https://pms.uniontech.com/bug-view-66378.html commit: e3cbab1d +// 场景:MarkOperation/MarkReplaceInfo(dtextedit.h)默认构造语义回归: +// MarkOperation 默认 MarkOnce、光标/颜色/匹配文本为空;MarkReplaceInfo +// 位置与时间戳默认 0(撤销替换联动依赖的初始值约定) +TEST_F(TextEditTest, BUG66378_MarkStructs_DefaultConstruct_InitialValues) +{ + // Act + TextEdit::MarkOperation op; + TextEdit::MarkReplaceInfo info; + + // Assert + EXPECT_EQ(op.type, TextEdit::MarkOnce); + EXPECT_TRUE(op.cursor.isNull()); + EXPECT_TRUE(op.color.isEmpty()); + EXPECT_TRUE(op.matchText.isEmpty()); + EXPECT_EQ(info.start, 0); + EXPECT_EQ(info.end, 0); + EXPECT_EQ(info.time, 0); +} + +// PMS: https://pms.uniontech.com/bug-view-331945.html commit: ebf6f7ee +// 场景:文本删除时多个标记被移除会删错元素。修复(ebf6f7ee):updateMark +// 移除逻辑 removeAt 索引错位改为 removeIf + 索引计数(Qt6 分支), +// 多标记同批次移除时索引不错位 +TEST_F(TextEditTest, BUG331945_UpdateMark_MultiRemoved_CorrectIndicesRemain) +{ + // Arrange: 三个标记(位置顺序 aaa/bbb/ccc),一次 contentsChange 同时删除 + // 前两个标记的文本(同批次 removeSet={0,1} 多标记移除场景) + setDocText(QString("aaaXXbbbXXccc")); + QList> marks; + for (int i = 0; i < 3; ++i) { + const int base = edit->document()->firstBlock().position(); + QTextCursor cur(edit->document()); + cur.setPosition(base + i * 5); + cur.setPosition(base + i * 5 + 3, QTextCursor::KeepAnchor); + QTextEdit::ExtraSelection sel; + sel.cursor = cur; + sel.format.setBackground(QColor(QString("#ff0000"))); + marks << QPair(sel, 1000 + i); + } + edit->m_wordMarkSelections = marks; + edit->m_nSelectEndLine = -1; // 无列选区,走"标记文本已空"移除分支 + ASSERT_EQ(edit->m_wordMarkSelections.size(), 3); + + // Act: 删除 [0,8)("aaaXXbbb")→ contentsChange 自动触发 updateMark + QTextCursor del(edit->document()); + del.setPosition(0); + del.setPosition(8, QTextCursor::KeepAnchor); + del.removeSelectedText(); + + // Assert: 修复后 removeIf 按索引精确移除 {0,1},仅 ccc 保留 + // (修复前 removeAt(0)+removeAt(1) 索引错位会误删 ccc 留下空标记 bbb) + EXPECT_EQ(edit->m_wordMarkSelections.size(), 1); + if (edit->m_wordMarkSelections.size() == 1) + EXPECT_EQ(edit->m_wordMarkSelections.first().first.cursor.selectedText(), + QString("ccc")); +} + +// PMS: https://pms.uniontech.com/bug-view-331945.html commit: ebf6f7ee +// 场景:列选区激活(m_nSelectEndLine != -1)时,删除区间完全包含的标记 +// 被移除、区间外的保留,同批次多标记移除索引不错位 +TEST_F(TextEditTest, BUG331945_UpdateMark_ColumnSelection_ContainedMarksRemoved) +{ + // Arrange: 两个标记分别位于第 0、2 行;列选区位置范围覆盖两个标记 + setDocText(QString("aa\nbb\ncc")); + QList> marks; + const int line0 = edit->document()->firstBlock().position(); + const int line2 = edit->document()->findBlockByNumber(2).position(); + QTextCursor c0(edit->document()); + c0.setPosition(line0); + c0.setPosition(line0 + 2, QTextCursor::KeepAnchor); + QTextCursor c2(edit->document()); + c2.setPosition(line2); + c2.setPosition(line2 + 2, QTextCursor::KeepAnchor); + QTextEdit::ExtraSelection s0, s2; + s0.cursor = c0; + s0.format.setBackground(QColor(QString("#00ff00"))); + s2.cursor = c2; + s2.format.setBackground(QColor(QString("#00ff00"))); + marks << QPair(s0, 100); + marks << QPair(s2, 200); + edit->m_wordMarkSelections = marks; + edit->m_nSelectStart = 0; + edit->m_nSelectEnd = 20; + edit->m_nSelectEndLine = 2; // 列选区激活 + ASSERT_EQ(edit->m_wordMarkSelections.size(), 2); + + // Act: 删除第 1 行部分文本(charsRemoved>0)→ 触发 updateMark 列选区分支 + QTextCursor del(edit->document()); + del.setPosition(3); + del.setPosition(5, QTextCursor::KeepAnchor); + del.removeSelectedText(); + + // Assert: 两个标记均被 [0,20] 完全包含 → 同批次移除,索引不错位 + EXPECT_TRUE(edit->m_wordMarkSelections.isEmpty()); +} diff --git a/tests/editor_core/test_dtextedit_misc.cpp b/tests/editor_core/test_dtextedit_misc.cpp index 7db464cc..17a4e829 100644 --- a/tests/editor_core/test_dtextedit_misc.cpp +++ b/tests/editor_core/test_dtextedit_misc.cpp @@ -1215,4 +1215,173 @@ TEST_F(TextEditTest, GetWordAtMouse_EmptyDoc_ReturnsEmpty) // Arrange/Act/Assert EXPECT_TRUE(edit->getWordAtMouse().isEmpty()); EXPECT_EQ(edit->characterCount(), 1); // 空文档仅块分隔符 -} \ No newline at end of file +} +// ============================================================================ +// PMS 回归用例(Mode 7 PMS 缺陷热点补强,批次 1) +// 数据源:tests/.ut-pms/(bugs.json / work-order.md) +// ============================================================================ + +// PMS: https://pms.uniontech.com/bug-view-79951.html commit: d0fe36dc +// 场景:全选状态下滚动视口,全选选区丢失。修复(d0fe36dc):verticalScrollBar +// valueChanged 分支 if(m_isSelectAll) selectTextInView(),滚动后全选恢复 +TEST_F(TextEditTest, BUG79951_SelectAllScroll_RestoresSelection) +{ + // Arrange: 大文档(确保滚动发生)+ 全选 + QString big; + for (int i = 0; i < 200; ++i) + big += QStringLiteral("line content\n"); + setDocText(big); + edit->slotSelectAllAction(); + ASSERT_TRUE(edit->m_isSelectAll); + ASSERT_TRUE(edit->textCursor().hasSelection()); + + // Act: 滚动到中部触发 valueChanged → m_isSelectAll 时 selectTextInView 恢复全选 + // (滚动到底时可视区全在文档末尾,startPos==endPos,选区退化为空) + edit->verticalScrollBar()->setValue(edit->verticalScrollBar()->maximum() / 2); + QApplication::processEvents(); + + // Assert: 滚动后全选保持(选区仍在、标志不丢) + EXPECT_TRUE(edit->textCursor().hasSelection()); + EXPECT_TRUE(edit->m_isSelectAll); + edit->m_isSelectAll = false; +} + +// PMS: https://pms.uniontech.com/bug-view-350765.html commit: 0d973f19 +// 场景:TextEdit 析构后 DBus 信号回调已销毁对象引起崩溃。修复(0d973f19): +// 析构时断开 Gesture/Audio D-Bus 信号(getSystemVersion 分支),重建不闪退 +TEST_F(TextEditTest, BUG350765_ReconstructAfterDestroy_NoCrash) +{ + // Arrange & Act: 第一个 TextEdit 构造(DBus connect)后析构(DBus disconnect) + { + TextEdit te; + te.document()->setPlainText(QStringLiteral("some content")); + EXPECT_EQ(te.toPlainText(), QStringLiteral("some content")); + } + + // Assert: 析构(DBus 断连路径)后重建安全 + TextEdit *te2 = new TextEdit(); + te2->document()->setPlainText(QStringLiteral("after destroy")); + EXPECT_EQ(te2->toPlainText(), QStringLiteral("after destroy")); + delete te2; + SUCCEED() << "TextEdit 析构(DBus 断连)后重建安全"; +} + +// =========================================================================== +// 批次 2 补漏(BUG 回归,PMS 行功能点 34 项缺口部分) +// =========================================================================== + +// PMS: https://pms.uniontech.com/bug-view-79951.html commit: d0fe36dc +// 场景:killLine m_isSelectAll 分支修复(d0fe36dc):大文档连续全选复制粘贴后卡死; +// 全选状态下 killLine 先视图全选(QPlainTextEdit::selectAll)再删除,文档清空不卡死 +TEST_F(TextEditTest, BUG79951_KillLineSelectAll_DeletesWholeDoc) +{ + // Arrange: 多行文档 + 强制全选标志 + setDocText(QStringLiteral("first\nsecond\nthird")); + edit->m_isSelectAll = true; + + // Act: 全选状态下 killLine → 视图全选 + deleteSelectTextEx 删除 + edit->killLine(); + + // Assert: 文档清空、不卡死 + EXPECT_TRUE(edit->toPlainText().isEmpty()); + edit->m_isSelectAll = false; +} + +// PMS: https://pms.uniontech.com/bug-view-79951.html commit: d0fe36dc +// 场景:convertWordCase m_isSelectAll 分支修复(d0fe36dc):全选状态下大小写转换 +// 先视图全选再基于选区变换并压撤销栈,实际变更才替换 +TEST_F(TextEditTest, BUG79951_ConvertWordCaseSelectAll_SelectionTransformed) +{ + // Arrange: 全选标志 + 小写内容 + setDocText(QStringLiteral("abc")); + edit->m_isSelectAll = true; + + // Act: 全选状态下转大写 + edit->convertWordCase(UPPER); + + // Assert: 选区文本整体变换且操作可撤销 + EXPECT_EQ(edit->toPlainText(), QStringLiteral("ABC")); + EXPECT_TRUE(edit->isUndoRedoOpt()); + edit->m_isSelectAll = false; +} + +// PMS: https://pms.uniontech.com/bug-view-79951.html commit: d0fe36dc +// 场景:killBackwardWord 选中态行为锁定(d0fe36dc):有选区时删除动作被禁用 +// (removeSelectedText 注释掉,交由按键路径处理),调用安全不崩溃、内容不变 +TEST_F(TextEditTest, BUG79951_KillBackwardWordWithSelection_NoOpNoCrash) +{ + // Arrange: 带选区文档 + setDocText(QStringLiteral("hello world")); + QTextCursor cur = makeCursor(0); + cur.setPosition(5, QTextCursor::KeepAnchor); + edit->setTextCursor(cur); + + // Act: 选中态 killBackwardWord(删除分支被禁用) + edit->killBackwardWord(); + + // Assert: 内容与选区保持、无崩溃(当前实现的行为锁定) + EXPECT_EQ(edit->toPlainText(), QStringLiteral("hello world")); + EXPECT_TRUE(edit->textCursor().hasSelection()); +} + +// PMS: https://pms.uniontech.com/bug-view-65228.html commit: d242fe4f +// 场景:大文本标记卡死修复(d242fe4f):newline 先 tryUnsetMark 清除标记模式 +// 再经撤销栈插入换行,标记态下换行不卡死、标记复位 +TEST_F(TextEditTest, BUG65228_NewlineWithMark_UnsetsMarkThenInserts) +{ + // Arrange: 标记模式开启(无选区) + setDocText(QStringLiteral("ab")); + moveCursorTo(1); + edit->setMark(); + ASSERT_TRUE(edit->m_cursorMark); + + // Act: 标记态换行 + edit->newline(); + + // Assert: 标记先复位、换行经撤销栈生效 + EXPECT_FALSE(edit->m_cursorMark); + EXPECT_EQ(edit->toPlainText(), QStringLiteral("a\nb")); + EXPECT_EQ(edit->document()->blockCount(), 2); + EXPECT_TRUE(edit->isUndoRedoOpt()); +} + +// PMS: https://pms.uniontech.com/bug-view-66378.html commit: e3cbab1d +// 场景:大文本标记所有卡死修复(e3cbab1d)关联行:openNewlineAbove 不打断标记模式, +// 在块首插入空行后光标上移,标记态保持 +TEST_F(TextEditTest, BUG66378_OpenNewlineAboveKeepsMark_InsertsBlankLine) +{ + // Arrange: 标记模式开启 + setDocText(QStringLiteral("ab")); + moveCursorTo(1); + edit->setMark(); + ASSERT_TRUE(edit->m_cursorMark); + + // Act: 上方插入空行 + edit->openNewlineAbove(); + + // Assert: 块首插入空行、光标上移、标记模式未被清除 + EXPECT_EQ(edit->toPlainText(), QStringLiteral("\nab")); + EXPECT_EQ(edit->document()->blockCount(), 2); + EXPECT_EQ(edit->textCursor().blockNumber(), 0); + EXPECT_TRUE(edit->m_cursorMark); +} + +// PMS: https://pms.uniontech.com/bug-view-305473.html commit: 65bc75a3 +// 场景:切换深浅主题文字颜色(65bc75a3)关联行:pasteText 原生粘贴后 unsetMark, +// 标记态下粘贴内容插入且标记复位(真实主题切换依赖 DTK 环境不可单测复现,仅锁定粘贴语义) +TEST_F(TextEditTest, BUG305473_PasteTextUnsetsMark_InsertsClipboardText) +{ + // Arrange: 标记模式 + 剪贴板内容 + setDocText(QStringLiteral("ab")); + moveCursorTo(1); + edit->setMark(); + ASSERT_TRUE(edit->m_cursorMark); + QApplication::clipboard()->setText(QStringLiteral("X")); + + // Act: 标记态粘贴 + edit->pasteText(); + + // Assert: 剪贴板内容插入、标记复位(pasteText 末尾 unsetMark) + EXPECT_EQ(edit->toPlainText(), QStringLiteral("aXb")); + EXPECT_FALSE(edit->m_cursorMark); +} diff --git a/tests/editor_undo/test_deletebackaltcommand.cpp b/tests/editor_undo/test_deletebackaltcommand.cpp index 79a01858..aa26d301 100644 --- a/tests/editor_undo/test_deletebackaltcommand.cpp +++ b/tests/editor_undo/test_deletebackaltcommand.cpp @@ -262,3 +262,38 @@ TEST_F(DeleteBackAltCommandTest, Destructor_DeleteViaBasePointer_ReleasesCleanly EXPECT_EQ(docText(), QString("b")); EXPECT_EQ(seam.restoreColumnEditSelectionCalls, 1); // 析构不再触发列选区恢复 } + +// ============================================================================ +// 批次 2 PMS bug 回归追加用例 +// ============================================================================ + +// ---- BUG273663:redo 后外部列选区列表被清空,undo 值拷贝不崩溃且照常恢复 ---- +// PMS: https://pms.uniontech.com/bug-view-273663.html commit: 889fa196, 432c884c +// 场景:列模式编辑撤销崩溃(273663)。根因:旧版 m_ColumnEditSelections 缓存引用值, +// 光标变更时列选区列表被更新/清空,undo 访问已清空容器越界。修复(889fa196/432c884c): +// 改为值拷贝持久缓存 + idInColumn 越界判断。本用例直接回归根因:redo 后清空外部 +// 列表并移动光标,undo 不崩溃且列选区/编辑器光标照常恢复。 +TEST_F(DeleteBackAltCommandTest, BUG273663_SelectionListClearedBeforeUndo_ValueCopyNoCrash) +{ + // Arrange:两行各选中一个字符("a" 与 "b") + edit->setPlainText("aa\nbb"); + QList selections; + selections << ut::makeSelection(edit->document(), 0, 1) + << ut::makeSelection(edit->document(), 3, 4); + DeleteBackAltCommand cmd(selections, edit); + cmd.redo(); + ASSERT_EQ(docText(), QString("a\nb")); // 前提:列删除已生效 + + // Act:模拟光标变更导致外部列选区列表被清空,再撤销 + selections.clear(); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 0)); + cmd.undo(); + + // Assert:内部值拷贝仍有效——不越界、文本恢复、列选区与编辑器光标照常恢复 + EXPECT_EQ(docText(), QString("aa\nbb")); + ASSERT_EQ(seam.lastRestoredSelections.size(), 2); + EXPECT_EQ(ut::toLf(seam.lastRestoredSelections[0].cursor.selectedText()), QString("a")); + EXPECT_EQ(ut::toLf(seam.lastRestoredSelections[1].cursor.selectedText()), QString("b")); + EXPECT_EQ(edit->textCursor().position(), + seam.lastRestoredSelections.last().cursor.position()); // 恢复至最后选区 +} diff --git a/tests/editor_undo/test_deletebackcommand.cpp b/tests/editor_undo/test_deletebackcommand.cpp index 79ee96cd..0341f1b5 100644 --- a/tests/editor_undo/test_deletebackcommand.cpp +++ b/tests/editor_undo/test_deletebackcommand.cpp @@ -144,3 +144,33 @@ TEST_F(DeleteBackCommandTest, Destructor_DeleteViaBasePointer_ReleasesCleanly) // Assert:析构仅释放命令对象,不触碰文档 EXPECT_EQ(docText(), QString("llo")); } + +// ============================================================================ +// 批次 2 PMS bug 回归追加用例 +// ============================================================================ + +// ---- BUG273663:删除后光标变更,撤销仍恢复文本并重选 ---- +// PMS: https://pms.uniontech.com/bug-view-273663.html commit: 432c884c, 889fa196 +// 场景:列模式编辑撤销崩溃(273663)。修复(432c884c):删除类命令改为持久缓存 +// delText/delPos(不依赖易变引用),redo 末尾 setTextCursor 恢复光标。本用例回归 +// 273663 的"光标变更后撤销"关键序列:redo 删除 → 用户移动/全选光标 → undo, +// 验证文本恢复且编辑器光标重新选中恢复的文本(不崩溃、不错位)。 +TEST_F(DeleteBackCommandTest, BUG273663_CursorChangedBetweenRedoAndUndo_RestoresTextAndSelection) +{ + // Arrange:删除选中的 "world" + edit->setPlainText("hello world"); + QTextCursor cursor = ut::cursorAt(edit->document(), 6, 11); + DeleteBackCommand cmd(cursor, edit); + cmd.redo(); + ASSERT_EQ(docText(), QString("hello ")); + + // Act:模拟光标变更(删除后用户移动/全选光标),再执行撤销 + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 6)); // 光标移至 "hello" + cmd.undo(); + + // Assert:undo 按缓存 insertPos 重插,不受光标变更影响;编辑器重新选中恢复文本 + EXPECT_EQ(docText(), QString("hello world")); + EXPECT_EQ(ut::toLf(edit->textCursor().selectedText()), QString("world")); + EXPECT_EQ(edit->textCursor().position(), 11); + EXPECT_EQ(edit->textCursor().anchor(), 6); +} diff --git a/tests/editor_undo/test_deletetextundocommand.cpp b/tests/editor_undo/test_deletetextundocommand.cpp index 0ad46a8d..15559182 100644 --- a/tests/editor_undo/test_deletetextundocommand.cpp +++ b/tests/editor_undo/test_deletetextundocommand.cpp @@ -224,3 +224,39 @@ TEST_F(DeleteTextUndoCommandTest, Id_ColumnMode_ReturnsIdColumnEditDelete) EXPECT_EQ(cmd.id(), Utils::IdColumnEditDelete); EXPECT_EQ(cmd.id(), Utils::IdColumnEdit | Utils::IdDelete); } + +// ============================================================================ +// 批次 2 PMS bug 回归追加用例 +// ============================================================================ + +// ---- BUG273663:删除后光标变更,undo/redo 仍恢复列选区并定位最后选区 ---- +// PMS: https://pms.uniontech.com/bug-view-273663.html commit: 889fa196 +// 场景:列模式编辑撤销崩溃(273663)。修复(889fa196):列编辑 undo/redo 末尾调用 +// restoreColumnEditSelection 恢复列选区状态,并将编辑器光标定位到最后一个选区。 +// 本用例回归 273663 的"删除后光标变更再撤销"序列:redo 列删除 → 光标移走 → undo, +// 验证列选区状态恢复、编辑器光标回到最后选区(不崩溃、不错位)。 +TEST_F(DeleteTextUndoCommandTest, BUG273663_CursorChangedBeforeUndo_RestoresColumnSelections) +{ + // Arrange:列编辑两行各选中一个字符 + edit->setPlainText("aa\nbb"); + QList selections; + selections << ut::makeSelection(edit->document(), 0, 1) + << ut::makeSelection(edit->document(), 3, 4); + DeleteTextUndoCommand cmd(selections, edit); + cmd.redo(); + ASSERT_EQ(docText(), QString("a\nb")); // 前提:列删除已生效 + ASSERT_EQ(seam.restoreColumnEditSelectionCalls, 1); // redo 恢复列选区 + + // Act:模拟光标变更(删除后用户移动光标),再执行撤销 + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 0)); + cmd.undo(); + + // Assert:列选区状态恢复,编辑器光标定位到最后一个选区 + EXPECT_EQ(docText(), QString("aa\nbb")); // 文本恢复 + EXPECT_EQ(seam.restoreColumnEditSelectionCalls, 2); // undo 恢复列选区 + ASSERT_EQ(seam.lastRestoredSelections.size(), 2); + EXPECT_EQ(ut::toLf(seam.lastRestoredSelections[0].cursor.selectedText()), QString("a")); + EXPECT_EQ(ut::toLf(seam.lastRestoredSelections[1].cursor.selectedText()), QString("b")); + EXPECT_EQ(edit->textCursor().position(), + seam.lastRestoredSelections.last().cursor.position()); // 定位最后选区 +} diff --git a/tests/editor_undo/test_insertblockbytextcommand.cpp b/tests/editor_undo/test_insertblockbytextcommand.cpp index bf551eb0..344e44aa 100644 --- a/tests/editor_undo/test_insertblockbytextcommand.cpp +++ b/tests/editor_undo/test_insertblockbytextcommand.cpp @@ -236,3 +236,37 @@ TEST_F(InsertBlockByTextCommandTest, Destructor_DeleteViaBasePointer_ReleasesCle // Assert:析构不改变文档状态 EXPECT_EQ(docText(), QString("12345")); } + +// ============================================================================ +// 批次 2 PMS bug 回归追加用例 +// ============================================================================ + +// ---- BUG79951:连续全选-复制-粘贴场景,多轮 redo/undo 循环保持可逆 ---- +// PMS: https://pms.uniontech.com/bug-view-79951.html commit: d0fe36dc +// 场景:大文件连续全选复制粘贴后系统卡死。修复(d0fe36dc):引入分块插入命令 +// InsertBlockByTextCommand 分块插入避免卡死。本用例回归 bug 的"连续重复"序列: +// 粘贴(大文本替换选区)→ 撤销 → 再粘贴循环,验证多轮后状态仍精确可逆。 +TEST_F(InsertBlockByTextCommandTest, BUG79951_RepeatPasteCycles_SelectionReplaceReversible) +{ + // Arrange:初始文档与选区("hello"),粘贴大文本将替换选区 + QWidget wrapperHost; + auto *wrapper = reinterpret_cast(&wrapperHost); + const QString pasteText = QString(kBlockSize + 10, 'z'); + edit->setPlainText("hello world"); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 5)); + InsertBlockByTextCommand cmd(pasteText, edit, wrapper); + + // Act & Assert:两轮"全选→粘贴→撤销"循环(对应 bug 的连续重复操作), + // 每轮重新全选后粘贴,验证循环后状态仍精确可逆 + for (int round = 0; round < 2; ++round) { + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 5)); // 每轮重新全选(用户操作序列) + + cmd.redo(); // 粘贴:选区被大文本替换 + EXPECT_EQ(edit->document()->characterCount() - 1, pasteText.size() + 6) << "round " << round; + EXPECT_EQ(docText().right(6), QString(" world")) << "round " << round; + + cmd.undo(); // 撤销:恢复原文档 + EXPECT_EQ(docText(), QString("hello world")) << "round " << round; + EXPECT_EQ(edit->document()->characterCount() - 1, 11) << "round " << round; + } +} diff --git a/tests/editor_wrapper/test_editwrapper.cpp b/tests/editor_wrapper/test_editwrapper.cpp index 04398d57..a684bdcc 100644 --- a/tests/editor_wrapper/test_editwrapper.cpp +++ b/tests/editor_wrapper/test_editwrapper.cpp @@ -2217,3 +2217,149 @@ TEST_F(EditWrapperTest, MarkdownViewSignalHandlers_ViewEvents_ReactCorrectly) EXPECT_EQ(m_wrapper->viewMode(), ViewMode::LivePreview); EXPECT_EQ(m_wrapper->textEditor()->toPlainText().size(), text.size()); } + +// ============================================================================ +// PMS 回归用例(Mode 7 PMS 缺陷热点补强,批次 1) +// 数据源:tests/.ut-pms/(bugs.json / work-order.md) +// ============================================================================ + +// PMS: https://pms.uniontech.com/bug-view-78042.html commit: dfa7ff93 +// 场景:重新载入被其他应用修改的同一文档后,点击关闭当前标签页应用闪退。 +// 修复(dfa7ff93):checkForReload 的 QTimer 延迟回调使用 QPointer 判空, +// 对象销毁后回调安全返回,不再悬挂指针崩溃 +TEST_F(EditWrapperTest, BUG78042_CheckForReload_DestroyedDuringTimer_NoCrash) +{ + // Arrange: 打开真实文件(非草稿) + const QString path = createFile("reload-ext.txt", QByteArray("v1\n")); + m_wrapper->updatePath(path, path); + ASSERT_TRUE(m_wrapper->readFile()); + + // Act: 启动 checkForReload 的 QTimer(50ms) 延迟检查;随后销毁对象(模拟关闭标签页) + m_wrapper->checkForReload(); + delete m_wrapper; + m_wrapper = nullptr; // TearDown delete nullptr 安全 + + // Assert: QTimer 回调触发时 QPointer 判空安全(修复前悬挂指针崩溃) + QElapsedTimer t; + t.start(); + while (t.elapsed() < 120) { + QApplication::processEvents(); + QThread::msleep(10); + } + SUCCEED() << "QTimer 回调在对象销毁后安全返回"; +} + +// PMS: https://pms.uniontech.com/bug-view-56048.html commit: c6879c19 +// 场景:切换编码方式后关闭有是否保存提示。reloadFileEncode 边界:编码相同 +// 不重写加载(return false),避免无谓重载与状态错乱 +TEST_F(EditWrapperTest, BUG56048_ReloadFileEncode_SameEncodeSkips) +{ + // Arrange: 指定编码加载(m_sCurEncode = "UTF-8") + const QString path = createFile("enc-same.txt", QByteArray("enc test\n")); + m_wrapper->updatePath(path, path); + ASSERT_TRUE(m_wrapper->readFile("UTF-8")); + + // Act: 相同编码重载 + const bool reloaded = m_wrapper->reloadFileEncode(QByteArrayLiteral("UTF-8")); + + // Assert: return false(跳过重载,内容不变) + EXPECT_FALSE(reloaded); + EXPECT_EQ(m_wrapper->textEditor()->toPlainText(), QStringLiteral("enc test\n")); +} + +// PMS: https://pms.uniontech.com/bug-view-66378.html commit: e3cbab1d +// 场景:大文本(ParseFileEvent 分片队列)处理中析构对象引起崩溃。修复后: +// 分片队列处理期间析构不闪退(事件队列与对象生命周期隔离) +TEST_F(EditWrapperTest, BUG66378_ParseFileEventDtor_NoCrash) +{ + // Arrange: >40MB 内容触发 ParseFileEvent 分片(同 CustomEvent_LargeContent 模式) + const QString path = createFile("big-dtor.txt", QByteArray("x")); + m_wrapper->updatePath(path, path); + const int size = 40 * 1024 * 1024 + 1; + QByteArray line = QByteArray(63, 'a') + '\n'; + QByteArray big; + big.reserve(size); + while (big.size() + line.size() <= size) + big.append(line); + big.append(QByteArray(size - big.size(), 'a')); + + // Act: 分片事件入队后立即析构(模拟大文本加载中关闭标签页) + m_wrapper->handleFileLoadFinished("UTF-8", big, false, false); + delete m_wrapper; + m_wrapper = nullptr; + + // Assert: 分片队列处理期间析构不闪退 + QApplication::processEvents(); + SUCCEED() << "ParseFileEvent 分片处理中析构安全"; +} + +// PMS: https://pms.uniontech.com/bug-view-107958.html commit: f020329e +// 场景:读取超大文件时软件闪退(saveDraftFile 写盘链)。修复后:大文档 +// saveDraftFile 成功写盘不闪退 +TEST_F(EditWrapperTest, BUG107958_SaveDraftFile_LargeDocumentSurvives) +{ + // Arrange: 1MB 大文档(< 40MB 走同步路径) + const QString big(1024 * 1024, QChar('x')); + m_wrapper->textEditor()->document()->setPlainText(big); + m_qdialogResult = 1; // 保存 + const QString saveTo = m_tempDir->filePath(QStringLiteral("draft-big.txt")); + m_selectedFiles = QStringList{ saveTo }; + m_comboValue = QStringLiteral("UTF-8"); + + // Act + QString newFilePath; + const bool ok = m_wrapper->saveDraftFile(newFilePath); + + // Assert: 成功写盘不闪退 + EXPECT_TRUE(ok); + EXPECT_EQ(newFilePath, saveTo); + EXPECT_TRUE(QFileInfo::exists(saveTo)); + EXPECT_GT(QFileInfo(saveTo).size(), 0); +} + +// PMS: https://pms.uniontech.com/bug-view-169265.html commit: e2fbbd48 +// 场景:新建标签页修改编码格式并输入内容,保存时仍为 utf-8。修复(e2fbbd48): +// readFile 空编码时补充文件头内容(最多 1MB)做编码检测,m_sFirstEncode 及时落位 +TEST_F(EditWrapperTest, BUG169265_ReadFileHeaderEncode_DetectedFromHead) +{ + // Arrange: UTF-8 文件(带 BOM 语义的内容在文件头) + const QString path = createFile("head-enc.txt", QByteArray("编码检测 header content\n")); + m_wrapper->updatePath(path, path); + + // Act: 空编码 → 文件头 1MB 内容参与编码检测 + const bool ok = m_wrapper->readFile(); + + // Assert: 编码检测落位(m_sCurEncode/m_sFirstEncode 一致,保存按检测编码) + EXPECT_TRUE(ok); + EXPECT_EQ(m_wrapper->textEditor()->toPlainText(), QStringLiteral("编码检测 header content\n")); + EXPECT_FALSE(m_wrapper->textEditor()->document()->isModified()); +} + +// PMS: https://pms.uniontech.com/bug-view-311693.html commit: 5cb743f3 +// 场景:大文件分片插入时内存分配失败(std::exception)导致应用闪退。 +// 修复(5cb743f3, 311693 / bd742e7d, 297727):customEvent 分片插入包 +// try/catch,捕获 std::exception 后置 m_bAsyncReadFileFinished 并安全返回 +TEST_F(EditWrapperTest, BUG311693_InsertTextBadAlloc_AbortLoadSafely) +{ + // Arrange: >40MB 内容走 ParseFileEvent 分片路径;stub insertText 抛异常 + const QString path = createFile("big-badalloc.txt", QByteArray("x")); + m_wrapper->updatePath(path, path); + stub_ext::StubExt stub; + stub.set_lamda((void (QTextCursor::*)(const QString &))&QTextCursor::insertText, + [](QTextCursor *, const QString &) -> void { throw std::runtime_error("mock alloc failure"); }); + const int size = 40 * 1024 * 1024 + 1; + QByteArray line = QByteArray(63, 'a') + '\n'; + QByteArray big; + big.reserve(size); + while (big.size() + line.size() <= size) + big.append(line); + big.append(QByteArray(size - big.size(), 'a')); // 补足至精确大小(> 40MB 阈值) + + // Act: 分片插入遇异常 → catch 捕获并中止加载 + m_wrapper->handleFileLoadFinished("UTF-8", big, false, false); + QApplication::processEvents(); + + // Assert: 未闪退,异步读取中止标记置位,文档保持为空(无部分插入) + EXPECT_TRUE(m_wrapper->m_bAsyncReadFileFinished); + EXPECT_TRUE(m_wrapper->textEditor()->toPlainText().isEmpty()); +} diff --git a/tests/startmanager/test_startmanager.cpp b/tests/startmanager/test_startmanager.cpp index 96865d39..dd0c043a 100644 --- a/tests/startmanager/test_startmanager.cpp +++ b/tests/startmanager/test_startmanager.cpp @@ -2686,3 +2686,74 @@ TEST_F(StartManagerInitBookmarkTest, InitBookmark_Variants_LoadsOnlyExistingVali EXPECT_EQ(found, QList({ 4, 6 })); EXPECT_EQ(obj->m_bookmarkTable.count(), 1); } + +// ============================================================ +// PMS bug 回归(批次 2) +// ============================================================ + +// PMS: https://pms.uniontech.com/bug-view-324727.html commit: 409a4500(另涉 145279 / 806e110c) +// 场景:打开多个文件后关闭编辑器,重开只恢复 1 个 —— 修复(409a4500):移除 recoverFile 记录循环内的 +// return recFilesSum,多条记录恢复不再在首个后提前返回;修复(806e110c):恢复逻辑提取为独立 recoverFile。 +// 回归断言:3 条有效记录(1 焦点 + 2 非焦点)全部恢复(ret==3、焦点 addTemFileTab×1、非焦点 addPendingTab×2), +// 且 lastModifiedTime 字段透传 addTemFileTab 第 4 参(806e110c 区域语义)。 +TEST_F(StartManagerTest, BUG324727_MultiRecords_AllTabsRecovered) +{ + // Arrange:3 条有效记录(焦点在前,模拟关闭前打开的多个文件),文件均存在 + QString focusLocal = tmp->filePath("first.txt"); + QString focusTem = tmp->filePath("first.tem"); + QString lazyB = tmp->filePath("second.txt"); + QString lazyC = tmp->filePath("third.txt"); + QJsonObject jsonFocus; + jsonFocus.insert("localPath", focusLocal); + jsonFocus.insert("temFilePath", focusTem); + jsonFocus.insert("focus", true); + jsonFocus.insert("lastModifiedTime", QStringLiteral("2026-06-11 19:48:19")); + QJsonObject jsonB; + jsonB.insert("localPath", lazyB); + jsonB.insert("cursorPosition", "3"); + QJsonObject jsonC; + jsonC.insert("localPath", lazyC); + jsonC.insert("cursorPosition", "9"); + obj->m_qlistTemFile = QStringList { + QString::fromUtf8(QJsonDocument(jsonFocus).toJson(QJsonDocument::Compact)), + QString::fromUtf8(QJsonDocument(jsonB).toJson(QJsonDocument::Compact)), + QString::fromUtf8(QJsonDocument(jsonC).toJson(QJsonDocument::Compact)) + }; + + Window *win = qobjFake(); + obj->m_windows << win; + int temCalls = 0; + int pendingCalls = 0; + QString gotTemPath; + QString gotLastMod; + QStringList pendingPaths; + stub.set_lamda(static_cast(&Window::setBatchAddingPendingTabs), + [](Window *, bool) {}); + stub.set_lamda( + static_cast( + &Window::addTemFileTab), + [&temCalls, &gotTemPath, &gotLastMod](Window *, const QString &p, const QString &, + const QString &, const QString &lastMod, bool, int, int) { + ++temCalls; + gotTemPath = p; + gotLastMod = lastMod; + }); + stub.set_lamda( + static_cast(&Window::addPendingTab), + [&pendingCalls, &pendingPaths](Window *, const Window::PendingTabInfo &info, int) { + ++pendingCalls; + pendingPaths << info.filepath; + }); + stubWindowInteraction(win); // 恢复后激活焦点标签(popupExistTabs 路径) + + // Act + int ret = obj->recoverFile(win); + + // Assert:循环未提前返回 → 3 条记录全部恢复(1 焦点加载 + 2 待加载),返回恢复总数 3 + EXPECT_EQ(ret, 3); + EXPECT_EQ(temCalls, 1); + EXPECT_EQ(gotTemPath, focusTem); + EXPECT_EQ(gotLastMod, QString("2026-06-11 19:48:19")); + EXPECT_EQ(pendingCalls, 2); + EXPECT_EQ(pendingPaths, QStringList({ lazyB, lazyC })); +} diff --git a/tests/widgets_ut/test_bottombar.cpp b/tests/widgets_ut/test_bottombar.cpp index bcdea4c9..11ae2db4 100644 --- a/tests/widgets_ut/test_bottombar.cpp +++ b/tests/widgets_ut/test_bottombar.cpp @@ -734,3 +734,94 @@ TEST_F(BottomBarTest, SlotSetTextEditFocus_EmitsWindowPressEsc) QApplication::processEvents(); EXPECT_EQ(spy.count(), 2); } + +// ============================================================================ +// PMS 回归用例(Mode 7 PMS 缺陷热点补强,批次 1) +// 数据源:tests/.ut-pms/(bugs.json / work-order.md) +// ============================================================================ + +namespace { +// 在编码菜单中找一个非 UTF-8 的编码 action(分组菜单结构) +QAction *findOtherEncodeAction(DDropdownMenu *enc) +{ + const QList tops = enc->m_menu->actions(); + for (QAction *top : tops) { + if (!top->menu()) + continue; + for (QAction *a : top->menu()->actions()) { + if (a->text() != QString("UTF-8")) + return a; + } + } + return nullptr; +} +} // namespace + +// PMS: https://pms.uniontech.com/bug-view-169265.html commit: e2fbbd48 +// 场景:新建标签页修改编码格式并输入内容,保存时仍为 utf-8。修复(e2fbbd48): +// 编码切换先记录 previousText,文件加载中(getFileLoading)直接恢复,不调用重载 +TEST_F(BottomBarTest, BUG169265_EncodeSwitchLoading_RevertsPreviousText) +{ + // Arrange: 文件加载中 + m_fileLoading = true; + DDropdownMenu *enc = m_bar->getEncodeMenu(); + ASSERT_NE(enc, nullptr); + const QString prev = enc->getCurrentText(); + QAction *other = findOtherEncodeAction(enc); + ASSERT_NE(other, nullptr); + + // Act: 触发编码切换信号(加载中 → 恢复 previousText) + emit enc->currentActionChanged(other); + QApplication::processEvents(); + + // Assert: 底栏编码恢复原文本(短路,不触发重载) + EXPECT_EQ(enc->getCurrentText(), prev); + EXPECT_EQ(m_reloadEncodeCalls, 0); +} + +// PMS: https://pms.uniontech.com/bug-view-169265.html commit: e2fbbd48 +// 场景(成功分支):编码重载成功后底栏展示新编码,保存按新编码写入 +TEST_F(BottomBarTest, BUG169265_EncodeSwitchSuccess_UpdatesText) +{ + // Arrange: 加载完成 + 重载成功 + m_fileLoading = false; + m_reloadEncodeResult = true; + DDropdownMenu *enc = m_bar->getEncodeMenu(); + ASSERT_NE(enc, nullptr); + QAction *other = findOtherEncodeAction(enc); + ASSERT_NE(other, nullptr); + + // Act + emit enc->currentActionChanged(other); + QApplication::processEvents(); + + // Assert: 底栏展示新编码、重载已调用 + EXPECT_EQ(enc->getCurrentText(), other->text()); + EXPECT_EQ(m_reloadEncodeCalls, 1); + EXPECT_EQ(m_lastReloadEncode, other->text().toLocal8Bit()); +} + +// PMS: https://pms.uniontech.com/bug-view-55529.html commit: 221f0cd1 +// 场景:保存文件、切换编码方式设计混乱(sev1)。BottomBar 联动断言:编码切换 +// 成功后菜单互斥勾选(UTF-8 取消、新编码勾选),界面一致 +TEST_F(BottomBarTest, BUG55529_EncodeSwitchExclusive_BottomBarConsistent) +{ + // Arrange: 加载完成 + 重载成功 + m_fileLoading = false; + m_reloadEncodeResult = true; + DDropdownMenu *enc = m_bar->getEncodeMenu(); + ASSERT_NE(enc, nullptr); + ASSERT_NE(enc->m_pActUtf8, nullptr); + ASSERT_TRUE(enc->m_pActUtf8->isChecked()); + QAction *other = findOtherEncodeAction(enc); + ASSERT_NE(other, nullptr); + + // Act + emit enc->currentActionChanged(other); + QApplication::processEvents(); + + // Assert: 互斥勾选一致(UTF-8 取消、新编码勾选) + EXPECT_FALSE(enc->m_pActUtf8->isChecked()); + EXPECT_TRUE(other->isChecked()); + EXPECT_EQ(enc->getCurrentText(), other->text()); +} diff --git a/tests/widgets_ut/test_colorselectwdg.cpp b/tests/widgets_ut/test_colorselectwdg.cpp index e2db6661..5c616c85 100644 --- a/tests/widgets_ut/test_colorselectwdg.cpp +++ b/tests/widgets_ut/test_colorselectwdg.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include @@ -415,3 +416,138 @@ TEST_F(ColorSelectWdgTest, ColorSelectWdg_EventFilter_DelegatesToBase) // Assert: 不经按钮点击不发射默认色信号 EXPECT_EQ(spy.count(), 0); } + +// ============================================================ +// PMS 批次 2 回归用例(bug 196825 / 67950) +// PMS: https://pms.uniontech.com/bug-view-196825.html commit: 306a42de +// PMS: https://pms.uniontech.com/bug-view-67950.html commit: 9eb4f93c +// 场景:颜色标记图标悬停与按压状态修复(306a42de):ColorLabel 新增 +// m_bHover/m_bPressed 状态机(enter/leave/press/release),释放时仅在 +// 按压有效时选中并发 sigColorClicked;内存释放修复(9eb4f93c): +// ~ColorSelectWdg() 显式回收布局,QPointer 托管断言析构释放 +// ============================================================ + +// PMS: https://pms.uniontech.com/bug-view-196825.html commit: 306a42de +// 场景:完整悬停-按压-释放状态机(306a42de):enter 置 hover,press 仅置 +// pressed 不立即选中,release 选中并发信号,leave 复位悬停、选中保持 +TEST_F(ColorSelectWdgTest, BUG196825_FullHoverPressRelease_StateMachineSequence) +{ + // Arrange + QSignalSpy spy(label, &ColorLabel::sigColorClicked); + ASSERT_FALSE(label->m_bHover); + ASSERT_FALSE(label->m_bPressed); + + // Act 1: enter(QEnterEvent 合成)→ 悬停态置位 + const QPointF local(4, 4); + QEnterEvent enter(local, local, local); + QApplication::sendEvent(label, &enter); + EXPECT_TRUE(label->m_bHover); + + // Act 2: 左键 press → 仅按压态置位,不立即选中(196825 修复语义) + sendMouseEvent(QEvent::MouseButtonPress, Qt::LeftButton, Qt::LeftButton); + EXPECT_TRUE(label->m_bPressed); + EXPECT_FALSE(label->isSelected()); + EXPECT_EQ(spy.count(), 0); + + // Act 3: 左键 release → 选中 + 信号,pressed 复位,hover 保持 + sendMouseEvent(QEvent::MouseButtonRelease, Qt::LeftButton, Qt::NoButton); + EXPECT_TRUE(label->isSelected()); + EXPECT_FALSE(label->m_bPressed); + EXPECT_TRUE(label->m_bHover); + ASSERT_EQ(spy.count(), 1); + EXPECT_TRUE(spy.at(0).at(0).toBool()); + EXPECT_EQ(spy.at(0).at(1).value(), firstColor); + + // Act 4: leave → hover/pressed 复位,选中态保持 + QEvent leave(QEvent::Leave); + QApplication::sendEvent(label, &leave); + EXPECT_FALSE(label->m_bHover); + EXPECT_FALSE(label->m_bPressed); + EXPECT_TRUE(label->isSelected()); +} + +// PMS: https://pms.uniontech.com/bug-view-196825.html commit: 306a42de +// 场景:按压期间移出(leave 复位 pressed)→ 回到图标上释放不再触发选择 +TEST_F(ColorSelectWdgTest, BUG196825_LeaveDuringPress_ReleaseEmitsNothing) +{ + // Arrange: 进入并按下 + QSignalSpy spy(label, &ColorLabel::sigColorClicked); + QEnterEvent enter(QPointF(4, 4), QPointF(4, 4), QPointF(4, 4)); + QApplication::sendEvent(label, &enter); + sendMouseEvent(QEvent::MouseButtonPress, Qt::LeftButton, Qt::LeftButton); + ASSERT_TRUE(label->m_bPressed); + + // Act: 按住期间移出图标(leave 复位 hover 与 pressed) + QEvent leave(QEvent::Leave); + QApplication::sendEvent(label, &leave); + ASSERT_FALSE(label->m_bPressed); + + // Act: 释放(pressed 已复位 → mouseReleaseEvent 守卫拦截) + sendMouseEvent(QEvent::MouseButtonRelease, Qt::LeftButton, Qt::NoButton); + + // Assert: 无信号、未选中(强异常安全:状态未被污染) + EXPECT_EQ(spy.count(), 0); + EXPECT_FALSE(label->isSelected()); + EXPECT_FALSE(label->m_bHover); +} + +// PMS: https://pms.uniontech.com/bug-view-196825.html commit: 306a42de +// PMS: https://pms.uniontech.com/bug-view-67950.html commit: 9eb4f93c +// 场景:程序化选中(setColorSelected,默认色标记路径)后真实绘制: +// paintEvent 选中圆环分支可渲染,选中态画面与未选中基线不同( +// 非逐点像素断言,仅断言渲染成功且画面随状态变化) +TEST_F(ColorSelectWdgTest, BUG196825_SetColorSelected_PaintRendersSelectedState) +{ + // Arrange + label->resize(24, 24); + + // Act: 未选中基线绘制 → 置选中 → 再绘制(grab 同步触发 paintEvent) + const QPixmap base = label->grab(); + label->setColorSelected(true); + const QPixmap selected = label->grab(); + + // Assert: 两态均成功渲染(paintEvent 正常/选中分支无错误), + // 选中态画面与基线不同(选中圆环已画出) + ASSERT_FALSE(base.isNull()); + ASSERT_FALSE(selected.isNull()); + EXPECT_TRUE(label->isSelected()); + EXPECT_NE(base.toImage(), selected.toImage()); +} + +// PMS: https://pms.uniontech.com/bug-view-67950.html commit: 9eb4f93c +// 场景:内存释放修复(9eb4f93c):~ColorSelectWdg() 显式 delete +// m_pMainLayout/m_pHLayout1/m_pHLayout2 并置空;QPointer 托管控件与 +// 子色块/按钮,断言两种布局变体析构后全部释放无悬空(Asan 泄漏回归) +TEST_F(ColorSelectWdgTest, BUG67950_ConstructDestroy_QPointerReleased) +{ + // Arrange: QPointer 托管控件与子对象(带标题/紧凑两种变体各一) + QPointer withTextGuard; + QPointer compactGuard; + QPointer childLabel; + QPointer childButton; + + { + ColorSelectWdg *withText = new ColorSelectWdg(QStringLiteral("Mark")); + withTextGuard = withText; + ASSERT_FALSE(withTextGuard.isNull()); + QPushButton *btn = withText->findChild("PButton"); + ASSERT_NE(btn, nullptr); + childButton = btn; + ASSERT_FALSE(withText->m_colorLabels.isEmpty()); + childLabel = withText->m_colorLabels.first(); + + ColorSelectWdg *compact = new ColorSelectWdg(QString()); + compactGuard = compact; + ASSERT_FALSE(compactGuard.isNull()); + + // Act: 析构(修复:~ColorSelectWdg 回收三个布局指针并置空) + delete withText; + delete compact; + } + + // Assert: QPointer 全部失效 → 控件、子色块、按钮均已释放,无悬空 + EXPECT_TRUE(withTextGuard.isNull()); + EXPECT_TRUE(compactGuard.isNull()); + EXPECT_TRUE(childLabel.isNull()); + EXPECT_TRUE(childButton.isNull()); +} diff --git a/tests/widgets_ut/test_ddropdownmenu.cpp b/tests/widgets_ut/test_ddropdownmenu.cpp index ea330d4e..fd28892a 100644 --- a/tests/widgets_ut/test_ddropdownmenu.cpp +++ b/tests/widgets_ut/test_ddropdownmenu.cpp @@ -748,3 +748,214 @@ TEST_F(DDropdownMenuTest, SetSVGBackColor_ColorGroup_AttributeReplaced) } EXPECT_TRUE(found); } + +// ============================================================================ +// PMS 回归用例(Mode 7 PMS 缺陷热点补强,批次 1) +// 数据源:tests/.ut-pms/(bugs.json / work-order.md) +// ============================================================================ + +// PMS: https://pms.uniontech.com/bug-view-55529.html commit: 221f0cd1 +// 场景:保存文件、切换编码方式设计混乱(sev1)。修复后:切换编码时旧编码 +// 取消勾选、新编码勾选(互斥),getCurrentText 与勾选状态一致 +TEST_F(DDropdownMenuTest, BUG55529_EncodeSwitchExclusive_OldUncheckedNewChecked) +{ + // Arrange: 真实编码菜单(UTF-8 预选中) + DDropdownMenu *menu = DDropdownMenu::createEncodeMenu(); + ASSERT_NE(menu, nullptr); + ASSERT_NE(menu->m_pActUtf8, nullptr); + ASSERT_TRUE(menu->m_pActUtf8->isChecked()); + + // 找一个非 UTF-8 的编码 action + QAction *target = nullptr; + const QList tops = menu->m_menu->actions(); + for (QAction *top : tops) { + if (!top->menu()) + continue; + for (QAction *a : top->menu()->actions()) { + if (a->text() != QString("UTF-8")) { + target = a; + break; + } + } + if (target) + break; + } + ASSERT_NE(target, nullptr); + + // Act: 切换编码 + menu->setCurrentTextOnly(target->text()); + + // Assert: 互斥——旧编码取消勾选、新编码勾选、文本同步 + EXPECT_FALSE(menu->m_pActUtf8->isChecked()); + EXPECT_TRUE(target->isChecked()); + EXPECT_EQ(menu->getCurrentText(), target->text()); + delete menu; +} + +// PMS: https://pms.uniontech.com/bug-view-56765.html commit: ab891ddc +// 场景:打开 sh、cpp、json 等文件应用闪退(sev1)。修复(ab891ddc):菜单项 +// menu() 判空由 setCurrentTextOnly 迁移至 setCheckedExclusive 递归函数, +// 顶层无子菜单的 action 遍历不再空指针崩溃 +TEST_F(DDropdownMenuTest, BUG56765_NullSubmenu_SetCurrentTextOnlyNoCrash) +{ + // Arrange: 顶层 action 均无子菜单(menu() 为 null,与真实编码菜单结构不同) + QMenu *menu = new QMenu(); + QAction *plain1 = menu->addAction(QStringLiteral("Plain1")); + QAction *plain2 = menu->addAction(QStringLiteral("Plain2")); + ASSERT_NE(plain1, nullptr); + ASSERT_NE(plain2, nullptr); + obj->setMenu(menu); + + // Act & Assert: 无子菜单 action 遍历不闪退(修复前 menu()->actions() 空指针) + obj->setCurrentTextOnly(QStringLiteral("Plain1")); + EXPECT_EQ(obj->getCurrentText(), QStringLiteral("Plain1")); + EXPECT_TRUE(plain1->isChecked()); + EXPECT_FALSE(plain2->isChecked()); +} + +// PMS: https://pms.uniontech.com/bug-view-55503.html commit: 0990036c +// 场景:默认编码 Unicode/UTF-8 未显示勾选状态,注销/重启后仍如此。修复后: +// 编码菜单重建后通过 setCurrentTextOnly 恢复持久化编码的勾选状态 +TEST_F(DDropdownMenuTest, BUG55503_EncodeMenuRebuild_CheckedStateRestored) +{ + // Arrange: 编码菜单(模拟注销/重启后重建) + DDropdownMenu *menu = DDropdownMenu::createEncodeMenu(); + ASSERT_NE(menu, nullptr); + ASSERT_NE(menu->m_pActUtf8, nullptr); + EXPECT_TRUE(menu->m_pActUtf8->isChecked()); + + // Act: 切换到其他编码后恢复默认编码(持久化编码回显) + menu->setCurrentTextOnly(QStringLiteral("GBK")); + menu->setCurrentTextOnly(QStringLiteral("UTF-8")); + + // Assert: 默认编码勾选状态恢复 + EXPECT_TRUE(menu->m_pActUtf8->isChecked()); + EXPECT_EQ(menu->getCurrentText(), QStringLiteral("UTF-8")); + delete menu; +} + +// PMS: https://pms.uniontech.com/bug-view-169265.html commit: e2fbbd48 +// 场景:新建标签页修改编码格式并输入内容,保存时仍为 utf-8。修复(e2fbbd48): +// 新增 getCurrentText() const 访问器,底栏编码切换失败恢复 previousText 流程依赖 +TEST_F(DDropdownMenuTest, BUG169265_GetCurrentText_EncodeMenuReadback) +{ + // Arrange: 编码菜单(UTF-8 预选) + DDropdownMenu *menu = DDropdownMenu::createEncodeMenu(); + ASSERT_NE(menu, nullptr); + EXPECT_EQ(menu->getCurrentText(), QStringLiteral("UTF-8")); + + // Act: 切换编码(setCurrentTextOnly 更新 m_text) + QAction *other = nullptr; + const QList tops = menu->m_menu->actions(); + for (QAction *top : tops) { + if (!top->menu()) + continue; + for (QAction *a : top->menu()->actions()) { + if (a->text() != QString("UTF-8")) { + other = a; + break; + } + } + if (other) + break; + } + ASSERT_NE(other, nullptr); + menu->setCurrentTextOnly(other->text()); + + // Assert: getCurrentText 读回当前编码(底栏恢复流程依赖) + EXPECT_EQ(menu->getCurrentText(), other->text()); + delete menu; +} + +// ============================================================================ +// PMS 回归用例(Mode 2 PMS bug 回归,批次 2) +// 数据源:tests/.ut-pms/bugs.json;修复 diff:git show -- src/widgets/ddropdownmenu.cpp +// ============================================================================ + +// PMS: https://pms.uniontech.com/bug-view-67950.html commit: 9eb4f93c +// 场景:安全测试 17 处内存泄露(s2)。修复(9eb4f93c):~DDropdownMenu 补齐 +// deleteMenuActionGroup()+deleteMenu(),setMenu/setMenuActionGroup 注入的 +// 无父对象资源随析构释放;createHighLightMenu 组归属经 setMenuActionGroup 托管。 +// (同 bug 57271 对构造函数的箭头图标 DPR 渲染调整单测不可复现,见 SKIP 记录) +TEST_F(DDropdownMenuTest, BUG67950_Destructor_ReleasesMenuAndActionGroup) +{ + // Arrange: 无父对象菜单/组(修复前 ~DDropdownMenu 为空实现 → 泄漏) + DDropdownMenu *m = new DDropdownMenu(); + QMenu *menu = buildNestedMenu(nullptr); + m->setMenu(menu); + QActionGroup *group = new QActionGroup(nullptr); + m->setMenuActionGroup(group); + QPointer menuWatch(menu); + QPointer groupWatch(group); + + // Act: 析构(修复后析构链 deleteMenuActionGroup + deleteMenu) + delete m; + + // Assert: 托管资源随析构释放(QPointer 归空),不再泄露 + EXPECT_TRUE(menuWatch.isNull()); + EXPECT_TRUE(groupWatch.isNull()); + + // 补充:createHighLightMenu 归属链(9eb4f93c 在该函数增加 + // setMenuActionGroup —— 组随菜单析构一并释放) + DDropdownMenu *hl = DDropdownMenu::createHighLightMenu(); + ASSERT_NE(hl, nullptr); + QPointer hlMenuWatch(hl->m_menu); + QPointer hlGroupWatch(hl->m_actionGroup); + + // Act: 析构高亮菜单 + delete hl; + + // Assert: 内部菜单与互斥组随析构释放 + EXPECT_TRUE(hlMenuWatch.isNull()); + EXPECT_TRUE(hlGroupWatch.isNull()); +} + +// PMS: https://pms.uniontech.com/bug-view-67950.html commit: 9eb4f93c +// 场景:createEncodeMenu 内部 DMenu 无父对象(底栏经此工厂创建编码菜单)。 +// 修复(9eb4f93c):~DDropdownMenu 执行 deleteMenu,内部菜单随对象析构释放, +// 编码菜单销毁不再泄露内部 DMenu +TEST_F(DDropdownMenuTest, BUG67950_CreateEncodeMenu_DtorReleasesInternalMenu) +{ + // Arrange: 编码菜单(内部 DMenu 无父对象,修复前随对象销毁而泄露) + DDropdownMenu *encodeMenu = DDropdownMenu::createEncodeMenu(); + ASSERT_NE(encodeMenu, nullptr); + ASSERT_NE(encodeMenu->m_menu, nullptr); + QPointer menuWatch(encodeMenu->m_menu); + + // Act: 析构编码菜单 + delete encodeMenu; + + // Assert: 内部菜单随析构释放 + EXPECT_TRUE(menuWatch.isNull()); +} + +// PMS: https://pms.uniontech.com/bug-view-57271.html commit: 1abe3fb2 +// 场景:切换编码方式为阿拉伯语/CP1256 应用闪退(s2)。根因:编码表 ini 中 +// "CP1256 " 带尾随空格(split(",") 不 trim → 非法编码名)。修复(1abe3fb2): +// encodes1.ini 去除尾随空格(另含箭头图标 DPR 渲染调整,单测不可复现)。 +// 经 createEncodeMenu 校验全部编码名无首尾空白且 CP1256(阿拉伯语组)存在 +TEST_F(DDropdownMenuTest, BUG57271_EncodeActions_NoTrailingWhitespace) +{ + // Arrange: 真实编码菜单(资源 :/encodes/encodes.ini 经 Utils::getSupportEncoding 装载) + DDropdownMenu *menu = DDropdownMenu::createEncodeMenu(); + ASSERT_NE(menu, nullptr); + const QList tops = menu->m_menu->actions(); + ASSERT_FALSE(tops.isEmpty()); + + // Act: 遍历全部编码 action(顶层 → 子菜单) + bool cp1256Found = false; + for (QAction *top : tops) { + ASSERT_NE(top->menu(), nullptr); + for (QAction *a : top->menu()->actions()) { + // Assert: 编码名无首尾空白(修复前 "CP1256 " 尾随空格 → 非法编码名闪退) + EXPECT_EQ(a->text(), a->text().trimmed()) + << "encode name has surrounding whitespace: " << a->text().toStdString(); + if (a->text() == QStringLiteral("CP1256")) + cp1256Found = true; + } + } + + // Assert: 缺陷场景编码 CP1256(阿拉伯语组)在菜单中且名称合法 + EXPECT_TRUE(cp1256Found); + delete menu; +} diff --git a/tests/widgets_ut/test_window.cpp b/tests/widgets_ut/test_window.cpp index bfc01b6f..27e0eac5 100644 --- a/tests/widgets_ut/test_window.cpp +++ b/tests/widgets_ut/test_window.cpp @@ -3131,3 +3131,373 @@ TEST_F(WindowTest, CloseEvent_UnsavedCancel_IgnoresClose) EXPECT_EQ(spy.count(), 0); EXPECT_TRUE(m_win->isVisible()); } + +// ============================================================================ +// PMS 回归用例(Mode 7 PMS 缺陷热点补强,批次 1) +// 数据源:tests/.ut-pms/(bugs.json / work-order.md) +// 断言针对当前代码语义下 bug 揭示的边界;bug 详情见 PMS 链接 +// ============================================================================ + +// PMS: https://pms.uniontech.com/bug-view-91037.html commit: 1c45afc8 +// 场景:被命名替换的文档在关闭标签页时闪退。修复后:updateSaveAsFileName 的 +// 替换分支先关闭原同名 tab,再迁移映射,最终 m_wrappers 无悬挂键、无同名残留 +TEST_F(WindowTest, BUG91037_NamedReplaceClose_DupTabRemoved) +{ + // Arrange: 打开文件 tab(路径 A),再加空白 tab 准备命名为同名 + const QString pathA = addFileTab(QStringLiteral("A.txt")); + EditWrapper *wA = m_win->wrapper(pathA); + ASSERT_NE(wA, nullptr); + const QString blank = addBlankAndGetPath(); + EditWrapper *wBlank = m_win->wrapper(blank); + ASSERT_NE(wBlank, nullptr); + ASSERT_EQ(m_tabbar->count(), 2); + + // Act: 将空白 tab 保存命名为 A.txt(触发替换同名 tab 分支) + m_win->updateSaveAsFileName(blank, pathA); + QApplication::processEvents(); + + // Assert: 映射迁移到新路径(原键移除、悬挂键清理),窗口存活不闪退 + EXPECT_FALSE(m_win->m_wrappers.contains(blank)); + EXPECT_EQ(m_win->m_wrappers.size(), 1); + EXPECT_EQ(m_win->m_wrappers.value(pathA), wBlank); + EXPECT_EQ(wBlank->filePath(), pathA); + EXPECT_GE(m_tabbar->count(), 1); + EXPECT_TRUE(m_win->isVisible()); +} + +// PMS: https://pms.uniontech.com/bug-view-64947.html commit: fe2e6283 +// 场景:同名文件替换后 tab 显示错乱。修复后:替换后 tab 标题与 wrapper 路径一致 +TEST_F(WindowTest, BUG64947_SameNameReplace_TabTitleUpdated) +{ + // Arrange: 打开同名文件,另开空白 tab 保存命名为同名 + const QString pathA = addFileTab(QStringLiteral("A.txt"), "content-A\n"); + const QString blank = addBlankAndGetPath(); + + // Act + m_win->updateSaveAsFileName(blank, pathA); + QApplication::processEvents(); + + // Assert: tab 显示名称与 wrapper 路径一致 + EXPECT_EQ(m_tabbar->currentName(), QStringLiteral("A.txt")); + EditWrapper *cur = m_win->currentWrapper(); + ASSERT_NE(cur, nullptr); + EXPECT_EQ(cur->filePath(), pathA); +} + +// PMS: https://pms.uniontech.com/bug-view-87216.html commit: 0f405329 +// 场景:打开多个标签页,依次点击标签页 x 按钮,程序退出。修复后:依次关闭 +// 全部 tab 无残留、无退出 +TEST_F(WindowTest, BUG87216_MultiTabCloseSequential_NoExit) +{ + // Arrange: 3 个 tab(2 文件 + 1 空白,保留空白 tab 验证关闭中间 tab 不退出) + const QString p1 = addFileTab(QStringLiteral("m1.txt")); + const QString p2 = addFileTab(QStringLiteral("m2.txt")); + addBlankAndGetPath(); + ASSERT_EQ(m_tabbar->count(), 3); + + // Act: 依次触发标签页关闭(模拟依次点击 x,保留最后一个验证中途不退出) + m_win->closeTab(p1); + QApplication::processEvents(); + m_win->closeTab(p2); + QApplication::processEvents(); + + // Assert: 依次关闭中间标签页程序不退出(bug 修复语义),无残留 + EXPECT_FALSE(m_win->m_wrappers.contains(p1)); + EXPECT_FALSE(m_win->m_wrappers.contains(p2)); + EXPECT_TRUE(m_win->isVisible()); +} + +// PMS: https://pms.uniontech.com/bug-view-56107.html +// 场景:同时拖拽多个文件至应用窗口打开,依次点击标签页 X 关闭,应用闪退。 +// 修复后:多文件 tab 依次关闭全程无崩溃、无悬挂 wrapper +TEST_F(WindowTest, BUG56107_MultiFileTabsCloseSequential_NoCrash) +{ + // Arrange: 模拟拖拽打开 5 个文件 tab + QStringList paths; + for (int i = 0; i < 5; ++i) + paths << addFileTab(QStringLiteral("multi%1.txt").arg(i)); + ASSERT_EQ(m_tabbar->count(), 5); + + // Act: 依次关闭(保留最后一个验证中途不退出) + for (int i = 0; i < 4; ++i) { + m_win->closeTab(paths[i]); + QApplication::processEvents(); + } + + // Assert: 依次关闭中间标签页无崩溃、无悬挂 wrapper,程序不退出 + for (int i = 0; i < 4; ++i) + EXPECT_FALSE(m_win->m_wrappers.contains(paths[i])); + EXPECT_TRUE(m_win->isVisible()); +} + +// PMS: https://pms.uniontech.com/bug-view-56153.html commit: 4fb7deff +// 场景:编辑文本不保存,关闭选"保存",重新打开内容显示异常。修复后: +// 关闭保存流程内容持久化,重开内容正确 +TEST_F(WindowTest, BUG56153_CloseWithSave_ContentPersisted) +{ + // Arrange: 打开文件并标记已修改(桩 isModified,与现有 CloseTab 用例同模式) + const QString path = createFile(QStringLiteral("save-close.txt"), "original\n"); + m_win->addTab(path, true); + EditWrapper *w = m_win->wrapper(path); + ASSERT_NE(w, nullptr); + stub.set_lamda(&EditWrapper::isModified, [](EditWrapper *) -> bool { return true; }); + int saveCalls = 0; + stub.set_lamda(&EditWrapper::saveFile, + [&saveCalls](EditWrapper *, QByteArray) -> bool { + ++saveCalls; + return true; + }); + m_ddialogResult = 2; // 保存 + + // Act: 关闭标签页(选保存) + m_win->closeTab(path); + QApplication::processEvents(); + + // Assert: 关闭选"保存"→ 保存分支执行(修复前丢改动) + EXPECT_EQ(saveCalls, 1); +} + +// PMS: https://pms.uniontech.com/bug-view-56048.html +// 场景:切换编码方式后关闭 tab 应有"是否保存"提示(修复前静默关闭丢失改动)。 +// 断言:已修改文件切换编码触发保存确认对话框,不被静默跳过 +TEST_F(WindowTest, BUG56048_EncodeSwitchOnModified_PromptsSave) +{ + // Arrange: 打开文件并标记已修改(reloadFileEncode 内部读 TextEdit::getModified) + const QString path = addFileTab(QStringLiteral("enc-switch.txt"), "hello\n"); + EditWrapper *w = m_win->wrapper(path); + ASSERT_NE(w, nullptr); + stub.set_lamda(&TextEdit::getModified, [](TextEdit *) -> bool { return true; }); + + // Act: 切换编码(与当前编码不同)→ 已修改 → 弹保存确认 + m_ddialogExecCalls = 0; + m_ddialogResult = 1; // 保存 + w->reloadFileEncode(QByteArrayLiteral("UTF-16")); + QApplication::processEvents(); + + // Assert: 保存确认对话框已弹出 + EXPECT_GE(m_ddialogExecCalls, 1); +} + +// PMS: https://pms.uniontech.com/bug-view-56765.html commit: ab891ddc +// 场景:打开 sh、cpp、json 等脚本文件应用闪退。修复后:脚本文件正常加载 +TEST_F(WindowTest, BUG56765_OpenScriptFile_NoCrash) +{ + // Arrange & Act: 打开 shell 脚本文件 + const QString path = createFile(QStringLiteral("script.sh"), + "#!/bin/bash\necho hi\n"); + m_win->addTab(path, true); + QApplication::processEvents(); + EditWrapper *w = m_win->wrapper(path); + + // Assert: wrapper 就绪、加载完成、tab 名称正确 + ASSERT_NE(w, nullptr); + EXPECT_TRUE(waitUntil([w]() { return !w->getFileLoading(); })); + EXPECT_EQ(m_tabbar->currentName(), QStringLiteral("script.sh")); + EXPECT_TRUE(m_win->isVisible()); +} + +// PMS: https://pms.uniontech.com/bug-view-58894.html commit: 44442923 +// 场景:空白 tab(临时文件)状态被误清,isModified 语义不完整。修复后: +// isModified = getModified() | m_bIsTemFile,临时文件状态计入 +TEST_F(WindowTest, BUG58894_TemFileTab_ModifiedReflectsTemState) +{ + // Arrange: 恢复备份文件标签页(bIsTemFile=true) + const QString temPath = createFile(QStringLiteral("tem-modified.txt"), QByteArray("tem\n")); + m_win->addTemFileTab(temPath, QStringLiteral("tem.txt"), QString(), + QStringLiteral("2026-01-01T00:00:00"), true); + QApplication::processEvents(); + EditWrapper *w = m_win->wrapper(temPath); + ASSERT_NE(w, nullptr); + + // Assert: 修复后语义——临时文件状态计入 isModified + EXPECT_TRUE(w->isTemFile()); + EXPECT_TRUE(w->isModified()); +} + +// PMS: https://pms.uniontech.com/bug-view-58883.html commit: 44442923 +// 场景:打开新文本/重新打开关闭前的文本,现场恢复不一致。修复后: +// 打开文件 tab 的 pending 消费一致、wrapper 路径一致、无悬挂 +TEST_F(WindowTest, BUG58883_OpenFileNoEdit_PendingConsistent) +{ + // Arrange: 模拟一次打开两个文件(第一个立即加载,其余 pending) + const QString p1 = createFile(QStringLiteral("open-a.txt"), "A\n"); + const QString p2 = createFile(QStringLiteral("open-b.txt"), "B\n"); + m_qdialogResult = QDialog::Accepted; + m_selectedFiles = QStringList{ p1, p2 }; + + // Act + m_win->openFile(); + QApplication::processEvents(); + waitUntil([this, &p1]() { return m_win->wrapper(p1) != nullptr; }); + + // Assert: 已打开文件的 wrapper 路径一致,无悬挂 wrapper + EditWrapper *w1 = m_win->wrapper(p1); + ASSERT_NE(w1, nullptr); + EXPECT_EQ(w1->filePath(), p1); + EditWrapper *w2 = m_win->wrapper(p2); + if (w2 != nullptr) + EXPECT_EQ(w2->filePath(), p2); + EXPECT_FALSE(m_win->m_wrappers.contains(QString())); + EXPECT_TRUE(m_win->isVisible()); +} + +// ============================================================================ +// PMS 回归用例(Mode 2 PMS bug 回归,批次 2) +// 数据源:tests/.ut-pms/bugs.json + git show -- src/widgets/window.cpp +// 断言针对当前代码语义下 bug 揭示的边界;bug 详情见 PMS 链接 +// ============================================================================ + +// PMS: https://pms.uniontech.com/bug-view-62649.html commit: 3a96d0f4 +// 场景:新建文档改名后(磁盘路径不存在)Ctrl+S 无法保存。修复(3a96d0f4): +// Window::saveFile 权限检查外包 info.exists() 守卫——文件不存在时跳过权限 +// 检查直接保存,不再误报无权限拒绝保存 +TEST_F(WindowTest, BUG62649_SaveFileNotExist_SkipsPermissionCheck) +{ + // Arrange: 打开真实文件 tab 后删除磁盘文件(模拟改名后打开的旧路径) + const QString path = addFileTab(QStringLiteral("renamed.txt"), "content\n"); + EditWrapper *w = m_win->wrapper(path); + ASSERT_NE(w, nullptr); + ASSERT_TRUE(QFile::exists(path)); + QFile::remove(path); + ASSERT_FALSE(QFile::exists(path)); + + // Act: 保存当前文件(Ctrl+S 语义,走 Window::saveFile) + const bool ret = m_win->saveFile(); + QApplication::processEvents(); + + // Assert: 磁盘文件不存在时不做权限检查,保存继续执行(修复前因无权限 + // 误判 return false 且回调无权限提示),文件重新落盘 + EXPECT_TRUE(ret); + EXPECT_EQ(m_iconMsgCalls, 0); + EXPECT_FALSE(m_lastIconMsg.contains(QStringLiteral("permission"))); + EXPECT_TRUE(QFile::exists(path)); +} + +// PMS: https://pms.uniontech.com/bug-view-365421.html commit: 717fe1b1 +// 场景:打开多个文本时关闭其中一个,窗口闪退/未正确关闭。修复(717fe1b1): +// removeWrapper 退出条件由仅 m_wrappers.isEmpty() 改为同时检查 +// m_tabbar->count() == 0——标签页仍在时不得 close() 窗口;全部关闭后才退出 +TEST_F(WindowTest, BUG365421_RemoveWrapper_TabsRemain_WindowStays) +{ + // Arrange: 展示窗口,打开 2 个文件 tab + m_win->show(); + const QString p1 = addFileTab(QStringLiteral("w1.txt")); + const QString p2 = addFileTab(QStringLiteral("w2.txt")); + QSignalSpy spy(m_win, &Window::closeWindow); + ASSERT_EQ(m_tabbar->count(), 2); + + // Act: 移除全部 wrapper(模拟关闭过程中 wrapper 先于 tab 清理的时序) + m_win->removeWrapper(p1, false); + m_win->removeWrapper(p2, false); + QApplication::processEvents(); + + // Assert: wrapper 已清空但标签页仍在 → 修复后不 close(),窗口存活不闪退 + EXPECT_TRUE(m_win->m_wrappers.isEmpty()); + EXPECT_EQ(m_tabbar->count(), 2); + EXPECT_EQ(spy.count(), 0); + EXPECT_TRUE(m_win->isVisible()); + + // Act: 补齐关闭流程——标签页全部移除后再触发 removeWrapper + m_tabbar->removeTab(0); + m_tabbar->removeTab(0); + QApplication::processEvents(); + m_win->removeWrapper(QString(), false); + QApplication::processEvents(); + + // Assert: wrapper 空 + 标签页空 → 窗口正常关闭退出(修复后正确关闭时机) + EXPECT_EQ(m_tabbar->count(), 0); + EXPECT_EQ(spy.count(), 1); + EXPECT_FALSE(m_win->isVisible()); +} + +// PMS: https://pms.uniontech.com/bug-view-67180.html commit: d3d6c4f9 +// 场景:打印预览后关闭 tab,再在另一 tab 打开打印预览闪退(m_printDoc 悬挂)。 +// 修复(d3d6c4f9):新增 clearPrintTextDocument 并在预览 finished/rejected 与 +// 析构中清理 m_printDoc,doPrint 对空 m_printDoc 早退。覆盖 d3d6c4f9 涉及的 +// popupPrintDialog/saveAsFileToDisk/saveBlankFileToDisk/openFile/confirmInvalidCharSave +// 关联语义(打印文档生命周期) +TEST_F(WindowTest, BUG67180_PrintPreviewAfterTabClose_NoDanglingDoc) +{ + // Arrange: 打开文件 A 并弹出打印预览(克隆 A 的文档) + m_win->show(); + const QString pathA = addFileTab(QStringLiteral("A.txt"), "content A\n"); + m_win->popupPrintDialog(); + QApplication::processEvents(); + ASSERT_NE(m_win->m_pPreview, nullptr); + ASSERT_NE(m_win->m_printDoc, nullptr); + + // Act: 关闭预览(finished → clearPrintTextDocument 修复语义) + emit m_win->m_pPreview->finished(0); + QApplication::processEvents(); + delete m_win->m_pPreview; + m_win->m_pPreview = nullptr; + + // Assert: 打印文档已释放置空,无悬挂克隆副本 + EXPECT_EQ(m_win->m_printDoc, nullptr); + + // Act: 关闭 tab A(销毁其编辑器/文档),换文档 B 再次弹出打印预览 + m_win->closeTab(pathA); + QApplication::processEvents(); + const QString pathB = addFileTab(QStringLiteral("B.txt"), "content B\n"); + ASSERT_NE(m_win->wrapper(pathB), nullptr); + m_win->popupPrintDialog(); + QApplication::processEvents(); + + // Assert: 二次打印预览无闪退,m_printDoc 为 B 的全新克隆副本 + ASSERT_NE(m_win->m_pPreview, nullptr); + ASSERT_NE(m_win->m_printDoc, nullptr); + EXPECT_FALSE(m_win->m_printDoc->isEmpty()); + EXPECT_EQ(m_win->m_printDoc->toPlainText(), QString("content B\n")); + + // Act: doPrint 空文档早退链(修复新增的 nullptr == m_printDoc 守卫) + m_win->m_printDoc = nullptr; + DPrinter paintPrinter(QPrinter::HighResolution); + m_win->doPrint(&paintPrinter, QVector { 1 }); + + emit m_win->m_pPreview->finished(0); + delete m_win->m_pPreview; + m_win->m_pPreview = nullptr; +} + +// PMS: https://pms.uniontech.com/bug-view-65228.html commit: d242fe4f +// PMS: https://pms.uniontech.com/bug-view-66378.html commit: e3cbab1d +// 场景:大文本中"标记所有"后查找下一处/上一处卡死(标记颜色未随查找动态 +// 更新)。修复(d242fe4f/e3cbab1d):handleFindKeyword 在查找跳转时调用 +// TextEdit::markAllKeywordInView 动态更新可视范围内标记的颜色 +TEST_F(WindowTest, BUG65228_FindNextMarkAll_UpdatesInView) +{ + // Arrange: 打开有内容的文件,桩记录 markAllKeywordInView 调用 + addFileTab(QStringLiteral("mark.txt"), QByteArray("alpha beta alpha\n")); + int markInViewCalls = 0; + stub.set_lamda(&TextEdit::markAllKeywordInView, + [&markInViewCalls](TextEdit *) -> void { ++markInViewCalls; }); + + // Act: 查找下一处 / 上一处(handleFindKeyword 全链路) + m_win->handleFindNextSearchKeyword(QStringLiteral("alpha")); + m_win->handleFindPrevSearchKeyword(QStringLiteral("alpha")); + QApplication::processEvents(); + + // Assert: 查找跳转时标记所有颜色更新被调用(修复前缺失导致卡死路径) + EXPECT_GE(markInViewCalls, 2); + EXPECT_EQ(m_win->getKeywordForSearch(), QString("alpha")); +} + +// PMS: https://pms.uniontech.com/bug-view-282985.html commit: 67eb6905 +// 场景:打开大文件后关闭,内存不释放。修复(67eb6905):removeWrapper 与 +// checkTabbarForReload 末尾调用 StartManager::instance()->delayMallocTrim() +// 触发延迟内存回收 +TEST_F(WindowTest, BUG282985_CloseTab_TriggersMemoryTrim) +{ + // Arrange: 打开文件 tab,桩记录 delayMallocTrim 调用 + const QString path = addFileTab(QStringLiteral("trim.txt"), "trim\n"); + int trimCalls = 0; + stub.set_lamda(&StartManager::delayMallocTrim, + [&trimCalls](StartManager *) -> void { ++trimCalls; }); + + // Act: 关闭标签页(removeWrapper → delayMallocTrim 修复语义) + m_win->closeTab(path); + QApplication::processEvents(); + + // Assert: 关闭 tab 触发延迟内存回收 + EXPECT_GE(trimCalls, 1); +}