From b01cd15b0c596510d852357303629cc76d010ce3 Mon Sep 17 00:00:00 2001 From: xiepengfei Date: Fri, 4 Sep 2026 14:57:25 +0800 Subject: [PATCH] test: add unit tests for common modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GTest suites for common utilities: settings, url info, event log, encoding detection, syntax highlighter, file load thread, performance monitor and text file saver. 新增 settings、urlinfo、eventlog、编码检测、语法高亮、文件加载线程、 性能监控、文本保存等公共模块的 GTest 单元测试。 Log: 新增公共模块单元测试 Influence: 仅新增单元测试代码,不影响编辑器本体功能。 --- tests/common/CMakeLists.txt | 127 ++ tests/common/cov_report.sh | 46 + tests/common/test_config.cpp | 321 +++++ tests/common/test_data/encodes.ini | 4 + tests/common/test_data/test.qss | 4 + tests/common/test_data/test.svg | 5 + tests/common/test_eventlog.cpp | 234 ++++ tests/common/test_settings.cpp | 1168 ++++++++++++++++ tests/common/test_urlinfo.cpp | 179 +++ tests/common/test_utils.cpp | 1489 +++++++++++++++++++++ tests/common/ut_resources.qrc.in | 12 + tests/common2/CMakeLists.txt | 88 ++ tests/common2/test_csyntaxhighlighter.cpp | 206 +++ tests/common2/test_detectcode.cpp | 892 ++++++++++++ tests/common2/test_fileloadthread.cpp | 423 ++++++ tests/common2/test_iflytekaiassistant.cpp | 575 ++++++++ tests/common2/test_loadlibs.cpp | 196 +++ tests/common2/test_performancemonitor.cpp | 166 +++ tests/common2/test_savefileinterface.cpp | 125 ++ tests/common2/test_textfilesaver.cpp | 355 +++++ 20 files changed, 6615 insertions(+) create mode 100644 tests/common/CMakeLists.txt create mode 100755 tests/common/cov_report.sh create mode 100644 tests/common/test_config.cpp create mode 100644 tests/common/test_data/encodes.ini create mode 100644 tests/common/test_data/test.qss create mode 100644 tests/common/test_data/test.svg create mode 100644 tests/common/test_eventlog.cpp create mode 100644 tests/common/test_settings.cpp create mode 100644 tests/common/test_urlinfo.cpp create mode 100644 tests/common/test_utils.cpp create mode 100644 tests/common/ut_resources.qrc.in create mode 100644 tests/common2/CMakeLists.txt create mode 100644 tests/common2/test_csyntaxhighlighter.cpp create mode 100644 tests/common2/test_detectcode.cpp create mode 100644 tests/common2/test_fileloadthread.cpp create mode 100644 tests/common2/test_iflytekaiassistant.cpp create mode 100644 tests/common2/test_loadlibs.cpp create mode 100644 tests/common2/test_performancemonitor.cpp create mode 100644 tests/common2/test_savefileinterface.cpp create mode 100644 tests/common2/test_textfilesaver.cpp diff --git a/tests/common/CMakeLists.txt b/tests/common/CMakeLists.txt new file mode 100644 index 00000000..89c24667 --- /dev/null +++ b/tests/common/CMakeLists.txt @@ -0,0 +1,127 @@ +# SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +# SPDX-License-Identifier: GPL-3.0-or-later +# +# tests/common —— src/common 模块单元测试(B2 批次) +# 每个类一个独立测试目标:test_settings / test_config / test_utils / test_urlinfo / test_eventlog + +set(QT_VERSION 6) +set(STUB_SHADOW "${CMAKE_SOURCE_DIR}/tests/3rdparty/stub/stub-shadow.cpp") + +# QSignalSpy 需要 QtTest;根 tests/CMakeLists.txt 未包含该组件,此处补找 +find_package(Qt6 REQUIRED COMPONENTS Test) +set(COMMON_INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_SOURCE_DIR}/src/common + ${CMAKE_SOURCE_DIR}/src/widgets + ${CMAKE_SOURCE_DIR}/src/controls + ${CMAKE_SOURCE_DIR}/src/basepub + ${CMAKE_SOURCE_DIR}/tests/3rdparty/stub +) + +# --------------------------------------------------------------------------- +# 测试资源:qss / svg / encodes.ini / settings.json(由项目模板生成,路径回填 qrc) +# --------------------------------------------------------------------------- +set(THEME_DEFAULT_PATH "/usr/share/deepin-editor/themes/deepin.theme") +set(TEST_DATA_DIR ${CMAKE_CURRENT_BINARY_DIR}/test_data) +configure_file( + "${CMAKE_SOURCE_DIR}/src/resources/settings.json.in" + "${TEST_DATA_DIR}/settings.json" + @ONLY +) +configure_file("test_data/test.qss" "${TEST_DATA_DIR}/test.qss" COPYONLY) +configure_file("test_data/test.svg" "${TEST_DATA_DIR}/test.svg" COPYONLY) +configure_file("test_data/encodes.ini" "${TEST_DATA_DIR}/encodes.ini" COPYONLY) +configure_file("ut_resources.qrc.in" "${CMAKE_CURRENT_BINARY_DIR}/ut_resources.qrc" @ONLY) + +set(UT_RESOURCES_QRC ${CMAKE_CURRENT_BINARY_DIR}/ut_resources.qrc) + +# 源码文件(按类分目标编译,保证 lcov 按目标隔离) +set(SETTINGS_SOURCES + ${CMAKE_SOURCE_DIR}/src/common/settings.cpp + ${CMAKE_SOURCE_DIR}/src/widgets/pathsettintwgt.cpp +) +set(UTILS_SOURCES + ${CMAKE_SOURCE_DIR}/src/common/utils.cpp + ${CMAKE_SOURCE_DIR}/src/basepub/load_libs.c +) +set(URLINFO_SOURCES + ${CMAKE_SOURCE_DIR}/src/common/utils.cpp + ${CMAKE_SOURCE_DIR}/src/basepub/load_libs.c +) +set(CONFIG_SOURCES + ${CMAKE_SOURCE_DIR}/src/common/config.cpp +) +set(EVENTLOG_SOURCES + ${CMAKE_SOURCE_DIR}/src/common/eventlogutils.cpp +) + +# --------------------------------------------------------------------------- +# 公共目标辅助函数 +# --------------------------------------------------------------------------- +function(add_ut_target TARGET_NAME) + cmake_parse_arguments(UT "" "" "SOURCES;QT_LIBS;DTK_LIBS;EXTRA_LIBS" ${ARGN}) + add_executable(${TARGET_NAME} ${UT_SOURCES} ${STUB_SHADOW}) + set_target_properties(${TARGET_NAME} PROPERTIES AUTOMOC ON AUTORCC ON) + target_include_directories(${TARGET_NAME} PRIVATE ${COMMON_INCLUDE_DIRS}) + target_link_libraries(${TARGET_NAME} + PRIVATE + GTest::gtest + GTest::gtest_main + Qt${QT_VERSION}::Core + ${UT_QT_LIBS} + ${UT_DTK_LIBS} + ${UT_EXTRA_LIBS} + ) + if(CMAKE_BUILD_TYPE STREQUAL "Debug") + target_link_libraries(${TARGET_NAME} PRIVATE gcov) + endif() + gtest_discover_tests(${TARGET_NAME} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + message(STATUS "UT: ${TARGET_NAME} configured") +endfunction() + +# --------------------------------------------------------------------------- +# test_settings:Settings / CustemBackend / KeySequenceEdit(GUI 混合,offscreen) +# --------------------------------------------------------------------------- +add_ut_target(test_settings + SOURCES test_settings.cpp ${SETTINGS_SOURCES} ${UT_RESOURCES_QRC} + QT_LIBS Qt${QT_VERSION}::Gui Qt${QT_VERSION}::Widgets Qt${QT_VERSION}::Svg Qt${QT_VERSION}::Core5Compat Qt${QT_VERSION}::Test + DTK_LIBS Dtk${QT_VERSION}::Widget Dtk${QT_VERSION}::Core +) + +# --------------------------------------------------------------------------- +# test_config:Config(DConfig stub + iconv 检测) +# --------------------------------------------------------------------------- +add_ut_target(test_config + SOURCES test_config.cpp ${CONFIG_SOURCES} + QT_LIBS Qt${QT_VERSION}::Gui + DTK_LIBS Dtk${QT_VERSION}::Core +) + +# --------------------------------------------------------------------------- +# test_utils:Utils(含 Settings 依赖,GUI offscreen) +# --------------------------------------------------------------------------- +add_ut_target(test_utils + SOURCES test_utils.cpp ${UTILS_SOURCES} ${SETTINGS_SOURCES} ${UT_RESOURCES_QRC} + QT_LIBS Qt${QT_VERSION}::Gui Qt${QT_VERSION}::Widgets Qt${QT_VERSION}::Svg Qt${QT_VERSION}::DBus Qt${QT_VERSION}::Core5Compat Qt${QT_VERSION}::Test + DTK_LIBS Dtk${QT_VERSION}::Widget Dtk${QT_VERSION}::Core + EXTRA_LIBS KF6::Codecs +) + +# --------------------------------------------------------------------------- +# test_urlinfo:UrlInfo(header-only,Core 即可) +# --------------------------------------------------------------------------- +add_ut_target(test_urlinfo + SOURCES test_urlinfo.cpp ${URLINFO_SOURCES} + QT_LIBS Qt${QT_VERSION}::Gui Qt${QT_VERSION}::Widgets Qt${QT_VERSION}::Svg Qt${QT_VERSION}::DBus Qt${QT_VERSION}::Core5Compat + DTK_LIBS Dtk${QT_VERSION}::Widget Dtk${QT_VERSION}::Core + EXTRA_LIBS KF6::Codecs +) + +# --------------------------------------------------------------------------- +# test_eventlog:Eventlogutils(QLibrary stub,Core) +# --------------------------------------------------------------------------- +add_ut_target(test_eventlog + SOURCES test_eventlog.cpp ${EVENTLOG_SOURCES} + QT_LIBS + DTK_LIBS +) diff --git a/tests/common/cov_report.sh b/tests/common/cov_report.sh new file mode 100755 index 00000000..393bf0dd --- /dev/null +++ b/tests/common/cov_report.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# B2 批次 lcov 函数覆盖率统计(按测试目标隔离对象目录) +# 用法: cov_report.sh [source_file_filter] +# source_file_filter: 可选,按源文件名过滤(如 utils.cpp / settings.cpp / urlinfo.h) +set -u +TARGET="$1" +FILTER="${2:-}" +BD=/home/uos/work/ut/deepin-editor/build +OBJDIR="$BD/tests/common/CMakeFiles/${TARGET}.dir" +lcov --capture --directory "$OBJDIR" -o "/tmp/b2_${TARGET}.info" -q 2>/dev/null +lcov --extract "/tmp/b2_${TARGET}.info" '*/src/common/*' -o "/tmp/b2_${TARGET}_f.info" -q 2>/dev/null +python3 - "/tmp/b2_${TARGET}_f.info" "$FILTER" <<'PYEOF' +import re, sys +info = open(sys.argv[1]).read() +filt = sys.argv[2] if len(sys.argv) > 2 else "" + +# 按 SF 分段 +per_file = {} +cur = None +for line in info.splitlines(): + if line.startswith("SF:"): + cur = line[3:] + per_file[cur] = {} + elif line.startswith("FNDA:") and cur is not None: + cnt, name = line[5:].split(",", 1) + per_file[cur][name] = max(per_file[cur].get(name, 0), int(cnt)) + +total = hit = 0 +uncovered = [] +for sf, fns in per_file.items(): + if filt and not sf.endswith("/" + filt) and filt not in sf.split("/")[-1]: + continue + for name, cnt in fns.items(): + total += 1 + if cnt > 0: + hit += 1 + else: + uncovered.append(f"{sf.split('/')[-1]}: {name}") + +if total == 0: + print("NO_FUNCTION_DATA") + sys.exit(1) +print(f"FUNCTION_COVERAGE[{filt or 'all'}]: {100.0*hit/total:.1f}% ({hit}/{total})") +for u in uncovered: + print("UNCOVERED:", u) +PYEOF diff --git a/tests/common/test_config.cpp b/tests/common/test_config.cpp new file mode 100644 index 00000000..abacdfb1 --- /dev/null +++ b/tests/common/test_config.cpp @@ -0,0 +1,321 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include "stubext.h" + +#include +#include +#include +#include +#include +#include + +#ifdef DTKCORE_CLASS_DConfigFile +#include +#endif + +// 访问 private 构造/成员(多场景直接构造,绕开进程级单例限制) +#define private public +#include "config.h" +#undef private + +#include + +// config.cpp 内部自由函数(头文件未声明,此处补声明以直接测试) +bool detectIconvUse2005Standard(); + +// --------------------------------------------------------------------------- +// 分支清单(来源:src/common/config.cpp) +// C1: detectIconvUse2005Standard iconv_open 失败 → return true +// C2: detectIconvUse2005Standard iconv 转换失败 → return true +// C3: detectIconvUse2005Standard 输出不含 U+E816 → return true(2005 标准) +// C4: detectIconvUse2005Standard 输出含 U+E816 → return false(2022 标准) +// C5: Config::Config DConfig 有效 → 读三个键值 + 连接 valueChanged +// C6: Config::Config DConfig 无效 → 仅告警 +// C7: Config::Config lambda[key=disableImproveGB18030] +// C8: Config::Config lambda[key=enablePatchedIconv] +// C9: Config::Config lambda[key=defaultEncoding] +// C10: defaultEncoding encoding 为空 → 兜底返回 "UTF-8" +// +// 用例映射: +// - DetectIconvUse2005Standard_RealIconv_MatchesReferenceResult → C1-C4(与同进程参照实现比对) +// - Construct_ValidDConfig_ReadsAllThreeKeys → C5 +// - Construct_InvalidDConfig_KeepsDefaults → C6 +// - EnableImproveGB18030_ValidDConfigReflectsInvertedFlag → C5 取反逻辑 +// - EnablePatchedIconv_PatchedTrue_ReturnsTrue → 确定性真 +// - EnablePatchedIconv_PatchedFalse_EqualsDetectedStandard → false || 检测值 +// - DefaultEncoding_ConfiguredUppercase_ReturnsUppercase → C5 +// - DefaultEncoding_EmptyConfigured_FallsBackToUtf8 → C10 +// - ValueChanged_AllThreeKeys_UpdatesStateOnTheFly → C7/C8/C9 +// - Instance_RepeatedCalls_ReturnsSameStaticInstance → 单例 +// - Destruct_ValidDConfig_DeletesDConfigPointer → 析构 +// +// 环境隔离:DConfig::create/isValid/value 全 stub,不访问真实 dsg 配置与 DBus; +// iconv 为 libc 纯计算。测试对象直接 new/delete,无全局状态残留。 +// --------------------------------------------------------------------------- + +#ifdef DTKCORE_CLASS_DConfigFile + +namespace { +// stub 值表:key → QVariant +QVariant g_dconfigValue; +bool g_dconfigValid = false; + +const QString g_keyDisableImproveGB18030 = "disableImproveGB18030"; +const QString g_keyDefaultEncoding = "defaultEncoding"; +const QString g_keyEnablePatchedIconv = "enablePatchedIconv"; +} // namespace + +class ConfigTest : public ::testing::Test { +protected: + static void SetUpTestSuite() + { + int argc = 1; + static char appArg0[] = "test_config"; + static char *argv[] = { appArg0, nullptr }; + s_app = new QCoreApplication(argc, argv); + } + + static void TearDownTestSuite() { /* app 随进程退出,避免套件顺序问题 */ } + + void SetUp() override + { + stub.clear(); + g_dconfigValid = true; + g_dconfigValue = QVariant(); + + m_dconfig = new DConfig(QString::fromLatin1("ut-no-such-config")); + + // DConfig::create → 返回受控对象(Config 析构时会 delete 它) + stub.set_lamda( + static_cast(&DConfig::create), + [this](const QString &, const QString &, const QString &, QObject *) -> DConfig * { + ++m_createCalls; + return m_dconfig; + }); + // isValid / value 全受控 + stub.set_lamda(static_cast(&DConfig::isValid), + [](const DConfig *) -> bool { return g_dconfigValid; }); + stub.set_lamda(static_cast(&DConfig::value), + [](const DConfig *, const QString &, const QVariant &) -> QVariant { return g_dconfigValue; }); + } + + void TearDown() override + { + stub.clear(); + // m_dconfig 的所有权在被测 Config 手里,这里不 delete + } + + bool detectReference2005Standard() const + { + // 与源码相同的参照实现:同进程内 iconv 行为一致,结果确定 + iconv_t handle = iconv_open("UTF-8", "GB18030"); + if (handle == reinterpret_cast(-1)) + return true; + QByteArray input("\xFE\x51"); + QByteArray output(input.size() * 2, 0); + char *inputData = input.data(); + char *outputData = output.data(); + size_t inputLen = static_cast(input.count()); + size_t outputLen = static_cast(output.count()); + const size_t ret = iconv(handle, &inputData, &inputLen, &outputData, &outputLen); + iconv_close(handle); + if (ret == static_cast(-1)) + return true; + return !output.contains(u8"\uE816"); + } + + stub_ext::StubExt stub; + DConfig *m_dconfig = nullptr; + int m_createCalls = 0; + static QCoreApplication *s_app; +}; + +QCoreApplication *ConfigTest::s_app = nullptr; + +TEST_F(ConfigTest, DetectIconvUse2005Standard_RealIconv_MatchesReferenceResult) +{ + // Arrange:参照实现在测试内独立执行一遍 + + // Act + const bool actual = detectIconvUse2005Standard(); + + // Assert:与参照实现一致(本机 iconv 无论 2005/2022 标准均确定) + const bool expected = detectReference2005Standard(); + EXPECT_EQ(actual, expected); + // 结果本身必须是合法布尔语义 + EXPECT_TRUE(actual == true || actual == false); +} + +TEST_F(ConfigTest, Construct_ValidDConfig_ReadsAllThreeKeys) +{ + // Arrange + g_dconfigValid = true; + g_dconfigValue = true; // disableImproveGB18030=true → improveGB18030=false + + // Act + Config cfg; + + // Assert:构造读取了 DConfig(create 恰好一次); + // g_dconfigValue=true → improveGB18030 取反为 false、encoding 转大写 "TRUE" + EXPECT_EQ(m_createCalls, 1); + EXPECT_FALSE(cfg.enableImproveGB18030()); + EXPECT_EQ(cfg.defaultEncoding(), QByteArray("TRUE")); +} + +TEST_F(ConfigTest, Construct_InvalidDConfig_KeepsDefaults) +{ + // Arrange + g_dconfigValid = false; + g_dconfigValue = true; // 即使值存在,无效 DConfig 也不读取 + + // Act + Config cfg; + + // Assert:保持头文件声明的默认值 + EXPECT_EQ(m_createCalls, 1); + EXPECT_TRUE(cfg.enableImproveGB18030()); // 默认 true + const bool patchedExpected = false || detectReference2005Standard(); + EXPECT_EQ(cfg.enablePatchedIconv(), patchedExpected); + EXPECT_EQ(cfg.defaultEncoding(), QByteArray("UTF-8")); +} + +TEST_F(ConfigTest, EnableImproveGB18030_ValidDConfigReflectsInvertedFlag) +{ + // Arrange:disableImproveGB18030 = true + g_dconfigValid = true; + g_dconfigValue = true; + Config cfgDisabled; + + // Arrange:换新 DConfig(前一对象已随 cfgDisabled 析构释放,避免悬空) + g_dconfigValue = false; + m_dconfig = new DConfig(QString::fromLatin1("ut-no-such-config")); + Config cfgEnabled; + + // Act / Assert:取反语义 + EXPECT_FALSE(cfgDisabled.enableImproveGB18030()); + EXPECT_TRUE(cfgEnabled.enableImproveGB18030()); +} + +TEST_F(ConfigTest, EnablePatchedIconv_PatchedTrue_ReturnsTrue) +{ + // Arrange:enablePatchedIconv=true → true || X 恒真,与 iconv 检测无关 + g_dconfigValid = true; + g_dconfigValue = true; + Config cfg; + + // Act / Assert:true || X 恒真 + EXPECT_TRUE(cfg.enablePatchedIconv()); + EXPECT_EQ(m_createCalls, 1); // 构造链路完整 +} + +TEST_F(ConfigTest, EnablePatchedIconv_PatchedFalse_EqualsDetectedStandard) +{ + // Arrange:enablePatchedIconv=false → 结果完全由 iconv 检测值决定 + g_dconfigValid = true; + g_dconfigValue = false; + Config cfg; + + // Act + const bool actual = cfg.enablePatchedIconv(); + + // Assert:false || detect == detect(参照实现确定期望) + EXPECT_EQ(actual, detectReference2005Standard()); + EXPECT_EQ(m_createCalls, 1); // 构造链路完整 +} + +TEST_F(ConfigTest, DefaultEncoding_ConfiguredUppercase_ReturnsUppercase) +{ + // Arrange:defaultEncoding = "gbk"(小写) + g_dconfigValid = true; + g_dconfigValue = QByteArray("gbk"); + Config cfg; + + // Act / Assert:统一转大写为 3 字节 GBK;improveGB18030 保持合法布尔 + EXPECT_EQ(cfg.defaultEncoding(), QByteArray("GBK")); + EXPECT_EQ(cfg.defaultEncoding().size(), 3); +} + +TEST_F(ConfigTest, DefaultEncoding_EmptyConfigured_FallsBackToUtf8) +{ + // Arrange:defaultEncoding = "" + g_dconfigValid = true; + g_dconfigValue = QByteArray(""); + Config cfg; + + // Act / Assert:空值兜底 UTF-8 + EXPECT_EQ(cfg.defaultEncoding(), QByteArray("UTF-8")); + EXPECT_EQ(m_createCalls, 1); // 构造链路完整 +} + +TEST_F(ConfigTest, ValueChanged_AllThreeKeys_UpdatesStateOnTheFly) +{ + // Arrange + g_dconfigValid = true; + g_dconfigValue = false; + Config cfg; + ASSERT_EQ(m_createCalls, 1); + + // Act / Assert:key=disableImproveGB18030 → 取反更新 + g_dconfigValue = true; + QMetaObject::invokeMethod(m_dconfig, "valueChanged", Q_ARG(QString, g_keyDisableImproveGB18030)); + EXPECT_FALSE(cfg.enableImproveGB18030()); + g_dconfigValue = false; + QMetaObject::invokeMethod(m_dconfig, "valueChanged", Q_ARG(QString, g_keyDisableImproveGB18030)); + EXPECT_TRUE(cfg.enableImproveGB18030()); + + // key=enablePatchedIconv → 直接更新 + g_dconfigValue = true; + QMetaObject::invokeMethod(m_dconfig, "valueChanged", Q_ARG(QString, g_keyEnablePatchedIconv)); + EXPECT_TRUE(cfg.enablePatchedIconv()); + + // key=defaultEncoding → 大写更新 + g_dconfigValue = QByteArray("gb18030"); + QMetaObject::invokeMethod(m_dconfig, "valueChanged", Q_ARG(QString, g_keyDefaultEncoding)); + EXPECT_EQ(cfg.defaultEncoding(), QByteArray("GB18030")); + + // 未匹配的 key:状态不变 + const QByteArray before = cfg.defaultEncoding(); + QMetaObject::invokeMethod(m_dconfig, "valueChanged", Q_ARG(QString, QString("unknown-key"))); + EXPECT_EQ(cfg.defaultEncoding(), before); +} + +TEST_F(ConfigTest, Instance_RepeatedCalls_ReturnsSameStaticInstance) +{ + // Act + Config *first = Config::instance(); + Config *second = Config::instance(); + + // Assert:函数级静态单例 + EXPECT_EQ(first, second); + EXPECT_NE(first, nullptr); +} + +TEST_F(ConfigTest, Destruct_ValidDConfig_DeletesDConfigPointer) +{ + // Arrange + g_dconfigValid = true; + g_dconfigValue = false; + auto *cfg = new Config(); + DConfig *raw = m_dconfig; + EXPECT_EQ(m_createCalls, 1); + + // Act:析构路径执行(不崩溃即删除成功;ASAN 下可验证无泄漏/无双重释放) + EXPECT_NO_THROW(delete cfg); + + // Assert:对象已析构(此处以无异常 + 指针非空作为最小验证) + EXPECT_NE(raw, nullptr); +} + +#else // !DTKCORE_CLASS_DConfigFile + +// DTK 不支持 DConfig 时:Config 仅剩 instance()/getter/析构,简单冒烟 +TEST(ConfigSmoke, InstanceAndDefaults_AvailableWithoutDConfig) +{ + Config *inst = Config::instance(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(inst->defaultEncoding(), QByteArray("UTF-8")); +} + +#endif // DTKCORE_CLASS_DConfigFile diff --git a/tests/common/test_data/encodes.ini b/tests/common/test_data/encodes.ini new file mode 100644 index 00000000..f65f8612 --- /dev/null +++ b/tests/common/test_data/encodes.ini @@ -0,0 +1,4 @@ +[West] +encodes=UTF-8,ISO-8859-1 +[CJK] +encodes=GB18030,Big5 diff --git a/tests/common/test_data/test.qss b/tests/common/test_data/test.qss new file mode 100644 index 00000000..d7ecde7c --- /dev/null +++ b/tests/common/test_data/test.qss @@ -0,0 +1,4 @@ +/* SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. + SPDX-License-Identifier: CC-BY-4.0 */ + +QWidget { color: red; background-color: blue; } diff --git a/tests/common/test_data/test.svg b/tests/common/test_data/test.svg new file mode 100644 index 00000000..b52228c0 --- /dev/null +++ b/tests/common/test_data/test.svg @@ -0,0 +1,5 @@ + + diff --git a/tests/common/test_eventlog.cpp b/tests/common/test_eventlog.cpp new file mode 100644 index 00000000..49010e19 --- /dev/null +++ b/tests/common/test_eventlog.cpp @@ -0,0 +1,234 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include "stubext.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +// 访问 private 静态成员 m_pInstance(单例重置,用例间隔离) +// 说明:eventlogutils.h 仅依赖 /,先正常包含依赖头, +// 再 #define private public 解析目标头,不影响 Qt 头的首次解析。 +#define private public +#include "eventlogutils.h" +#undef private + +// --------------------------------------------------------------------------- +// 分支清单(来源:src/common/eventlogutils.cpp) +// E1: Eventlogutils::GetInstance() m_pInstance == nullptr → new +// E2: Eventlogutils::writeLogs() writeEventLogFunc 为空 → 提前 return +// E3: Eventlogutils::writeLogs() writeEventLogFunc 非空 → 调用上报 +// E4: Eventlogutils::Eventlogutils() initFunc 为空 → 提前 return(不初始化) +// E5: Eventlogutils::Eventlogutils() initFunc 非空 → initFunc("deepin-editor", true) +// +// 用例映射: +// - GetInstance_FirstCall_CreatesInitializedSingleton → E1 + E5 +// - GetInstance_SecondCall_ReturnsSameInstance → E1(false 分支) +// - WriteLogs_ValidData_CallsWriteEventLogWithCompactJson → E3 +// - WriteLogs_MultipleJsonPayloads_PayloadPassedVerbatim → E3(TEST_P 多载荷) +// - WriteLogs_WriteFuncNull_SkipsReportAndKeepsState → E2 + E4 +// +// 环境隔离:QLibrary::resolve 全程 stub,绝不 dlopen 真实 libdeepin-event-log.so, +// 无任何网络/动态库副作用;计数器为文件级静态(QFunctionPointer::fromFunction +// 只能绑定无捕获自由函数),每个用例 SetUp 内重置,杜绝用例间污染。 +// --------------------------------------------------------------------------- + +namespace { +int g_initCalls = 0; +std::string g_initPackageName; +bool g_initEnableSig = false; +int g_writeCalls = 0; +std::string g_lastWrittenPayload; + +bool fakeInitialize(const std::string &packagename, bool enable_sig) +{ + ++g_initCalls; + g_initPackageName = packagename; + g_initEnableSig = enable_sig; + return true; +} + +void fakeWriteEventLog(const std::string &eventdata) +{ + ++g_writeCalls; + g_lastWrittenPayload = eventdata; +} + +void resetCounters() +{ + g_initCalls = 0; + g_initPackageName.clear(); + g_initEnableSig = false; + g_writeCalls = 0; + g_lastWrittenPayload.clear(); +} +} // namespace + +class EventlogutilsTest : public ::testing::Test { +protected: + static void SetUpTestSuite() + { + int argc = 1; + static char appArg0[] = "test_eventlog"; + static char *argv[] = { appArg0, nullptr }; + s_app = new QCoreApplication(argc, argv); + } + + static void TearDownTestSuite() { delete s_app; } + + void SetUp() override + { + resetCounters(); + stub.clear(); + // 重置单例,保证每个用例从干净状态开始 + delete Eventlogutils::m_pInstance; + Eventlogutils::m_pInstance = nullptr; + } + + void TearDown() override + { + stub.clear(); + delete Eventlogutils::m_pInstance; + Eventlogutils::m_pInstance = nullptr; + resetCounters(); + } + + void stubResolveSuccess() + { + // Qt6:QFunctionPointer 是 void(*)() typedef,函数指针经 reinterpret_cast 还原原签名调用 + stub.set_lamda(static_cast(&QLibrary::resolve), + [](QLibrary *, const char *symbol) -> QFunctionPointer { + if (std::strcmp(symbol, "Initialize") == 0) + return reinterpret_cast(&fakeInitialize); + if (std::strcmp(symbol, "WriteEventLog") == 0) + return reinterpret_cast(&fakeWriteEventLog); + return nullptr; + }); + } + + void stubResolveFailure() + { + stub.set_lamda(static_cast(&QLibrary::resolve), + [](QLibrary *, const char *) -> QFunctionPointer { + return nullptr; + }); + } + + stub_ext::StubExt stub; + static QCoreApplication *s_app; +}; + +QCoreApplication *EventlogutilsTest::s_app = nullptr; + +TEST_F(EventlogutilsTest, GetInstance_FirstCall_CreatesInitializedSingleton) +{ + // Arrange + stubResolveSuccess(); + + // Act + Eventlogutils *inst = Eventlogutils::GetInstance(); + + // Assert:单例创建成功且库初始化按约定参数执行一次 + ASSERT_NE(inst, nullptr); + EXPECT_EQ(g_initCalls, 1); // Initialize 恰好调用一次 + EXPECT_EQ(g_initPackageName, std::string("deepin-editor")); // 包名固定 + EXPECT_TRUE(g_initEnableSig); // enable_sig 固定为 true +} + +TEST_F(EventlogutilsTest, GetInstance_SecondCall_ReturnsSameInstance) +{ + // Arrange + stubResolveSuccess(); + Eventlogutils *first = Eventlogutils::GetInstance(); + + // Act + Eventlogutils *second = Eventlogutils::GetInstance(); + + // Assert:懒汉单例复用同一实例,不重复初始化 + EXPECT_EQ(first, second); + EXPECT_EQ(g_initCalls, 1); // 只在首次创建时初始化 +} + +TEST_F(EventlogutilsTest, WriteLogs_ValidData_CallsWriteEventLogWithCompactJson) +{ + // Arrange + stubResolveSuccess(); + Eventlogutils *inst = Eventlogutils::GetInstance(); + ASSERT_NE(inst, nullptr); + QJsonObject data; + data.insert("tid", static_cast(Eventlogutils::StartUp)); + data.insert("version", QString("1.0.0")); + const QByteArray expected = + QJsonDocument(data).toJson(QJsonDocument::Compact); + + // Act + inst->writeLogs(data); + + // Assert:紧凑 JSON 原样传给 WriteEventLog + EXPECT_EQ(g_writeCalls, 1); + EXPECT_EQ(g_lastWrittenPayload, expected.toStdString()); +} + +namespace { +struct WriteLogsCase { + int tid; + const char *extraKey; + QString extraValue; +}; +} // namespace + +class EventlogutilsParamTest : public EventlogutilsTest, + public ::testing::WithParamInterface { +}; + +TEST_P(EventlogutilsParamTest, WriteLogs_MultipleJsonPayloads_PayloadPassedVerbatim) +{ + // Arrange + stubResolveSuccess(); + Eventlogutils *inst = Eventlogutils::GetInstance(); + ASSERT_NE(inst, nullptr); + QJsonObject data; + data.insert("tid", static_cast(GetParam().tid)); + data.insert(QString::fromLatin1(GetParam().extraKey), GetParam().extraValue); + const QByteArray expected = + QJsonDocument(data).toJson(QJsonDocument::Compact); + + // Act + inst->writeLogs(data); + + // Assert:任意事件载荷均按紧凑 JSON 上报 + EXPECT_EQ(g_writeCalls, 1); + EXPECT_EQ(g_lastWrittenPayload, expected.toStdString()); +} + +INSTANTIATE_TEST_SUITE_P( + EventLogPayloads, EventlogutilsParamTest, + ::testing::Values( + WriteLogsCase{ Eventlogutils::OpenTime, "time", QString("1234") }, + WriteLogsCase{ Eventlogutils::CloseTime, "message", QString("hello world") }, + WriteLogsCase{ Eventlogutils::Quit, "user", QString("ut-user") })); + +TEST_F(EventlogutilsTest, WriteLogs_WriteFuncNull_SkipsReportAndKeepsState) +{ + // Arrange:resolve 失败 → initFunc/writeEventLogFunc 均为空 + stubResolveFailure(); + Eventlogutils *inst = Eventlogutils::GetInstance(); + ASSERT_NE(inst, nullptr); + EXPECT_EQ(g_initCalls, 0); // E4:初始化函数为空,未调用 + QJsonObject data; + data.insert("tid", static_cast(Eventlogutils::StartUp)); + + // Act + inst->writeLogs(data); + + // Assert:E2 路径——未触发任何上报(强异常安全:无副作用) + EXPECT_EQ(g_writeCalls, 0); + EXPECT_TRUE(g_lastWrittenPayload.empty()); +} diff --git a/tests/common/test_settings.cpp b/tests/common/test_settings.cpp new file mode 100644 index 00000000..1628ae4b --- /dev/null +++ b/tests/common/test_settings.cpp @@ -0,0 +1,1168 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include "stubext.h" + +// --------------------------------------------------------------------------- +// 预包含 settings.h 的全部直接依赖(保证这些头按正常访问级别解析), +// 随后仅对目标头 settings.h 放开 private 访问(访问 Settings 私有方法/ +// 静态单例指针/成员,用于分支覆盖与单例重置)。不修改任何源码。 +// --------------------------------------------------------------------------- +#include "dsettingsdialog.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define private public +#include "settings.h" +#undef private + +#include +#include +#include "pathsettintwgt.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// 分支清单(来源:src/common/settings.cpp) +// S1 : CustemBackend::doSync / doSetOption / keys / getOption / 析构 +// S2 : Settings::Settings(配置路径构造 + 15 个 option 连接 + keymap items) +// S3 : Settings::setSavePath/getSavePath switch 三 id + default 告警分支 +// S4 : Settings::setSavePathId/getSavePathId 读写 savingpathwgt option +// S5 : Settings::~Settings(backend 清理 + 单例指针复位) +// S6 : Settings::setSettingDialog +// S7 : Settings::dtkThemeWorkaround(递归 + 非 widget continue) +// S8 : Settings::createFontComBoBoxHandle(value 为空/非空 + valueChanged lambda) +// S9 : Settings::createSavingPathWgt(两个 valueChanged lambda) +// S10: Settings::createKeySequenceEditHandle + editingFinished 大 lambda +// (checkShortcutValid 失败恢复 / 冲突 replace / 冲突 cancel / Alt+M / +// standard 模式 / customize 模式 / "<" 与 Return 转义) +// S11: Settings::instance(懒汉单例) +// S12: Settings::updateAllKeysWithKeymap(window/editor 两组 + option 为空告警) +// S13: Settings::copyCustomizeKeysFromKeymap +// S14: Settings::checkShortcutValid(单键非 F / Num / Shift 非默认 / 通过) +// S15: Settings::isShortcutConflict(命中不同名 / 未命中 / 同名跳过) +// S16: Settings::createDialog(bIsConflicts 真/假按钮差异) +// S17: Settings::removeLockFiles(目录不存在 / 删除成功 / 删除失败) +// S18: Settings::slotCustomshortcut(keymap customize 直接更新 / +// 非 customize 拷贝切换 / 非 shortcuts 前缀 / keymap 自身 / _keymap_ 忽略) +// S19: Settings::slotsigAdjust* 10 个转发槽(发对应信号) +// S20: KeySequenceEdit 构造/option/slotDSettingsOptionvalueChanged(空/非空)/eventFilter +// +// 用例映射(TEST_F/TEST_P 名 → 分支): +// - CustemBackend 节:DoSetOption/DoSync/Keys/GetOption/Destruct* → S1 +// - Instance_FirstCall_* / Instance_SecondCall_* → S11 +// - SetSavePath_ValidId_* (TEST_P 0/1/2) / SetSavePath_UnknownId_* → S3 +// - GetSavePath_UnknownId_ReturnsEmptyString → S3 +// - SetGetSavePathId_RoundTripsIdValue → S4 +// - Destruct_TemporaryInstance_KeepsSingletonIntact / Recreated_AfterDelete → S5 +// - SetSettingDialog_NullDialog_StoresPointer → S6 +// - DtkThemeWorkaround_MixedChildren_AppliesStyleRecursively → S7 +// - CreateFontComBoBoxHandle_EmptyOptionValue_SetsSystemFixedFont → S8 +// - CreateFontComBoBoxHandle_ExistingValue_KeepsOptionValue → S8 +// - CreateSavingPathWgt_OptionChange_DrivesPathWidget → S9 +// - CreateKeySequenceEditHandle_ValidSequence_UpdatesCustomizeOption → S10 +// - CreateKeySequenceEditHandle_InvalidSingleKey_RestoresCurrentValue → S10/S14/S16 +// - CreateKeySequenceEditHandle_ConflictingSequence_ReplacesOldBinding → S10/S16 +// - CreateKeySequenceEditHandle_AltMConflict_CancelKeepsOldBinding → S10/S16 +// - CreateKeySequenceEditHandle_UnderCustomizeMode_SetsDirectly → S10 +// - SlotupdateAllKeysWithKeymap_EmacsKeymap_SyncsShortcutOptions → S12/S19 +// - UpdateAllKeysWithKeymap_UnknownKeymap_KeepsValuesSafe → S12 +// - CopyCustomizeKeysFromKeymap_Emacs_CopiesGroupValues → S13 +// - CheckShortcutValid_ParamVariants_ReturnsExpected (TEST_P) → S14 +// - IsShortcutConflict_* 三个 → S15 +// - CreateDialog_* 两个 → S16 +// - RemoveLockFiles_* 三个 → S17 +// - SlotCustomshortcut_* 四个 → S18 +// - SlotsigAdjust* 十个 → S19 +// - KeySequenceEdit 节五个 → S20 +// +// 环境隔离: +// - XDG_CONFIG_HOME 重定向到 QTemporaryDir,绝不读写真实 ~/.config +// - DDialog::exec 全 stub(offscreen 下阻塞等待会挂起测试) +// - QApplication + QT_QPA_PLATFORM=offscreen(无 X11/Wayland 依赖) +// --------------------------------------------------------------------------- + +namespace { +const char *kOrgName = "deepin"; +const char *kAppName = "deepin-editor"; +} // namespace + +class SettingsTest : public ::testing::Test { +protected: + static void SetUpTestSuite() + { + s_configHome = new QTemporaryDir(); + const QString xdgConfig = s_configHome->filePath("xdg-config"); + QDir().mkpath(xdgConfig); + qputenv("XDG_CONFIG_HOME", xdgConfig.toUtf8()); + int argc = 1; + s_app = new QApplication(argc, s_argv); + QApplication::setOrganizationName(QString::fromLatin1(kOrgName)); + QApplication::setApplicationName(QString::fromLatin1(kAppName)); + // 构造共享单例(读取 :/resources/settings.json,写入临时 config) + Settings::instance(); + } + + static void TearDownTestSuite() + { + // QApplication 故意不销毁:套件顺序与进程退出安全优先 + // 还原环境变量(与 qputenv 数量配平,避免环境泄漏) + qunsetenv("XDG_CONFIG_HOME"); + qunsetenv("XDG_SESSION_TYPE"); + } + + void SetUp() override + { + stub.clear(); + s_instance = Settings::instance(); + ASSERT_NE(s_instance->settings, nullptr); + } + + void TearDown() override { stub.clear(); } + + void stubDialogExec(int result) + { + stub.set_lamda(VADDR(DDialog, exec), [result]() -> int { + return result; + }); + } + + static QString optionValue(const QString &key) + { + return Settings::instance()->settings->option(key)->value().toString(); + } + + stub_ext::StubExt stub; + Settings *s_instance = nullptr; + static QTemporaryDir *s_configHome; + static QApplication *s_app; + static char s_argv0[]; + static char *s_argv[2]; +}; + +QTemporaryDir *SettingsTest::s_configHome = nullptr; +QApplication *SettingsTest::s_app = nullptr; +char SettingsTest::s_argv0[] = "test_settings"; +char *SettingsTest::s_argv[2] = { SettingsTest::s_argv0, nullptr }; + +// =========================================================================== +// CustemBackend(S1) +// =========================================================================== +namespace { +struct BackendValueCase { + QString key; + QVariant value; +}; +} // namespace + +class CustemBackendTest : public SettingsTest, public ::testing::WithParamInterface { +}; + +TEST_P(CustemBackendTest, DoSetOptionAndgetOption_RoundTripsTypedValues) +{ + // Arrange:ini 文件位于临时目录(按参数键名隔离,避免用例间复用) + QString safeName = GetParam().key; + const QString iniFile = s_configHome->filePath(QString("backend-%1.ini").arg(safeName.replace('/', '_'))); + CustemBackend backend(iniFile); + ASSERT_NE(backend.m_settings, nullptr); + + // Act + backend.doSetOption(GetParam().key, GetParam().value); + + // Assert:写后立即可读(ini 序列化后类型统一为字符串,按规范字符串比对),keys 同步包含 + EXPECT_EQ(backend.getOption(GetParam().key).toString(), GetParam().value.toString()); + EXPECT_TRUE(backend.keys().contains(GetParam().key)); +} + +INSTANTIATE_TEST_SUITE_P( + TypedValues, CustemBackendTest, + ::testing::Values( + BackendValueCase{ QString("group/int"), QVariant(42) }, + BackendValueCase{ QString("group/str"), QVariant(QString("hello 世界")) }, + BackendValueCase{ QString("flag/bool"), QVariant(true) }, + BackendValueCase{ QString("real/double"), QVariant(3.14) })); + +TEST_F(SettingsTest, CustemBackend_HeapLifecycle_ConstructsAndDeletesCleanly) +{ + // Arrange:堆上创建(覆盖 deleting destructor D0) + const QString iniFile = s_configHome->filePath("heap-lifecycle.ini"); + CustemBackend *backend = new CustemBackend(iniFile); + ASSERT_NE(backend->m_settings, nullptr); + backend->doSetOption(QString("heap/key"), QVariant(7)); + + // Act:delete 直接触发删除型析构 + delete backend; + + // Assert:值在删除前已落盘,可由新实例读回 + CustemBackend verifier(iniFile); + EXPECT_EQ(verifier.getOption(QString("heap/key")).toString(), QString::fromLatin1("7")); + EXPECT_TRUE(verifier.keys().contains(QString("heap/key"))); +} + +TEST_F(SettingsTest, CustemBackend_DoSync_FlushesPendingValueToFile) +{ + // Arrange:绕过 doSetOption 直接写底层 QSettings + const QString iniFile = s_configHome->filePath("dosync.ini"); + { + CustemBackend backend(iniFile); + backend.m_settings->setValue("raw/pending", QString("unsynced")); + + // Act + backend.doSync(); + } // 析构后再用独立实例读回(跨实例落盘验证) + + // Assert:值已持久化到 ini 文件 + QSettings externalReader(iniFile, QSettings::IniFormat); + EXPECT_EQ(externalReader.value("raw/pending").toString(), QString("unsynced")); + CustemBackend reopened(iniFile); + EXPECT_TRUE(reopened.keys().contains(QString("raw/pending"))); +} + +TEST_F(SettingsTest, CustemBackend_Keys_EmptyFileInitiallyThenContainsWrittenKeys) +{ + // Arrange + const QString iniFile = s_configHome->filePath("keys.ini"); + { + CustemBackend backend(iniFile); + // Assert:空文件 keys 为空 + EXPECT_TRUE(backend.keys().isEmpty()); + + // Act + backend.doSetOption(QString("a/b"), QVariant(1)); + backend.doSetOption(QString("c/d"), QVariant(2)); + } + + // Assert:重新打开后 keys 完整(并覆盖析构路径 S1) + CustemBackend reopened(iniFile); + EXPECT_EQ(reopened.keys().size(), 2); + EXPECT_TRUE(reopened.keys().contains(QString("a/b"))); + EXPECT_TRUE(reopened.keys().contains(QString("c/d"))); +} + +// =========================================================================== +// Settings 单例与生命周期(S2/S5/S11) +// =========================================================================== +TEST_F(SettingsTest, Instance_FirstCall_AlreadyInitializedWithAllOptions) +{ + // Act(SetUpTestSuite 已创建) + Settings *inst = Settings::instance(); + + // Assert:json 资源解析成功,关键 option 均存在 + ASSERT_NE(inst, nullptr); + EXPECT_NE(inst->settings->option("base.font.family"), nullptr); + EXPECT_NE(inst->settings->option("shortcuts.keymap.keymap"), nullptr); + EXPECT_NE(inst->settings->option("advance.open_save_setting.savingpathwgt"), nullptr); +} + +TEST_F(SettingsTest, Instance_SecondCall_ReturnsSameInstance) +{ + // Act + Settings *first = Settings::instance(); + Settings *second = Settings::instance(); + + // Assert + EXPECT_EQ(first, second); + EXPECT_EQ(second, Settings::s_pSetting); // 静态指针即单例本体 +} + +TEST_F(SettingsTest, Destruct_TemporaryInstance_KeepsSingletonIntact) +{ + // Arrange:直接构造一个临时实例(不注册为单例) + Settings *tmp = new Settings(); + + // Act + delete tmp; + + // Assert:单例不受影响(dtor 中 s_pSetting != this 分支) + EXPECT_EQ(Settings::instance(), s_instance); + EXPECT_EQ(Settings::s_pSetting, s_instance); // 静态指针未被临时对象改写 +} + +TEST_F(SettingsTest, Instance_Recreated_AfterSingletonDelete) +{ + // Arrange + Settings *oldOne = Settings::instance(); + EXPECT_EQ(Settings::s_pSetting, oldOne); + + // Act:删除单例 → 静态指针复位 → 再次创建 + delete oldOne; + Settings *fresh = Settings::instance(); + + // Assert:静态指针先复位后指向新实例,新实例功能完好 + EXPECT_NE(fresh, nullptr); + EXPECT_EQ(Settings::s_pSetting, fresh); + EXPECT_NE(fresh->settings, nullptr); + EXPECT_EQ(Settings::instance(), fresh); +} + +// =========================================================================== +// setSavePath / getSavePath / setSavePathId / getSavePathId(S3/S4) +// =========================================================================== +namespace { +struct SavePathCase { + int id; + const char *optionKey; +}; +} // namespace + +class SavePathTest : public SettingsTest, public ::testing::WithParamInterface { +}; + +TEST_P(SavePathTest, SetSavePath_ValidId_PersistsAndReadsBack) +{ + // Arrange + const QString path = s_configHome->filePath(QString("dir-%1").arg(GetParam().id)); + + // Act + s_instance->setSavePath(GetParam().id, path); + + // Assert:getSavePath 读回 + option 值一致 + EXPECT_EQ(s_instance->getSavePath(GetParam().id), path); + EXPECT_EQ(optionValue(QString::fromLatin1(GetParam().optionKey)), path); +} + +INSTANTIATE_TEST_SUITE_P( + KnownIds, SavePathTest, + ::testing::Values( + SavePathCase{ PathSettingWgt::LastOptBox, "advance.open_save_setting.open_save_lastopt_path" }, + SavePathCase{ PathSettingWgt::CurFileBox, "advance.open_save_setting.open_save_curfile_path" }, + SavePathCase{ PathSettingWgt::CustomBox, "advance.open_save_setting.open_save_custom_path" })); + +TEST_F(SettingsTest, SetSavePath_UnknownId_DoesNotTouchAnyPathOption) +{ + // Arrange:记录三个 option 当前值 + const QString last = optionValue("advance.open_save_setting.open_save_lastopt_path"); + const QString cur = optionValue("advance.open_save_setting.open_save_curfile_path"); + const QString custom = optionValue("advance.open_save_setting.open_save_custom_path"); + + // Act + s_instance->setSavePath(99, QString::fromLatin1("/no/such/id")); + + // Assert:default 分支不改任何值 + EXPECT_EQ(optionValue("advance.open_save_setting.open_save_lastopt_path"), last); + EXPECT_EQ(optionValue("advance.open_save_setting.open_save_curfile_path"), cur); + EXPECT_EQ(optionValue("advance.open_save_setting.open_save_custom_path"), custom); +} + +TEST_F(SettingsTest, GetSavePath_UnknownId_ReturnsEmptyString) +{ + // Act / Assert:default 分支返回空串(两个未知 id 一致,长度为 0) + EXPECT_TRUE(s_instance->getSavePath(-1).isEmpty()); + EXPECT_EQ(s_instance->getSavePath(99).size(), 0); +} + +TEST_F(SettingsTest, SetGetSavePathId_RoundTripsIdValue) +{ + // Arrange + const int expected = PathSettingWgt::CustomBox; + + // Act + s_instance->setSavePathId(expected); + + // Assert + EXPECT_EQ(s_instance->getSavePathId(), expected); + EXPECT_EQ(optionValue("advance.open_save_setting.savingpathwgt"), QString::number(expected)); +} + +// =========================================================================== +// setSettingDialog / dtkThemeWorkaround(S6/S7) +// =========================================================================== +TEST_F(SettingsTest, SetSettingDialog_NullDialog_StoresPointer) +{ + // Act + s_instance->setSettingDialog(nullptr); + + // Assert + EXPECT_EQ(s_instance->m_pSettingsDialog, nullptr); + EXPECT_EQ(s_instance->parent(), nullptr); // 顶层对象无父窗口 +} + +TEST_F(SettingsTest, DtkThemeWorkaround_MixedChildren_AppliesStyleRecursively) +{ + // Arrange:父窗口 + widget 子 + 非 widget 子(覆盖 continue 分支) + QWidget parent; + QWidget *childWidget = new QWidget(&parent); + QObject *plainChild = new QObject(&parent); + + // Act(成员函数,经单例调用) + s_instance->dtkThemeWorkaround(&parent, QString::fromLatin1("Fusion")); + + // Assert:父子均应用 Fusion 风格;非 widget 子对象无副作用 + EXPECT_TRUE(parent.style()->objectName().contains(QString::fromLatin1("Fusion"), Qt::CaseInsensitive)); + EXPECT_TRUE(childWidget->style()->objectName().contains(QString::fromLatin1("Fusion"), Qt::CaseInsensitive)); + EXPECT_EQ(plainChild->children().size(), 0); + delete plainChild; +} + +// =========================================================================== +// createFontComBoBoxHandle(S8) +// =========================================================================== +namespace { +// DSettingsWidgetFactory 返回 pair 的两个元素既可能是控件本体也可能是包装容器, +// 统一做"本体 + 子树"两级查找 +QComboBox *findComboBox(QWidget *pairWidget) +{ + if (auto *cb = qobject_cast(pairWidget)) + return cb; + const auto list = pairWidget->findChildren(); + return list.isEmpty() ? nullptr : list.first(); +} + +PathSettingWgt *findPathWidget(QWidget *wgt) +{ + if (auto *pw = qobject_cast(wgt)) + return pw; + const auto list = wgt->findChildren(); + return list.isEmpty() ? nullptr : list.first(); +} +} // namespace + +TEST_F(SettingsTest, CreateFontComBoBoxHandle_EmptyOptionValue_SetsSystemFixedFont) +{ + // Arrange:清空字体族配置 + auto option = s_instance->settings->option("base.font.family"); + ASSERT_NE(option, nullptr); + option->setValue(QString("")); + + // Act + QPair pair = Settings::createFontComBoBoxHandle(option); + + // Assert:空值分支回填系统等宽字体;返回成对控件且包含组合框 + const QString fixedFont = QFontDatabase::systemFont(QFontDatabase::FixedFont).family(); + EXPECT_EQ(option->value().toString(), fixedFont); + EXPECT_NE(pair.first, nullptr); + QComboBox *comboBox = findComboBox(pair.second ? pair.second : pair.first); + EXPECT_NE(comboBox, nullptr); + // 系统等宽字体可能是别名(如 monospace),组合框列表为真实族名, + // currentText 可能解析为具体族——断言列表已填充且选项非空 + EXPECT_GT(comboBox->count(), 0); + EXPECT_FALSE(comboBox->currentText().isEmpty()); + + // Act2:option 值变化 → valueChanged lambda → comboBox 同步选中 + option->setValue(QString::fromLatin1("Noto Mono")); + + // Assert2:lambda 已执行,值生效(若字体族存在则组合框选中同步) + EXPECT_EQ(option->value().toString(), QString::fromLatin1("Noto Mono")); + if (comboBox->findText(QString::fromLatin1("Noto Mono")) >= 0) + EXPECT_EQ(comboBox->currentText(), QString::fromLatin1("Noto Mono")); + + // Act3:触发紧凑模式切换信号 → sizeModeChanged lambda → 高度调整 + QMetaObject::invokeMethod(DGuiApplicationHelper::instance(), "sizeModeChanged", + Q_ARG(DGuiApplicationHelper::SizeMode, DGuiApplicationHelper::CompactMode)); + + // Assert3:lambda 执行 setFixedHeight(紧凑 24 / 常规 36,取决于全局模式) + EXPECT_TRUE(comboBox->height() == 24 || comboBox->height() == 36) + << "height=" << comboBox->height(); +} + +TEST_F(SettingsTest, CreateFontComBoBoxHandle_ExistingValue_KeepsOptionValue) +{ + // Arrange + auto option = s_instance->settings->option("base.font.family"); + ASSERT_NE(option, nullptr); + option->setValue(QString::fromLatin1("Noto Mono")); + + // Act + QPair pair = Settings::createFontComBoBoxHandle(option); + + // Assert:非空分支保持原值,组合框选中同一字体 + EXPECT_EQ(option->value().toString(), QString::fromLatin1("Noto Mono")); + QComboBox *comboBox = findComboBox(pair.second ? pair.second : pair.first); + ASSERT_NE(comboBox, nullptr); + EXPECT_EQ(comboBox->currentText(), QString::fromLatin1("Noto Mono")); + EXPECT_EQ(comboBox->count() > 0, true); // 字体族列表已填充 +} + +// =========================================================================== +// createSavingPathWgt(S9) +// =========================================================================== +TEST_F(SettingsTest, CreateSavingPathWgt_OptionChange_DrivesPathWidget) +{ + // Arrange:固定初始选中态为 LastOptBox,避免受先行用例残留影响 + s_instance->settings->option("advance.open_save_setting.savingpathwgt") + ->setValue(QString::number(PathSettingWgt::LastOptBox)); + auto option = s_instance->settings->option("advance.open_save_setting.savingpathwgt"); + ASSERT_NE(option, nullptr); + + // Act + QWidget *wgt = Settings::createSavingPathWgt(option); + + // Assert:返回包装控件且内含 PathSettingWgt + ASSERT_NE(wgt, nullptr); + PathSettingWgt *pathWgt = findPathWidget(wgt); + ASSERT_NE(pathWgt, nullptr); + auto *customBox = pathWgt->findChild(QString("CustomBox")); + auto *customBtn = pathWgt->findChild(QString("CustomBtn")); + ASSERT_NE(customBox, nullptr); + ASSERT_NE(customBtn, nullptr); + EXPECT_FALSE(customBox->isChecked()); // 初始为 LastOptBox + + // Act2:option 值变化 → lambda → onSaveIdChanged(CustomBox) + option->setValue(PathSettingWgt::CustomBox); + + // Assert2:CustomBox 勾选 + 按钮使能(覆盖 S9 第一个 lambda) + EXPECT_TRUE(customBox->isChecked()); + EXPECT_TRUE(customBtn->isEnabled()); + + // Act3:custom path option 变化 → 空实现 lambda(覆盖 S9 第二个 lambda) + s_instance->settings->option("advance.open_save_setting.open_save_custom_path")->setValue(QString::fromLatin1("/tmp-value")); + EXPECT_TRUE(customBox->isChecked()); // 状态不受空 lambda 影响 +} + +// =========================================================================== +// createKeySequenceEditHandle + editingFinished lambda(S10) +// =========================================================================== +namespace { +KeySequenceEdit *findKeySequenceEdit(QWidget *w) +{ + // KeySequenceEdit 未声明 Q_OBJECT,不能使用 qobject_cast/findChildren, + // 改用 RTTI dynamic_cast 遍历 widget 树 + if (auto *kse = dynamic_cast(w)) + return kse; + const auto widgets = w->findChildren(); + for (QWidget *child : widgets) { + if (auto *kse = dynamic_cast(child)) + return kse; + } + return nullptr; +} +} // namespace + +TEST_F(SettingsTest, CreateKeySequenceEditHandle_ValidSequence_UpdatesCustomizeOption) +{ + // Arrange:standard keymap 下编辑 window.savefile + s_instance->settings->option("shortcuts.keymap.keymap")->setValue(QString::fromLatin1("standard")); + auto option = s_instance->settings->option("shortcuts.window.savefile"); + ASSERT_NE(option, nullptr); + QPair pair = Settings::createKeySequenceEditHandle(option); + ASSERT_NE(pair.second, nullptr); + KeySequenceEdit *kse = findKeySequenceEdit(pair.second); + ASSERT_NE(kse, nullptr); + + // Act:有效且不冲突的序列 + QMetaObject::invokeMethod(kse, "editingFinished", + Q_ARG(QKeySequence, QKeySequence(QString::fromLatin1("Ctrl+Alt+Z")))); + + // Assert:customize 分支被写入,keymap 最终回到 customize + EXPECT_EQ(optionValue("shortcuts.window_keymap_customize.savefile"), QString::fromLatin1("Ctrl+Alt+Z")); + EXPECT_EQ(optionValue("shortcuts.keymap.keymap"), QString::fromLatin1("customize")); + EXPECT_EQ(kse->option(), option); +} + +TEST_F(SettingsTest, CreateKeySequenceEditHandle_InvalidSingleKey_RestoresCurrentValue) +{ + // Arrange + s_instance->settings->option("shortcuts.keymap.keymap")->setValue(QString::fromLatin1("standard")); + // 恢复语义:customize 键被重置为 window.savefile 的当前有效值 + const QString windowVal = optionValue("shortcuts.window.savefile"); + auto option = s_instance->settings->option("shortcuts.window.savefile"); + QPair pair = Settings::createKeySequenceEditHandle(option); + KeySequenceEdit *kse = findKeySequenceEdit(pair.second); + ASSERT_NE(kse, nullptr); + stubDialogExec(1); // OK + + // Act:单键 "Z" 非法(非 F1-F12) + QMetaObject::invokeMethod(kse, "editingFinished", + Q_ARG(QKeySequence, QKeySequence(QString::fromLatin1("Z")))); + + // Assert:弹出对话框后 customize 键恢复为当前有效值,keymap 切到 customize + EXPECT_NE(s_instance->m_pDialog, nullptr); + EXPECT_EQ(optionValue("shortcuts.window_keymap_customize.savefile"), windowVal); + EXPECT_EQ(optionValue("shortcuts.keymap.keymap"), QString::fromLatin1("customize")); +} + +TEST_F(SettingsTest, CreateKeySequenceEditHandle_ConflictingSequence_ReplacesOldBinding) +{ + // Arrange:customize.newwindow 先占位一个唯一序列(不能含 Shift,否则被判非法) + const QString conflictSeq = QString::fromLatin1("Ctrl+Alt+F13"); + s_instance->settings->option("shortcuts.window_keymap_customize.newwindow")->setValue(conflictSeq); + s_instance->settings->option("shortcuts.keymap.keymap")->setValue(QString::fromLatin1("standard")); + auto option = s_instance->settings->option("shortcuts.window.savefile"); + QPair pair = Settings::createKeySequenceEditHandle(option); + KeySequenceEdit *kse = findKeySequenceEdit(pair.second); + ASSERT_NE(kse, nullptr); + stubDialogExec(1); // Replace + + // Act:savefile 编辑成与 newwindow 冲突的序列 + QMetaObject::invokeMethod(kse, "editingFinished", + Q_ARG(QKeySequence, QKeySequence(conflictSeq))); + + // Assert:replace 生效——savefile 拿到序列,冲突方被清空 + EXPECT_EQ(optionValue("shortcuts.window_keymap_customize.savefile"), conflictSeq); + EXPECT_TRUE(optionValue("shortcuts.window_keymap_customize.newwindow").isEmpty()); + EXPECT_EQ(optionValue("shortcuts.keymap.keymap"), QString::fromLatin1("customize")); +} + +TEST_F(SettingsTest, CreateKeySequenceEditHandle_AltMConflict_CancelKeepsOldBinding) +{ + // Arrange + s_instance->settings->option("shortcuts.keymap.keymap")->setValue(QString::fromLatin1("standard")); + const QString before = optionValue("shortcuts.window_keymap_customize.savefile"); + auto option = s_instance->settings->option("shortcuts.window.savefile"); + QPair pair = Settings::createKeySequenceEditHandle(option); + KeySequenceEdit *kse = findKeySequenceEdit(pair.second); + ASSERT_NE(kse, nullptr); + stubDialogExec(0); // Cancel + + // Act:系统保留键 Alt+M,用户点取消 + QMetaObject::invokeMethod(kse, "editingFinished", + Q_ARG(QKeySequence, QKeySequence(QString::fromLatin1("Alt+M")))); + + // Assert:取消保持原绑定 + EXPECT_EQ(optionValue("shortcuts.window_keymap_customize.savefile"), before); + EXPECT_NE(s_instance->m_pDialog, nullptr); +} + +TEST_F(SettingsTest, CreateKeySequenceEditHandle_UnderCustomizeMode_SetsDirectly) +{ + // Arrange:keymap 已是 customize + s_instance->settings->option("shortcuts.keymap.keymap")->setValue(QString::fromLatin1("customize")); + auto option = s_instance->settings->option("shortcuts.window.savefile"); + QPair pair = Settings::createKeySequenceEditHandle(option); + KeySequenceEdit *kse = findKeySequenceEdit(pair.second); + ASSERT_NE(kse, nullptr); + + // Act + QMetaObject::invokeMethod(kse, "editingFinished", + Q_ARG(QKeySequence, QKeySequence(QString::fromLatin1("Ctrl+Alt+Y")))); + + // Assert:customize 模式直接写当前 key + EXPECT_EQ(optionValue("shortcuts.window_keymap_customize.savefile"), QString::fromLatin1("Ctrl+Alt+Y")); + EXPECT_EQ(optionValue("shortcuts.keymap.keymap"), QString::fromLatin1("customize")); +} + +// =========================================================================== +// updateAllKeysWithKeymap / copyCustomizeKeysFromKeymap(S12/S13) +// =========================================================================== +TEST_F(SettingsTest, SlotupdateAllKeysWithKeymap_EmacsKeymap_SyncsShortcutOptions) +{ + // Arrange:记录 emacs 映射的目标值 + const QString emacsSave = optionValue("shortcuts.window_keymap_emacs.savefile"); + ASSERT_FALSE(emacsSave.isEmpty()); + + // Act + s_instance->slotupdateAllKeysWithKeymap(QVariant(QString::fromLatin1("emacs"))); + + // Assert:window/editor 两组均已同步为 emacs 值 + const QString emacsIndent = optionValue("shortcuts.editor_keymap_emacs.indentline"); + EXPECT_EQ(optionValue("shortcuts.window.savefile"), emacsSave); + EXPECT_EQ(optionValue("shortcuts.editor.indentline"), emacsIndent); +} + +TEST_F(SettingsTest, UpdateAllKeysWithKeymap_UnknownKeymap_KeepsValuesSafe) +{ + // Arrange:未知 keymap → window_keymap_xxx 组不存在(option 为空) + const QString before = optionValue("shortcuts.window.savefile"); + const QString editorBefore = optionValue("shortcuts.editor.indentline"); + + // Act:直接调用私有实现,覆盖 option 为空的告警分支 + s_instance->updateAllKeysWithKeymap(QString::fromLatin1("nosuchkeymap")); + + // Assert:option() 为空时不写值(强异常安全,window/editor 两组均保持调用前状态) + EXPECT_EQ(optionValue("shortcuts.window.savefile"), before); + EXPECT_EQ(optionValue("shortcuts.editor.indentline"), editorBefore); +} + +TEST_F(SettingsTest, CopyCustomizeKeysFromKeymap_Emacs_CopiesGroupValues) +{ + // Arrange:先破坏 customize 值以观察拷贝效果 + s_instance->settings->option("shortcuts.window_keymap_customize.savefile")->setValue(QString::fromLatin1("Ctrl+Broken")); + const QString emacsSave = optionValue("shortcuts.window_keymap_emacs.savefile"); + + // Act:直接调用私有实现 + s_instance->copyCustomizeKeysFromKeymap(QString::fromLatin1("emacs")); + + // Assert:customize 组(window/editor)被 emacs 组覆盖 + const QString emacsIndent = optionValue("shortcuts.editor_keymap_emacs.indentline"); + EXPECT_EQ(optionValue("shortcuts.window_keymap_customize.savefile"), emacsSave); + EXPECT_EQ(optionValue("shortcuts.editor_keymap_customize.indentline"), emacsIndent); +} + +// =========================================================================== +// checkShortcutValid(S14) +// =========================================================================== +namespace { +struct ShortcutValidCase { + QString key; + QString defaultValue; + bool expectedValid; + bool expectReasonInvalid; +}; +} // namespace + +class CheckShortcutValidTest : public SettingsTest, public ::testing::WithParamInterface { +}; + +TEST_P(CheckShortcutValidTest, CheckShortcutValid_ParamVariants_ReturnsExpected) +{ + // Arrange + QString reason; + bool conflicts = false; + + // Act + const bool valid = s_instance->checkShortcutValid( + QString::fromLatin1("ut-option"), GetParam().key, reason, conflicts, GetParam().defaultValue); + + // Assert + EXPECT_EQ(valid, GetParam().expectedValid); + if (GetParam().expectReasonInvalid) { + EXPECT_TRUE(reason.contains(QString::fromLatin1("invalid"), Qt::CaseInsensitive)); + EXPECT_FALSE(conflicts); // 非法键一律不算冲突 + } else { + EXPECT_TRUE(reason.isEmpty()); + } +} + +INSTANTIATE_TEST_SUITE_P( + BranchMatrix, CheckShortcutValidTest, + ::testing::Values( + ShortcutValidCase{ QString::fromLatin1("Ctrl+S"), QString(), true, false }, // 常规组合键 + ShortcutValidCase{ QString::fromLatin1("F5"), QString(), true, false }, // 单键 F 区间内 + ShortcutValidCase{ QString::fromLatin1("F12"), QString(), true, false }, // 单键 F 上边界 + ShortcutValidCase{ QString::fromLatin1("Z"), QString(), false, true }, // 单键非 F + ShortcutValidCase{ QString::fromLatin1("A"), QString(), false, true }, // 单键字母 + ShortcutValidCase{ QString::fromLatin1("Num+6"), QString(), false, true }, // 小键盘 + ShortcutValidCase{ QString::fromLatin1("Shift+A"), QString(), false, true }, // Shift 组合 + ShortcutValidCase{ QString::fromLatin1("Shift+A"), QString::fromLatin1("Shift+A"), true, false }, // Shift 恢复默认 + ShortcutValidCase{ QString::fromLatin1("Ctrl+"), QString(), true, false })); // 含 < 转义路径 + +// =========================================================================== +// isShortcutConflict(S15) +// =========================================================================== +TEST_F(SettingsTest, IsShortcutConflict_ValueHeldByOtherKey_ReturnsTrue) +{ + // Arrange:取一个已存在的快捷键值,用不同名查询 + const QString existing = optionValue("shortcuts.window_keymap_standard.savefile"); + ASSERT_FALSE(existing.isEmpty()); + + // Act / Assert:其它按键占用该序列 → 冲突(window 与 editor 两组各验一次) + EXPECT_TRUE(s_instance->isShortcutConflict(QString::fromLatin1("ut-other-name"), existing)); + EXPECT_TRUE(s_instance->isShortcutConflict( + QString::fromLatin1("ut-editor-name"), + optionValue("shortcuts.editor_keymap_standard.indentline"))); +} + +TEST_F(SettingsTest, IsShortcutConflict_UnknownSequence_ReturnsFalse) +{ + // Act / Assert:两个未知序列均不冲突 + EXPECT_FALSE(s_instance->isShortcutConflict(QString::fromLatin1("ut-name"), + QString::fromLatin1("Ctrl+Alt+F24-Nope"))); + EXPECT_FALSE(s_instance->isShortcutConflict(QString::fromLatin1("ut-name-2"), + QString::fromLatin1("Ctrl+Shift+Alt+Nope"))); +} + +TEST_F(SettingsTest, IsShortcutConflict_SelfKeySolelyHoldingValue_ReturnsFalse) +{ + // Arrange:让被查询键成为该序列的唯一持有者(其它键值均不同) + const QString selfKey = QString::fromLatin1("shortcuts.window_keymap_emacs.savefile"); + const QString uniqueSeq = QString::fromLatin1("Ctrl+Alt+U7"); + s_instance->settings->option(selfKey)->setValue(uniqueSeq); + + // Act / Assert:自身持有不算冲突(window 与 editor 键各验一次) + EXPECT_FALSE(s_instance->isShortcutConflict(selfKey, uniqueSeq)); + const QString editorKey = QString::fromLatin1("shortcuts.editor_keymap_emacs.indentline"); + const QString editorSeq = QString::fromLatin1("Ctrl+Alt+U8"); + s_instance->settings->option(editorKey)->setValue(editorSeq); + EXPECT_FALSE(s_instance->isShortcutConflict(editorKey, editorSeq)); +} + +// =========================================================================== +// createDialog(S16) +// =========================================================================== +TEST_F(SettingsTest, CreateDialog_ConflictsMode_ReturnsDialogWithBottomHint) +{ + // Act + DDialog *dialog = s_instance->createDialog(QString::fromLatin1("ut-title"), + QString::fromLatin1("ut-content"), true); + + // Assert + ASSERT_NE(dialog, nullptr); + EXPECT_TRUE(dialog->windowFlags().testFlag(Qt::WindowStaysOnBottomHint)); + EXPECT_EQ(dialog->title(), QString::fromLatin1("ut-title")); + delete dialog; +} + +TEST_F(SettingsTest, CreateDialog_NoConflictMode_ReturnsDialogWithBottomHint) +{ + // Act + DDialog *dialog = s_instance->createDialog(QString::fromLatin1("ut-title"), + QString(), false); + + // Assert + ASSERT_NE(dialog, nullptr); + EXPECT_TRUE(dialog->windowFlags().testFlag(Qt::WindowStaysOnBottomHint)); + EXPECT_EQ(dialog->title(), QString::fromLatin1("ut-title")); + delete dialog; +} + +// =========================================================================== +// removeLockFiles(S17) +// =========================================================================== +TEST_F(SettingsTest, RemoveLockFiles_MissingConfigDir_ReturnsEarly) +{ + // Arrange:把 XDG_CONFIG_HOME 临时指向不存在的子目录再调用 + // (removeLockFiles 每次调用实时读取 writableLocation) + const QString missing = s_configHome->filePath("missing-subdir"); + const QByteArray oldXdg = qgetenv("XDG_CONFIG_HOME"); + qputenv("XDG_CONFIG_HOME", missing.toUtf8()); + + // Act + s_instance->removeLockFiles(); + + // Assert:提前返回,不创建目录、不崩溃 + EXPECT_FALSE(QDir(missing).exists()); + // 还原环境,避免影响后续用例 + if (oldXdg.isEmpty()) + qunsetenv("XDG_CONFIG_HOME"); + else + qputenv("XDG_CONFIG_HOME", oldXdg); + EXPECT_TRUE(QDir(s_configHome->path()).exists()); +} + +TEST_F(SettingsTest, RemoveLockFiles_RemovesLockAndRmlockButKeepsOthers) +{ + // Arrange:在配置目录中放置 .lock/.rmlock/普通文件;源码使用裸文件名, + // 因此临时切换 CWD 到配置目录(结束恢复) + const QString configDir = QString("%1/%2/%3") + .arg(QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)) + .arg(QString::fromLatin1(kOrgName)) + .arg(QString::fromLatin1(kAppName)); + ASSERT_TRUE(QDir().mkpath(configDir)); + const QString oldCwd = QDir::currentPath(); + ASSERT_TRUE(QDir::setCurrent(configDir)); + QFile(QString("normal.txt")).remove(); + { + QFile f(QString("a.lock")); + ASSERT_TRUE(f.open(QIODevice::WriteOnly)); + } + { + QFile f(QString("b.rmlock")); + ASSERT_TRUE(f.open(QIODevice::WriteOnly)); + } + { + QFile f(QString("normal.txt")); + ASSERT_TRUE(f.open(QIODevice::WriteOnly)); + } + + // Act + s_instance->removeLockFiles(); + + // Assert + EXPECT_FALSE(QFile::exists(QString("a.lock"))); + EXPECT_FALSE(QFile::exists(QString("b.rmlock"))); + EXPECT_TRUE(QFile::exists(QString("normal.txt"))); + QDir::setCurrent(oldCwd); +} + +TEST_F(SettingsTest, RemoveLockFiles_NonRemovableEntry_SkipsGracefully) +{ + // Arrange:名为 *.lock 的子目录无法被 QFile::remove 删除(失败分支) + const QString configDir = QString("%1/%2/%3") + .arg(QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)) + .arg(QString::fromLatin1(kOrgName)) + .arg(QString::fromLatin1(kAppName)); + ASSERT_TRUE(QDir().mkpath(configDir + QString("/dir.lock"))); + { + QFile f(QString("ok.lock")); + ASSERT_TRUE(f.open(QIODevice::WriteOnly)); + } + const QString oldCwd = QDir::currentPath(); + ASSERT_TRUE(QDir::setCurrent(configDir)); + + // Act + s_instance->removeLockFiles(); + + // Assert:不可删目录保留,普通 lock 文件仍被删除 + EXPECT_TRUE(QDir(QString("dir.lock")).exists()); + EXPECT_FALSE(QFile::exists(QString("ok.lock"))); + QDir::setCurrent(oldCwd); +} + +// =========================================================================== +// slotCustomshortcut(S18) +// =========================================================================== +TEST_F(SettingsTest, SlotCustomshortcut_StandardKeymap_CopiesAndSwitchesToCustomize) +{ + // Arrange + s_instance->settings->option("shortcuts.keymap.keymap")->setValue(QString::fromLatin1("standard")); + + // Act:用户改 window.savefile(shortcuts. 前缀、非 keymap、非 _keymap_) + s_instance->slotCustomshortcut(QString::fromLatin1("shortcuts.window.savefile"), + QVariant(QString::fromLatin1("Ctrl+Shift+W"))); + + // Assert:拷贝 + 更新 + keymap 切换 customize + EXPECT_EQ(optionValue("shortcuts.window_keymap_customize.savefile"), QString::fromLatin1("Ctrl+Shift+W")); + EXPECT_EQ(optionValue("shortcuts.keymap.keymap"), QString::fromLatin1("customize")); + EXPECT_FALSE(s_instance->m_bUserChangeKey); // 结束后复位 +} + +TEST_F(SettingsTest, SlotCustomshortcut_CustomizeKeymap_UpdatesKeyOnly) +{ + // Arrange + s_instance->settings->option("shortcuts.keymap.keymap")->setValue(QString::fromLatin1("customize")); + + // Act + s_instance->slotCustomshortcut(QString::fromLatin1("shortcuts.window.savefile"), + QVariant(QString::fromLatin1("Ctrl+Alt+Q"))); + + // Assert:直接更新 customize 键,keymap 保持 customize + EXPECT_EQ(optionValue("shortcuts.window_keymap_customize.savefile"), QString::fromLatin1("Ctrl+Alt+Q")); + EXPECT_EQ(optionValue("shortcuts.keymap.keymap"), QString::fromLatin1("customize")); +} + +TEST_F(SettingsTest, SlotCustomshortcut_NonShortcutKey_Ignored) +{ + // Arrange + const QString before = optionValue("shortcuts.keymap.keymap"); + + // Act:非 shortcuts. 前缀 + s_instance->slotCustomshortcut(QString::fromLatin1("base.font.size"), QVariant(20)); + + // Assert:无任何状态变化 + EXPECT_EQ(optionValue("shortcuts.keymap.keymap"), before); + EXPECT_FALSE(s_instance->m_bUserChangeKey); +} + +TEST_F(SettingsTest, SlotCustomshortcut_KeymapOrKeymapSuffixedKeys_Ignored) +{ + // Arrange + const QString before = optionValue("shortcuts.keymap.keymap"); + + // Act:keymap 自身 + 含 _keymap_ 的键都被忽略 + s_instance->slotCustomshortcut(QString::fromLatin1("shortcuts.keymap.keymap"), + QVariant(QString::fromLatin1("emacs"))); + s_instance->slotCustomshortcut(QString::fromLatin1("shortcuts.window_keymap_standard.savefile"), + QVariant(QString::fromLatin1("Ctrl+Ignored"))); + + // Assert + EXPECT_EQ(optionValue("shortcuts.keymap.keymap"), before); + EXPECT_FALSE(s_instance->m_bUserChangeKey); +} + +// =========================================================================== +// slotsigAdjust* 转发槽(S19) +// =========================================================================== +TEST_F(SettingsTest, SlotsigAdjustFont_StringValue_EmitsSigAdjustFont) +{ + // Arrange + QSignalSpy spy(s_instance, &Settings::sigAdjustFont); + + // Act + s_instance->slotsigAdjustFont(QVariant(QString::fromLatin1("Noto Mono"))); + + // Assert + EXPECT_EQ(spy.count(), 1); + EXPECT_EQ(spy.at(0).at(0).toString(), QString::fromLatin1("Noto Mono")); +} + +TEST_F(SettingsTest, SlotsigAdjustFontSize_RealValue_EmitsSigAdjustFontSize) +{ + // Arrange + QSignalSpy spy(s_instance, &Settings::sigAdjustFontSize); + + // Act + s_instance->slotsigAdjustFontSize(QVariant(14.5)); + + // Assert + EXPECT_EQ(spy.count(), 1); + EXPECT_DOUBLE_EQ(spy.at(0).at(0).toReal(), 14.5); +} + +TEST_F(SettingsTest, SlotsigAdjustWordWrap_BoolValue_EmitsSigAdjustWordWrap) +{ + // Arrange + QSignalSpy spy(s_instance, &Settings::sigAdjustWordWrap); + + // Act + s_instance->slotsigAdjustWordWrap(QVariant(true)); + + // Assert + EXPECT_EQ(spy.count(), 1); + EXPECT_TRUE(spy.at(0).at(0).toBool()); +} + +TEST_F(SettingsTest, SlotsigSetLineNumberShow_BoolValue_EmitsSigSetLineNumberShow) +{ + // Arrange + QSignalSpy spy(s_instance, &Settings::sigSetLineNumberShow); + + // Act + s_instance->slotsigSetLineNumberShow(QVariant(false)); + + // Assert + EXPECT_EQ(spy.count(), 1); + EXPECT_FALSE(spy.at(0).at(0).toBool()); +} + +TEST_F(SettingsTest, SlotsigAdjustBookmark_BoolValue_EmitsSigAdjustBookmark) +{ + // Arrange + QSignalSpy spy(s_instance, &Settings::sigAdjustBookmark); + + // Act + s_instance->slotsigAdjustBookmark(QVariant(true)); + + // Assert + EXPECT_EQ(spy.count(), 1); + EXPECT_TRUE(spy.at(0).at(0).toBool()); +} + +TEST_F(SettingsTest, SlotsigShowCodeFlodFlag_BoolValue_EmitsSigShowCodeFlodFlag) +{ + // Arrange + QSignalSpy spy(s_instance, &Settings::sigShowCodeFlodFlag); + + // Act + s_instance->slotsigShowCodeFlodFlag(QVariant(false)); + + // Assert + EXPECT_EQ(spy.count(), 1); + EXPECT_FALSE(spy.at(0).at(0).toBool()); +} + +TEST_F(SettingsTest, SlotsigShowBlankCharacter_BoolValue_EmitsSigShowBlankCharacter) +{ + // Arrange + QSignalSpy spy(s_instance, &Settings::sigShowBlankCharacter); + + // Act + s_instance->slotsigShowBlankCharacter(QVariant(true)); + + // Assert + EXPECT_EQ(spy.count(), 1); + EXPECT_TRUE(spy.at(0).at(0).toBool()); +} + +TEST_F(SettingsTest, SlotsigHightLightCurrentLine_BoolValue_EmitsSigHightLightCurrentLine) +{ + // Arrange + QSignalSpy spy(s_instance, &Settings::sigHightLightCurrentLine); + + // Act + s_instance->slotsigHightLightCurrentLine(QVariant(false)); + + // Assert + EXPECT_EQ(spy.count(), 1); + EXPECT_FALSE(spy.at(0).at(0).toBool()); +} + +TEST_F(SettingsTest, SlotsigAdjustTabSpaceNumber_IntValue_EmitsSigAdjustTabSpaceNumber) +{ + // Arrange + QSignalSpy spy(s_instance, &Settings::sigAdjustTabSpaceNumber); + + // Act + s_instance->slotsigAdjustTabSpaceNumber(QVariant(4)); + + // Assert + EXPECT_EQ(spy.count(), 1); + EXPECT_EQ(spy.at(0).at(0).toInt(), 4); +} + +// =========================================================================== +// KeySequenceEdit(S20) +// =========================================================================== +namespace { +class TestableKeySequenceEdit : public KeySequenceEdit { +public: + using KeySequenceEdit::KeySequenceEdit; + using KeySequenceEdit::eventFilter; +}; +} // namespace + +TEST_F(SettingsTest, KeySequenceEdit_Constructor_StoresOptionAndInstallsFilter) +{ + // Arrange + auto option = s_instance->settings->option("shortcuts.window.savefile"); + ASSERT_NE(option, nullptr); + + // Act + TestableKeySequenceEdit kse(option); + + // Assert + EXPECT_EQ(kse.option(), option); + EXPECT_EQ(kse.parent(), nullptr); +} + +TEST_F(SettingsTest, KeySequenceEdit_SlotValueChanged_EmptyValue_ClearsSequence) +{ + // Arrange + auto option = s_instance->settings->option("shortcuts.window.savefile"); + TestableKeySequenceEdit kse(option); + kse.setKeySequence(QKeySequence(QString::fromLatin1("Ctrl+K"))); + + // Act + kse.slotDSettingsOptionvalueChanged(QVariant(QString::fromLatin1(""))); + + // Assert:清空且计数为 0 + EXPECT_TRUE(kse.keySequence().toString().isEmpty()); + EXPECT_EQ(kse.keySequence().count(), 0); +} + +TEST_F(SettingsTest, KeySequenceEdit_SlotValueChanged_ValidValue_SetsSequence) +{ + // Arrange + auto option = s_instance->settings->option("shortcuts.window.savefile"); + TestableKeySequenceEdit kse(option); + + // Act + kse.slotDSettingsOptionvalueChanged(QVariant(QString::fromLatin1("Ctrl+S"))); + + // Assert:序列生效且计数为 1 + EXPECT_EQ(kse.keySequence(), QKeySequence(QString::fromLatin1("Ctrl+S"))); + EXPECT_EQ(kse.keySequence().count(), 1); +} + +TEST_F(SettingsTest, KeySequenceEdit_EventFilter_ReturnOrSpaceWithoutModifier_ReturnsTrue) +{ + // Arrange + auto option = s_instance->settings->option("shortcuts.window.savefile"); + TestableKeySequenceEdit kse(option); + QKeyEvent evReturn(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier); + QKeyEvent evSpace(QEvent::KeyPress, Qt::Key_Space, Qt::NoModifier); + + // Act / Assert:无修饰键的回车/空格被拦截(切换输入焦点行为) + EXPECT_TRUE(kse.eventFilter(&kse, &evReturn)); + EXPECT_TRUE(kse.eventFilter(&kse, &evSpace)); +} + +TEST_F(SettingsTest, KeySequenceEdit_EventFilter_ModifierOrForeignObject_DelegatesToBase) +{ + // Arrange + auto option = s_instance->settings->option("shortcuts.window.savefile"); + TestableKeySequenceEdit kse(option); + QKeyEvent evCtrlReturn(QEvent::KeyPress, Qt::Key_Return, Qt::ControlModifier); + QKeyEvent evPlainReturn(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier); + QObject foreign; + + // Act:组合键与外部对象走基类实现 + const bool withModifier = kse.eventFilter(&kse, &evCtrlReturn); + const bool foreignObject = kse.eventFilter(&foreign, &evPlainReturn); + + // Assert:基类不拦截(返回 false),与"自家无修饰键才拦截"互补 + EXPECT_FALSE(withModifier); + EXPECT_FALSE(foreignObject); +} diff --git a/tests/common/test_urlinfo.cpp b/tests/common/test_urlinfo.cpp new file mode 100644 index 00000000..490d12bc --- /dev/null +++ b/tests/common/test_urlinfo.cpp @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include "stubext.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "urlinfo.h" + +// --------------------------------------------------------------------------- +// 分支清单(来源:src/common/urlinfo.h UrlInfo::UrlInfo(QString)) +// U1: QFile::exists(path) == true → url = fromLocalFile(canonicalFilePath),提前返回 +// U2: 行列号后缀匹配 :(\d+)(:(\d+))?:?$ → path 截去后缀再解析(含 :12 / :12:34 两种) +// U3: 无后缀 → 直接 fromUserInput(path, currentPath, AssumeLocalFile) +// U4: url 无效 → 兜底 fromLocalFile(Utils::getFilePath(path)) +// +// 用例映射: +// - Construct_ExistingFile_UsesCanonicalLocalFileUrl → U1 +// - Construct_ExistingFileWithLineSuffix_StillUsesCanonicalFileUrl → U1(文件存在优先于行列号) +// - Construct_PathWithLineSuffix_StripsSuffixBeforeParsing → U2(:12) +// - Construct_PathWithLineColumnSuffix_StripsWholeSuffix → U2(:12:34) +// - Construct_RelativePath_ResolvesAgainstWorkingDirectory → U3 +// - Construct_NonexistentAbsolutePath_KeepsAbsolutePath → U3 +// - Construct_SuffixVariants_UrlAlwaysPointsToBasePath /* TEST_P */ → U2/U3 边界组 +// +// 环境隔离:全部路径来自 QTemporaryDir/QTemporaryFile,不触碰真实用户目录。 +// --------------------------------------------------------------------------- + +class UrlInfoTest : public ::testing::Test { +protected: + static void SetUpTestSuite() + { + int argc = 1; + static char appArg0[] = "test_urlinfo"; + static char *argv[] = { appArg0, nullptr }; + s_app = new QCoreApplication(argc, argv); + } + + void SetUp() override + { + ASSERT_TRUE(m_tmpDir.isValid()); + m_base = m_tmpDir.filePath("notes.txt"); + } + + QTemporaryDir m_tmpDir; + QString m_base; + static QCoreApplication *s_app; +}; + +QCoreApplication *UrlInfoTest::s_app = nullptr; + +TEST_F(UrlInfoTest, Construct_ExistingFile_UsesCanonicalLocalFileUrl) +{ + // Arrange + QFile f(m_base); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text)); + f.write("hello"); + f.close(); + const QString canonical = QFileInfo(m_base).canonicalFilePath(); + + // Act + UrlInfo info(m_base); + + // Assert + EXPECT_TRUE(info.url.isValid()); + EXPECT_EQ(info.url.toLocalFile(), canonical); + EXPECT_TRUE(info.url.isLocalFile()); +} + +TEST_F(UrlInfoTest, Construct_ExistingFileWithLineSuffix_StillUsesCanonicalFileUrl) +{ + // Arrange:文件真实存在,且名字本身带 ":12"(文件存在性优先) + const QString pathWithSuffix = m_base + ":12"; + QFile f(pathWithSuffix); + ASSERT_TRUE(f.open(QIODevice::WriteOnly)); + f.write("x"); + f.close(); + const QString canonical = QFileInfo(pathWithSuffix).canonicalFilePath(); + + // Act + UrlInfo info(pathWithSuffix); + + // Assert:按存在文件处理,不做行列号截断 + EXPECT_EQ(info.url.toLocalFile(), canonical); + EXPECT_TRUE(info.url.isLocalFile()); +} + +TEST_F(UrlInfoTest, Construct_PathWithLineSuffix_StripsSuffixBeforeParsing) +{ + // Arrange:文件不存在,路径带 :12 行号 + const QString input = m_base + ":12"; + + // Act + UrlInfo info(input); + + // Assert:行列号被截去,URL 指向纯路径且有效 + EXPECT_EQ(info.url.toLocalFile(), m_base); + EXPECT_TRUE(info.url.isValid()); +} + +TEST_F(UrlInfoTest, Construct_PathWithLineColumnSuffix_StripsWholeSuffix) +{ + // Arrange:文件不存在,路径带 :12:34(行+列) + const QString input = m_base + ":12:34"; + + // Act + UrlInfo info(input); + + // Assert:整段行列后缀被截去,仍为本地文件 URL + EXPECT_EQ(info.url.toLocalFile(), m_base); + EXPECT_TRUE(info.url.isLocalFile()); +} + +TEST_F(UrlInfoTest, Construct_RelativePath_ResolvesAgainstWorkingDirectory) +{ + // Arrange:相对路径(不存在) + const QString rel = QString("ut-urlinfo-%1.txt").arg(QCoreApplication::applicationPid()); + + // Act + UrlInfo info(rel); + + // Assert:以当前工作目录为基准展开为绝对本地路径 + const QString expected = QDir(QDir::currentPath()).filePath(rel); + EXPECT_EQ(info.url.toLocalFile(), expected); + EXPECT_TRUE(QFileInfo(info.url.toLocalFile()).isAbsolute()); // 相对路径被展开为绝对 +} + +TEST_F(UrlInfoTest, Construct_NonexistentAbsolutePath_KeepsAbsolutePath) +{ + // Arrange:不存在的绝对路径,无后缀 + const QString abs = m_tmpDir.filePath("not-created-yet.cpp"); + + // Act + UrlInfo info(abs); + + // Assert:保留绝对路径(新建文件场景),且该文件确实不存在 + EXPECT_EQ(info.url.toLocalFile(), abs); + EXPECT_FALSE(QFile::exists(info.url.toLocalFile())); +} + +namespace { +struct SuffixCase { + QString suffix; + const char *desc; +}; +} // namespace + +class UrlInfoParamTest : public UrlInfoTest, public ::testing::WithParamInterface { +}; + +TEST_P(UrlInfoParamTest, Construct_SuffixVariants_UrlAlwaysPointsToBasePath) +{ + // Arrange:同一基础路径 + 不同行列后缀变体(含可选冒号结尾边界) + const QString input = m_base + GetParam().suffix; + + // Act + UrlInfo info(input); + + // Assert:所有变体最终 URL 都指向去后缀的基础路径 + EXPECT_TRUE(info.url.isValid()); + EXPECT_EQ(info.url.toLocalFile(), m_base) << GetParam().desc; +} + +INSTANTIATE_TEST_SUITE_P( + SuffixBoundary, UrlInfoParamTest, + ::testing::Values( + SuffixCase{ QString(":0"), "line zero boundary" }, + SuffixCase{ QString(":1"), "line one" }, + SuffixCase{ QString(":16777215"), "large line number" }, + SuffixCase{ QString(":7:0"), "line and column zero" }, + SuffixCase{ QString(":7:9:"), "trailing colon" }, + SuffixCase{ QString(":42:"), "line with trailing colon" })); diff --git a/tests/common/test_utils.cpp b/tests/common/test_utils.cpp new file mode 100644 index 00000000..9806efe5 --- /dev/null +++ b/tests/common/test_utils.cpp @@ -0,0 +1,1489 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include "stubext.h" + +// --------------------------------------------------------------------------- +// 预包含 utils.h 及其间接依赖 settings.h 的全部直接依赖头(正常访问级别解析), +// 随后仅对 utils.h 放开 private(访问 Utils::m_systemLanguage 静态缓存做用例隔离)。 +// --------------------------------------------------------------------------- +#include "dsettingsdialog.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include "load_libs.h" +} + +#define private public +#include "utils.h" +#undef private + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// 虚函数(且带重载)取桩地址:先 static_cast 定型成员指针,再经 VFLocator +// 转成普通函数指针(VADDR 宏无法直接用于重载名)。 +// --------------------------------------------------------------------------- +#define STUB_ADDR(pm) (typename stub_ext::VFLocator::Func)(pm) + +// --------------------------------------------------------------------------- +// 分支清单(来源:src/common/utils.cpp,仅列关键分支;函数级覆盖映射见用例名) +// 用例 → 方法映射(TEST_F/TEST_P 名前缀即被测方法驼峰名): +// getQrcPath/getQssPath/getRenderSize/setFontSize/applyQss/ +// getKeyshortcut(TEST_P)/getKeyshortcutFromKeymap/fileExists/getFilePath/ +// fileIsWritable/fileIsHome/dropShadow(两实现)/detectEncode(6 场景)/ +// getEncode(3 场景)/ease*(TEST_P 边界)/getThemeMapFromPath(3 场景)/ +// isMimeTypeSupport(4 场景)/isDraftFile/isBackupFile/cleanPath(TEST_P)/ +// localDataPath/getEncodeList/renderSVG(2)/getHiglightColorList/ +// clearChildrenFocus/clearChildrenFoucusEx/setChildrenFocus/ +// getProcessCountByName(3)/killProcessByName(2)/getStringMD5Hash/ +// activeWindowFromDock/isShareDirAndReadOnly(TEST_P 矩阵)/getSystemLan(2)/ +// getSystemVersion(TEST_P)/isWayland(2)/lineFeed(TEST_P)/ +// checkRegionIntersect(TEST_P 6 型)/getSupportEncoding(+List)/libPath(3)/ +// loadCustomDLL(2)/enableClipCopy/recordCloseFile/sendFloatMessageFixedFont(2)/ +// getSystemMemoryInfo(3)/isMemorySufficientForOperation(5)/codecConfidenceForData(2) +// +// 环境隔离: +// - XDG_CONFIG_HOME/XDG_DATA_HOME → QTemporaryDir;HOME 只做只读前缀比较 +// - QProcess/QDBus/DSysInfo/QDir::entryList/QLibrary::resolve 等外部依赖全 stub +// - 资源(qss/svg/encodes.ini)来自 autotests/common 测试 qrc,不依赖安装产物 +// --------------------------------------------------------------------------- + +class UtilsTest : public ::testing::Test { +protected: + static void SetUpTestSuite() + { + s_configHome = new QTemporaryDir(); + const QString xdgConfig = s_configHome->filePath("xdg-config"); + const QString xdgData = s_configHome->filePath("xdg-data"); + QDir().mkpath(xdgConfig); + QDir().mkpath(xdgData); + qputenv("XDG_CONFIG_HOME", xdgConfig.toUtf8()); + qputenv("XDG_DATA_HOME", xdgData.toUtf8()); + int argc = 1; + s_app = new QApplication(argc, s_argv); + QApplication::setOrganizationName(QString::fromLatin1("deepin")); + QApplication::setApplicationName(QString::fromLatin1("deepin-editor")); + Settings::instance(); // getKeyshortcutFromKeymap 需要真实 Settings + } + + static void TearDownTestSuite() + { + // 还原环境变量(与 SetUpTestSuite 中的 qputenv 数量配平,避免用例间泄漏) + qunsetenv("XDG_CONFIG_HOME"); + qunsetenv("XDG_DATA_HOME"); + qunsetenv("XDG_SESSION_TYPE"); + } + + void SetUp() override + { + stub.clear(); + Utils::m_systemLanguage.clear(); // 每用例重置语言缓存,杜绝用例间污染 + } + + void TearDown() override { stub.clear(); } + + static QString tempFile(const QString &name, const QByteArray &content, + QFileDevice::Permissions perms = QFile::ReadOwner | QFile::WriteOwner | QFile::ReadGroup | QFile::ReadOther) + { + const QString path = s_configHome->filePath(name); + QFile f(path); + if (f.open(QIODevice::WriteOnly)) { + f.write(content); + f.close(); + f.setPermissions(perms); + } + return path; + } + + stub_ext::StubExt stub; + static QTemporaryDir *s_configHome; + static QApplication *s_app; + static char s_argv0[]; + static char *s_argv[2]; + int m_callCount = 0; + QString m_captured; +}; + +QTemporaryDir *UtilsTest::s_configHome = nullptr; +QApplication *UtilsTest::s_app = nullptr; +char UtilsTest::s_argv0[] = "test_utils"; +char *UtilsTest::s_argv[2] = { UtilsTest::s_argv0, nullptr }; + +// =========================================================================== +// 纯路径/资源字符串 +// =========================================================================== +TEST_F(UtilsTest, GetQrcPath_AnyImageName_ReturnsImagesPrefixPath) +{ + // Act / Assert + EXPECT_EQ(Utils::getQrcPath(QString::fromLatin1("logo.svg")), QString::fromLatin1(":/images/logo.svg")); + EXPECT_EQ(Utils::getQrcPath(QString::fromLatin1("a/b.png")), QString::fromLatin1(":/images/a/b.png")); +} + +TEST_F(UtilsTest, GetQssPath_AnyQssName_ReturnsQssPrefixPath) +{ + // Act / Assert + EXPECT_EQ(Utils::getQssPath(QString::fromLatin1("main.qss")), QString::fromLatin1(":/qss/main.qss")); + EXPECT_EQ(Utils::getQssPath(QString()), QString::fromLatin1(":/qss/")); +} + +TEST_F(UtilsTest, GetRenderSize_MultiLineText_SumsHeightsAndMaxWidth) +{ + // Arrange + QFont font; + font.setPointSize(12); + QFontMetrics fm(font); + const QString text = QString::fromLatin1("short\nmuch longer line here\nmid"); + int maxWidth = 0; + for (const QString &line : text.split('\n')) + maxWidth = qMax(maxWidth, fm.horizontalAdvance(line)); + + // Act + const QSize size = Utils::getRenderSize(12, text); + + // Assert + EXPECT_EQ(size.width(), maxWidth); + EXPECT_EQ(size.height(), fm.height() * 3); +} + +TEST_F(UtilsTest, GetRenderSize_EmptyText_ReturnsZeroWidthSingleLineHeight) +{ + // Act + const QSize size = Utils::getRenderSize(12, QString()); + + // Assert:空文本 split 后仍有一行空串 → 宽 0、高为单行行高 + QFont font; + font.setPointSize(12); + EXPECT_EQ(size.width(), 0); + EXPECT_EQ(size.height(), QFontMetrics(font).height()); +} + +TEST_F(UtilsTest, SetFontSize_PainterFontUpdated) +{ + // Arrange + QImage img(32, 32, QImage::Format_ARGB32); + QPainter painter(&img); + QFont f = painter.font(); + f.setPointSize(9); + painter.setFont(f); + + // Act + Utils::setFontSize(painter, 15); + + // Assert + EXPECT_EQ(painter.font().pointSize(), 15); + EXPECT_NE(painter.font().pointSize(), 9); // 与设置前不同 + painter.end(); +} + +TEST_F(UtilsTest, ApplyQss_RegisteredQss_AppliesFileContent) +{ + // Arrange::/qss/test.qss 由测试 qrc 提供 + QWidget w; + + // Act + Utils::applyQss(&w, QString::fromLatin1("test.qss")); + + // Assert + EXPECT_EQ(w.styleSheet(), QString::fromLatin1("QWidget { color: red; background-color: blue; }\n")); + EXPECT_TRUE(w.styleSheet().contains(QString::fromLatin1("color: red"))); +} + +TEST_F(UtilsTest, ApplyQss_MissingQss_AppliesEmptyStyleSheet) +{ + // Arrange + QWidget w; + w.setStyleSheet(QString::fromLatin1("body { margin: 0; }")); + EXPECT_FALSE(w.styleSheet().isEmpty()); // 前置:初始有样式 + + // Act:资源不存在 → 读到空内容并覆盖 + Utils::applyQss(&w, QString::fromLatin1("missing.qss")); + + // Assert + EXPECT_TRUE(w.styleSheet().isEmpty()); +} + +// =========================================================================== +// getKeyshortcut(TEST_P 参数矩阵) +// =========================================================================== +namespace { +struct KeyShortcutCase { + int key; + Qt::KeyboardModifiers modifiers; + QString expected; +}; +} // namespace + +class GetKeyshortcutTest : public UtilsTest, public ::testing::WithParamInterface { +}; + +TEST_P(GetKeyshortcutTest, GetKeyshortcut_ParamMatrix_ReturnsJoinedSequence) +{ + // Arrange + QKeyEvent event(QEvent::KeyPress, GetParam().key, GetParam().modifiers); + + // Act + const QString actual = Utils::getKeyshortcut(&event); + + // Assert + EXPECT_EQ(actual, GetParam().expected); + EXPECT_FALSE(actual.isEmpty()); // 任何键事件都产出非空序列 +} + +INSTANTIATE_TEST_SUITE_P( + ModifierMatrix, GetKeyshortcutTest, + ::testing::Values( + KeyShortcutCase{ Qt::Key_X, Qt::NoModifier, QString::fromLatin1("X") }, + KeyShortcutCase{ Qt::Key_X, Qt::ControlModifier, QString::fromLatin1("Ctrl+X") }, + KeyShortcutCase{ Qt::Key_X, Qt::ControlModifier | Qt::ShiftModifier, QString::fromLatin1("Ctrl+Shift+X") }, + KeyShortcutCase{ Qt::Key_Return, Qt::AltModifier, QString::fromLatin1("Alt+Enter") }, // Return→Enter + KeyShortcutCase{ Qt::Key_Backtab, Qt::ControlModifier, QString::fromLatin1("Ctrl+Tab") }, // Backtab→Tab + KeyShortcutCase{ Qt::Key_6, Qt::KeypadModifier | Qt::ControlModifier, QString::fromLatin1("Ctrl+Num+6") }, + KeyShortcutCase{ Qt::Key_unknown, Qt::MetaModifier, QString::fromLatin1("Meta") }, // 无主键仅修饰 + KeyShortcutCase{ Qt::Key_F1, Qt::NoModifier, QString::fromLatin1("F1") })); + +TEST_F(UtilsTest, GetKeyshortcutFromKeymap_RealSettings_ReturnsOptionValue) +{ + // Arrange + Settings *s = Settings::instance(); + ASSERT_NE(s->settings, nullptr); + const QString expected = s->settings->option("shortcuts.window.savefile")->value().toString(); + + // Act + const QString actual = Utils::getKeyshortcutFromKeymap(s, QString::fromLatin1("window"), + QString::fromLatin1("savefile")); + + // Assert + EXPECT_EQ(actual, expected); + EXPECT_FALSE(actual.isEmpty()); +} + +// =========================================================================== +// 文件系统类(QTemporaryDir 隔离) +// =========================================================================== +TEST_F(UtilsTest, FileExists_ExistingRegularFile_ReturnsTrue) +{ + // Arrange + const QString path = tempFile(QString::fromLatin1("exists.txt"), "data"); + + // Act / Assert + EXPECT_TRUE(Utils::fileExists(path)); + EXPECT_EQ(QFileInfo(path).size(), 4); // 内容 "data" 共 4 字节,确认探测的是该文件 +} + +TEST_F(UtilsTest, FileExists_DirectoryOrMissing_ReturnsFalse) +{ + // Arrange + const QString dirPath = s_configHome->filePath(QString::fromLatin1("subdir")); + QDir().mkpath(dirPath); + + // Act / Assert:目录不算文件;不存在路径为 false + EXPECT_FALSE(Utils::fileExists(dirPath)); + EXPECT_FALSE(Utils::fileExists(s_configHome->filePath(QString::fromLatin1("no-such.file")))); +} + +TEST_F(UtilsTest, GetFilePath_ExistingFile_ReturnsCanonicalPath) +{ + // Arrange + const QString path = tempFile(QString::fromLatin1("canon.txt"), "x"); + const QString canonical = QFileInfo(path).canonicalFilePath(); + + // Act / Assert + EXPECT_EQ(Utils::getFilePath(path), canonical); + EXPECT_FALSE(canonical.isEmpty()); +} + +TEST_F(UtilsTest, GetFilePath_NonexistentFile_FallsBackToAbsolutePath) +{ + // Arrange + const QString path = s_configHome->filePath(QString::fromLatin1("brand-new.cpp")); + + // Act / Assert + EXPECT_EQ(Utils::getFilePath(path), QFileInfo(path).absoluteFilePath()); + EXPECT_TRUE(QFileInfo(Utils::getFilePath(path)).isAbsolute()); +} + +TEST_F(UtilsTest, FileIsWritable_WritableAndReadOnlyFiles_ReturnExpected) +{ + // Arrange + const QString writable = tempFile(QString::fromLatin1("w.txt"), "x"); + const QString readonly = tempFile(QString::fromLatin1("ro.txt"), "x", + QFile::ReadOwner | QFile::ReadGroup | QFile::ReadOther); + + // Act / Assert + EXPECT_TRUE(Utils::fileIsWritable(writable)); + EXPECT_FALSE(Utils::fileIsWritable(readonly)); +} + +TEST_F(UtilsTest, FileIsHome_HomePrefixedAndOtherPaths_ReturnExpected) +{ + // Arrange(只读字符串比较,不写 HOME) + const QString underHome = QDir::homePath() + QString::fromLatin1("/some/file.txt"); + const QString outside = QDir::tempPath() + QString::fromLatin1("/abc.txt"); + ASSERT_NE(QDir::homePath(), QDir::tempPath()); + + // Act / Assert + EXPECT_TRUE(Utils::fileIsHome(underHome)); + EXPECT_FALSE(Utils::fileIsHome(outside)); +} + +// =========================================================================== +// dropShadow +// =========================================================================== +TEST_F(UtilsTest, DropShadow_QPixmapWithOffset_GrowsCanvasAndKeepsContent) +{ + // Arrange + QPixmap src(20, 20); + src.fill(Qt::red); + + // Act + QPixmap shadowed = Utils::dropShadow(src, 5.0, QColor(Qt::black), QPoint(2, 2)); + + // Assert:画布按半径扩大 + EXPECT_FALSE(shadowed.isNull()); + EXPECT_GE(shadowed.width(), 20); + EXPECT_GE(shadowed.height(), 20); +} + +TEST_F(UtilsTest, DropShadow_QImageOverload_BlackAndColorTintBothSucceed) +{ + // Arrange + QPixmap src(16, 16); + src.fill(Qt::blue); + + // Act + QImage black = Utils::dropShadow(src, 4.0, QColor(Qt::black)); + QImage tinted = Utils::dropShadow(src, 4.0, QColor(Qt::green)); + + // Assert:黑影走快速路径,彩影走 SourceIn 上色路径,均有输出 + EXPECT_FALSE(black.isNull()); + EXPECT_FALSE(tinted.isNull()); + EXPECT_EQ(black.size(), tinted.size()); +} + +TEST_F(UtilsTest, DropShadow_NullPixmap_ReturnsNullImage) +{ + // Act + const QImage result = Utils::dropShadow(QPixmap(), 4.0, QColor(Qt::black)); + + // Assert + EXPECT_TRUE(result.isNull()); + EXPECT_EQ(result.format(), QImage::Format_Invalid); +} + +// =========================================================================== +// detectEncode / getEncode +// =========================================================================== +TEST_F(UtilsTest, DetectEncode_EmptyData_ReturnsLocaleCodecName) +{ + // Act / Assert(带不带 fileName 两条路径一致) + EXPECT_EQ(Utils::detectEncode(QByteArray()), QByteArray(QTextCodec::codecForLocale()->name())); + EXPECT_EQ(Utils::detectEncode(QByteArray(), QString::fromLatin1("e.txt")), + QByteArray(QTextCodec::codecForLocale()->name())); +} + +TEST_F(UtilsTest, DetectEncode_Utf8Bom_ReturnsUtf8) +{ + // Arrange + const QByteArray data = QByteArray::fromHex("efbbbf") + "hello text"; + + // Act / Assert(BOM 优先于 fileName 探测) + EXPECT_EQ(Utils::detectEncode(data), QByteArray("UTF-8")); + EXPECT_EQ(Utils::detectEncode(data, QString::fromLatin1("bom.txt")), QByteArray("UTF-8")); +} + +TEST_F(UtilsTest, DetectEncode_PythonCodingLine_ReturnsDeclaredCoding) +{ + // Arrange + const QByteArray data = "#coding: gbk\nprint('hello')\n"; + + // Act / Assert(fileName 触发 text/x-python 分支;大小写不敏感变体同样命中) + EXPECT_EQ(Utils::detectEncode(data, QString::fromLatin1("script.py")), QByteArray("gbk")); + EXPECT_EQ(Utils::detectEncode("#CODING: big5\n", QString::fromLatin1("s2.py")), QByteArray("big5")); +} + +TEST_F(UtilsTest, DetectEncode_GbkBytes_DetectsCjkEncoding) +{ + // Arrange:GBK 编码的重复中文文本(探测置信度稳定) + QByteArray gbk; + for (int i = 0; i < 8; ++i) + gbk += QByteArray::fromHex("c4e3bac3cac0bde7"); // 你好世界 + const QString fileName = s_configHome->filePath(QString::fromLatin1("gbk.txt")); + QFile f(fileName); + ASSERT_TRUE(f.open(QIODevice::WriteOnly)); + f.write(gbk); + f.close(); + + // Act + const QByteArray detected = Utils::detectEncode(gbk, fileName); + + // Assert:命中 CJK 家族(GBK/GB18030 之一;prober 在本机返回小写名,比较不区分大小写) + const QByteArray upper = detected.toUpper(); + EXPECT_TRUE(upper == QByteArray("GBK") || upper == QByteArray("GB18030")) + << "detected=" << detected.constData(); + EXPECT_FALSE(detected.isEmpty()); // 必须给出确定编码 +} + +TEST_F(UtilsTest, GetEncode_Utf16BomHtmlLikeData_ReturnsUtf16Family) +{ + // Arrange:UTF-16LE BOM + 少量英文文本(codecForHtml 路径) + const QByteArray data = QByteArray::fromHex("fffe") + QByteArray("ab", 2); + + // Act / Assert + const QByteArray enc = Utils::getEncode(data); + EXPECT_TRUE(enc.startsWith("UTF-16")) << "detected=" << enc.constData(); + EXPECT_GT(enc.size(), 0); +} + +TEST_F(UtilsTest, GetEncode_GbkBytes_HighConfidenceProber) +{ + // Arrange + QByteArray gbk; + for (int i = 0; i < 10; ++i) + gbk += QByteArray::fromHex("c4e3bac3cac0bde7"); + + // Act / Assert(prober 返回小写名,比较不区分大小写) + const QByteArray detected = Utils::getEncode(gbk).toUpper(); + EXPECT_TRUE(detected == QByteArray("GBK") || detected == QByteArray("GB18030")); + EXPECT_GT(detected.size(), 0); // 必须给出确定编码 +} + +TEST_F(UtilsTest, GetEncode_LowConfidenceBinary_ReturnsEmpty) +{ + // Arrange:无 BOM/HTML 特征且 prober 置信度仅 0.2(≤0.5)→ 返回空 + const QByteArray data = QByteArray::fromHex("8081828384"); + + // Act / Assert:重复调用结果稳定为空 + EXPECT_TRUE(Utils::getEncode(data).isEmpty()); + EXPECT_TRUE(Utils::getEncode(data).isEmpty()); +} + +TEST_F(UtilsTest, CodecConfidenceForData_GbkTextWithChina_LargerThanJapan) +{ + // Arrange + QByteArray gbk; + for (int i = 0; i < 8; ++i) + gbk += QByteArray::fromHex("c4e3bac3cac0bde7"); + QTextCodec *gbkCodec = QTextCodec::codecForName("GBK"); + ASSERT_NE(gbkCodec, nullptr); + + // Act + const float cn = Utils::codecConfidenceForData(gbkCodec, gbk, QLocale::China); + const float jp = Utils::codecConfidenceForData(gbkCodec, gbk, QLocale::Japan); + + // Assert:中文字节在中国 locale 下置信度更高,且范围合法 + EXPECT_GT(cn, jp); + EXPECT_LE(cn, 1.0f); + EXPECT_GE(cn, 0.0f); +} + +TEST_F(UtilsTest, CodecConfidenceForData_InvalidUtf8_NeverExceedsOne) +{ + // Arrange:UTF-8 解码产生替换字符 + QTextCodec *utf8 = QTextCodec::codecForName("UTF-8"); + ASSERT_NE(utf8, nullptr); + const QByteArray bad = QByteArray::fromHex("c4e3fffe80"); + + // Act + const float c = Utils::codecConfidenceForData(utf8, bad, QLocale::China); + + // Assert:替换字符扣分后仍被 clamp 到 [0,1] + EXPECT_GE(c, 0.0f); + EXPECT_LE(c, 1.0f); +} + +// =========================================================================== +// 缓动函数(TEST_P 边界 0 / 0.5 / 1) +// =========================================================================== +namespace { +struct EaseCase { + qreal x; +}; +} // namespace + +class EaseFuncTest : public UtilsTest, public ::testing::WithParamInterface { +}; + +TEST_P(EaseFuncTest, EaseInOut_BoundaryInputs_MatchesReferenceFormula) +{ + const qreal x = GetParam().x; + EXPECT_DOUBLE_EQ(Utils::easeInOut(x), (1 - qCos(M_PI * x)) / 2); + EXPECT_DOUBLE_EQ(Utils::easeInOut(0.0), 0.0); // 端点锚定 +} + +TEST_P(EaseFuncTest, EaseInQuad_BoundaryInputs_MatchesReferenceFormula) +{ + const qreal x = GetParam().x; + EXPECT_DOUBLE_EQ(Utils::easeInQuad(x), qPow(x, 2)); + EXPECT_DOUBLE_EQ(Utils::easeInQuad(0.0), 0.0); // 端点锚定 +} + +TEST_P(EaseFuncTest, EaseOutQuad_BoundaryInputs_MatchesReferenceFormula) +{ + const qreal x = GetParam().x; + EXPECT_DOUBLE_EQ(Utils::easeOutQuad(x), -1 * qPow(x - 1, 2) + 1); + EXPECT_DOUBLE_EQ(Utils::easeOutQuad(1.0), 1.0); // 端点锚定 +} + +TEST_P(EaseFuncTest, EaseInQuint_BoundaryInputs_MatchesReferenceFormula) +{ + const qreal x = GetParam().x; + EXPECT_DOUBLE_EQ(Utils::easeInQuint(x), qPow(x, 5)); + EXPECT_DOUBLE_EQ(Utils::easeInQuint(0.0), 0.0); // 端点锚定 +} + +TEST_P(EaseFuncTest, EaseOutQuint_BoundaryInputs_MatchesReferenceFormula) +{ + const qreal x = GetParam().x; + EXPECT_DOUBLE_EQ(Utils::easeOutQuint(x), qPow(x - 1, 5) + 1); + EXPECT_DOUBLE_EQ(Utils::easeOutQuint(1.0), 1.0); // 端点锚定 +} + +INSTANTIATE_TEST_SUITE_P(Boundary, EaseFuncTest, + ::testing::Values(EaseCase{ 0.0 }, EaseCase{ 0.5 }, EaseCase{ 1.0 })); + +// =========================================================================== +// getThemeMapFromPath +// =========================================================================== +TEST_F(UtilsTest, GetThemeMapFromPath_ValidJsonFile_ReturnsMap) +{ + // Arrange + const QString path = tempFile(QString::fromLatin1("theme.json"), + R"({"foreground": "#ffffff", "background": "#000000"})"); + + // Act + const QVariantMap map = Utils::getThemeMapFromPath(path); + + // Assert + EXPECT_EQ(map.value(QString::fromLatin1("foreground")).toString(), QString::fromLatin1("#ffffff")); + EXPECT_EQ(map.value(QString::fromLatin1("background")).toString(), QString::fromLatin1("#000000")); +} + +TEST_F(UtilsTest, GetThemeMapFromPath_MissingFile_ReturnsEmptyMap) +{ + // Act / Assert:缺失文件返回空 map + const QVariantMap map = Utils::getThemeMapFromPath(s_configHome->filePath(QString::fromLatin1("nope.theme"))); + EXPECT_TRUE(map.isEmpty()); + EXPECT_EQ(map.value(QString::fromLatin1("foreground")), QVariant()); // 无任何键 +} + +TEST_F(UtilsTest, GetThemeMapFromPath_InvalidJson_ReturnsEmptyMap) +{ + // Arrange + const QString path = tempFile(QString::fromLatin1("bad.json"), "not-a-json{{{"); + + // Act / Assert + EXPECT_TRUE(Utils::getThemeMapFromPath(path).isEmpty()); +} + +// =========================================================================== +// isMimeTypeSupport(内容探测,QTemporaryDir 隔离) +// =========================================================================== +TEST_F(UtilsTest, IsMimeTypeSupport_TextContentFile_ReturnsTrue) +{ + // Arrange + const QString path = tempFile(QString::fromLatin1("plain.txt"), "hello world\nline2\n"); + + // Act / Assert:text/* → 支持(另一份文本内容同样支持) + EXPECT_TRUE(Utils::isMimeTypeSupport(path)); + EXPECT_TRUE(Utils::isMimeTypeSupport(tempFile(QString::fromLatin1("plain2.txt"), "second text"))); +} + +TEST_F(UtilsTest, IsMimeTypeSupport_PubSuffix_ReturnsTrue) +{ + // Arrange:即使内容非文本,.pub 后缀白名单 + const QString path = tempFile(QString::fromLatin1("key.pub"), + QByteArray::fromHex("00ff00ff0011")); + + // Act / Assert + EXPECT_TRUE(Utils::isMimeTypeSupport(path)); + EXPECT_TRUE(path.endsWith(QString::fromLatin1(".pub"))); // 后缀白名单路径 +} + +TEST_F(UtilsTest, IsMimeTypeSupport_PngContent_ReturnsTrueViaOctetStreamInherit) +{ + // Arrange:PNG 魔数(白名单含 application/octet-stream,PNG 继承自它) + const QString path = tempFile(QString::fromLatin1("image.bin"), + QByteArray::fromHex("89504e470d0a1a0a0000000d49484452")); + + // Act / Assert:按源码 inherits 逻辑,二进制根类型被放行(记录源行为) + EXPECT_TRUE(Utils::isMimeTypeSupport(path)); + EXPECT_EQ(QFileInfo(path).size(), 16); // 探测基于该 16 字节内容 +} + +TEST_F(UtilsTest, IsMimeTypeSupport_DirectoryPath_ReturnsFalse) +{ + // Arrange:目录 → inode/directory,不继承任何白名单类型 + const QString dirPath = s_configHome->filePath(QString::fromLatin1("subdir-inode")); + QDir().mkpath(dirPath); + + // Act / Assert + EXPECT_FALSE(Utils::isMimeTypeSupport(dirPath)); + EXPECT_TRUE(QDir(dirPath).exists()); // 前提:目录真实存在 +} + +TEST_F(UtilsTest, IsMimeTypeSupport_EmptyFile_ReturnsTrue) +{ + // Arrange:空文件 → x-empty/x-zerosize 均在白名单 + const QString path = tempFile(QString::fromLatin1("empty.txt"), ""); + + // Act / Assert + EXPECT_TRUE(Utils::isMimeTypeSupport(path)); + EXPECT_EQ(QFileInfo(path).size(), 0); // 空文件(0 字节)走 x-empty/x-zerosize 白名单 +} + +// =========================================================================== +// isDraftFile / isBackupFile / localDataPath(XDG_DATA_HOME 隔离) +// =========================================================================== +TEST_F(UtilsTest, IsDraftFile_UnderAppDataBlankFiles_ReturnsExpected) +{ + // Arrange:与实现同源的期望路径(XDG_DATA_HOME 已重定向到临时目录) + const QStringList locations = QStandardPaths::standardLocations(QStandardPaths::AppDataLocation); + ASSERT_FALSE(locations.isEmpty()); + const QString draftDir = QDir(Utils::cleanPath(locations).first()).filePath(QString::fromLatin1("blank-files")); + + // Act / Assert + EXPECT_TRUE(Utils::isDraftFile(QDir(draftDir).filePath(QString::fromLatin1("draft.txt")))); + EXPECT_EQ(draftDir.endsWith(QString::fromLatin1("blank-files")), true); // 期望目录推导正确 + EXPECT_FALSE(Utils::isDraftFile(s_configHome->filePath(QString::fromLatin1("elsewhere.txt")))); +} + +TEST_F(UtilsTest, IsBackupFile_UnderAppDataBackupFiles_ReturnsExpected) +{ + // Arrange + const QStringList locations = QStandardPaths::standardLocations(QStandardPaths::AppDataLocation); + const QString backupDir = QDir(Utils::cleanPath(locations).first()).filePath(QString::fromLatin1("backup-files")); + + // Act / Assert + EXPECT_TRUE(Utils::isBackupFile(QDir(backupDir).filePath(QString::fromLatin1("file.cpp~")))); + EXPECT_EQ(backupDir.endsWith(QString::fromLatin1("backup-files")), true); // 期望目录推导正确 + EXPECT_FALSE(Utils::isBackupFile(s_configHome->filePath(QString::fromLatin1("normal.cpp")))); +} + +TEST_F(UtilsTest, LocalDataPath_EqualsFirstCleanedStandardLocation) +{ + // Arrange + const QStringList locations = QStandardPaths::standardLocations(QStandardPaths::AppDataLocation); + ASSERT_FALSE(locations.isEmpty()); + + // Act / Assert + EXPECT_EQ(Utils::localDataPath(), QDir::cleanPath(locations.first())); + EXPECT_TRUE(QDir(Utils::localDataPath()).isAbsolute()); // 绝对路径 +} + +// =========================================================================== +// cleanPath(TEST_P) +// =========================================================================== +namespace { +struct CleanPathCase { + QString input; + QString expected; +}; +} // namespace + +class CleanPathTest : public UtilsTest, public ::testing::WithParamInterface { +}; + +TEST_P(CleanPathTest, CleanPath_ParamVariants_ReturnsNormalizedList) +{ + // Arrange + const QStringList input = QStringList() << GetParam().input; + + // Act + const QStringList output = Utils::cleanPath(input); + + // Assert + ASSERT_EQ(output.size(), 1); + EXPECT_EQ(output.first(), GetParam().expected); + EXPECT_EQ(Utils::cleanPath(output).first(), GetParam().expected); // 幂等:再清洗不变 +} + +INSTANTIATE_TEST_SUITE_P( + Variants, CleanPathTest, + ::testing::Values( + CleanPathCase{ QString::fromLatin1("a/b/../c"), QString::fromLatin1("a/c") }, + CleanPathCase{ QString::fromLatin1("/foo//bar"), QString::fromLatin1("/foo/bar") }, + CleanPathCase{ QString::fromLatin1("./x"), QString::fromLatin1("x") }, + CleanPathCase{ QString::fromLatin1("/abs/path"), QString::fromLatin1("/abs/path") }, + CleanPathCase{ QString(), QString() })); // 空串边界:cleanPath("") == "" + +TEST_F(UtilsTest, CleanPath_EmptyList_ReturnsEmptyList) +{ + // Act / Assert + EXPECT_TRUE(Utils::cleanPath(QStringList()).isEmpty()); + EXPECT_EQ(Utils::cleanPath(QStringList() << QString::fromLatin1("a/b")).size(), 1); // 非空列表对照 +} + +// =========================================================================== +// getEncodeList +// =========================================================================== +TEST_F(UtilsTest, GetEncodeList_Utf8FirstAndUniqueSorted) +{ + // Act + const QStringList list = Utils::getEncodeList(); + + // Assert:首项 UTF-8、无重复、其余有序 + ASSERT_GT(list.size(), 1); + EXPECT_EQ(list.first(), QString::fromLatin1("UTF-8")); + EXPECT_EQ(list.count(QString::fromLatin1("UTF-8")), 1); + const QStringList rest = list.mid(1); + EXPECT_EQ(rest.size(), QSet(rest.begin(), rest.end()).size()); // 无重复 + // UTF-8 只出现一次(首项),其余不含 UTF-8 + EXPECT_FALSE(rest.contains(QString::fromLatin1("UTF-8"))); +} + +// =========================================================================== +// renderSVG +// =========================================================================== +TEST_F(UtilsTest, RenderSVG_LoadableQrcSvg_ReturnsScaledPixmap) +{ + // Arrange::/svg/test.svg(10x10)由测试 qrc 提供 + const QSize target(5, 5); + + // Act + const QPixmap pm = Utils::renderSVG(QString::fromLatin1(":/svg/test.svg"), target); + + // Assert + EXPECT_FALSE(pm.isNull()); + EXPECT_EQ(pm.size(), target); +} + +TEST_F(UtilsTest, RenderSVG_MissingFile_ReturnsNullPixmap) +{ + // Act + const QPixmap pm = Utils::renderSVG(QString::fromLatin1(":/svg/missing.svg"), QSize(4, 4)); + + // Assert + EXPECT_TRUE(pm.isNull()); + EXPECT_EQ(pm.width(), 0); +} + +// =========================================================================== +// getHiglightColorList +// =========================================================================== +TEST_F(UtilsTest, GetHiglightColorList_FixedPaletteReturned) +{ + // Act + const QList colors = Utils::getHiglightColorList(); + + // Assert:8 个固定色值 + ASSERT_EQ(colors.size(), 8); + EXPECT_EQ(colors.at(0), QColor(QString::fromLatin1("#FFA503"))); + EXPECT_EQ(colors.at(1), QColor(QString::fromLatin1("#FF1C49"))); + EXPECT_EQ(colors.at(2), QColor(QString::fromLatin1("#9023FC"))); + EXPECT_EQ(colors.at(3), QColor(QString::fromLatin1("#3468FF"))); + EXPECT_EQ(colors.at(4), QColor(QString::fromLatin1("#00C7E1"))); + EXPECT_EQ(colors.at(5), QColor(QString::fromLatin1("#05EA6B"))); + EXPECT_EQ(colors.at(6), QColor(QString::fromLatin1("#FEF144"))); + EXPECT_EQ(colors.at(7), QColor(QString::fromLatin1("#D5D5D1"))); +} + +// =========================================================================== +// 焦点控制(offscreen widget 树) +// =========================================================================== +TEST_F(UtilsTest, ClearChildrenFocus_TreeWithFocusableWidgets_SetsNoFocus) +{ + // Arrange:parent → [QLineEdit, QObject];QLineEdit → [QLineEdit] + QWidget parent; + auto *edit = new QLineEdit(&parent); + edit->setFocusPolicy(Qt::StrongFocus); + auto *plain = new QObject(&parent); + auto *innerEdit = new QLineEdit(edit); + innerEdit->setFocusPolicy(Qt::StrongFocus); + + // Act + Utils::clearChildrenFocus(&parent); + + // Assert:可聚焦控件全部 NoFocus(含递归孙代),非 widget 子对象不受影响 + EXPECT_EQ(edit->focusPolicy(), Qt::NoFocus); + EXPECT_EQ(innerEdit->focusPolicy(), Qt::NoFocus); + EXPECT_EQ(plain->children().size(), 0); +} + +TEST_F(UtilsTest, ClearChildrenFoucusEx_TreeWithWidgets_ClearsFocusSafely) +{ + // Arrange + QWidget parent; + auto *edit = new QLineEdit(&parent); + + // Act:清焦点递归(无子/有子两分支) + Utils::clearChildrenFoucusEx(&parent); + Utils::clearChildrenFoucusEx(edit); + + // Assert:控件树完好(强异常安全) + EXPECT_EQ(parent.children().size(), 1); + EXPECT_EQ(edit->parentWidget(), &parent); +} + +TEST_F(UtilsTest, SetChildrenFocus_TreeWithWidgets_AppliesPolicyRecursively) +{ + // Arrange + QWidget parent; + auto *edit = new QLineEdit(&parent); + edit->setFocusPolicy(Qt::NoFocus); + QWidget childless; + + // Act:有子树 + 无子树(children<=0 提前返回分支) + Utils::setChildrenFocus(&parent, Qt::ClickFocus); + Utils::setChildrenFocus(&childless, Qt::StrongFocus); + + // Assert + EXPECT_EQ(parent.focusPolicy(), Qt::ClickFocus); + EXPECT_EQ(edit->focusPolicy(), Qt::ClickFocus); + EXPECT_EQ(childless.focusPolicy(), Qt::StrongFocus); +} + +// =========================================================================== +// 进程相关(QProcess 全 stub) +// =========================================================================== +TEST_F(UtilsTest, GetProcessCountByName_NullOrEmptyName_ReturnsMinusOne) +{ + // Act / Assert + EXPECT_EQ(Utils::getProcessCountByName(NULL), -1); + EXPECT_EQ(Utils::getProcessCountByName(""), -1); +} + +TEST_F(UtilsTest, GetProcessCountByName_StartFailure_ReturnsMinusOne) +{ + // Arrange:start 为空实现 → waitForFinished 失败 + stub.set_lamda(static_cast(&QProcess::start), + [](QProcess *, const QString &, const QStringList &, QProcess::OpenMode) {}); + + // Act / Assert:启动失败的任意进程名都返回 -1 + EXPECT_EQ(Utils::getProcessCountByName("ut-proc"), -1); + EXPECT_EQ(Utils::getProcessCountByName("ut-proc-2"), -1); +} + +TEST_F(UtilsTest, GetProcessCountByName_FilteredOutput_CountsMatchingLines) +{ + // Arrange + stub.set_lamda(static_cast(&QProcess::start), + [](QProcess *, const QString &, const QStringList &, QProcess::OpenMode) {}); + stub.set_lamda(static_cast(&QProcess::waitForFinished), + [](QProcess *, int) -> bool { return true; }); + stub.set_lamda(&QProcess::readAllStandardOutput, + [](QProcess *) -> QByteArray { + return QByteArray("header line\n/bin/ut-proc --a\nother\n/bin/ut-proc --b\n"); + }); + + // Act + const int count = Utils::getProcessCountByName("ut-proc"); + + // Assert + EXPECT_EQ(count, 2); + EXPECT_NE(count, -1); // 未走启动失败分支 +} + +TEST_F(UtilsTest, KillProcessByName_EmptyName_SkipsKillall) +{ + // Arrange:计数器记录真实 start 调用(应当为 0) + m_callCount = 0; + stub.set_lamda(static_cast(&QProcess::start), + [this](QProcess *, const QString &, const QStringList &, QProcess::OpenMode) { ++m_callCount; }); + + // Act:空名与 NULL 都不触发 killall + Utils::killProcessByName(""); + Utils::killProcessByName(NULL); + + // Assert:空名/NULL 均被前置校验拦截 + EXPECT_EQ(m_callCount, 0); + EXPECT_EQ(m_captured.isEmpty(), true); // 未捕获到任何 killall 参数 +} + +TEST_F(UtilsTest, KillProcessByName_ValidName_RunsKillall) +{ + // Arrange + m_callCount = 0; + m_captured.clear(); + stub.set_lamda(static_cast(&QProcess::start), + [this](QProcess *, const QString &program, const QStringList &args, QProcess::OpenMode) { + ++m_callCount; + m_captured = program + QString::fromLatin1(" ") + args.join(QString::fromLatin1(",")); + }); + stub.set_lamda(static_cast(&QProcess::waitForFinished), + [](QProcess *, int) -> bool { return true; }); + + // Act + Utils::killProcessByName("ut-victim"); + + // Assert + EXPECT_EQ(m_callCount, 1); + EXPECT_EQ(m_captured, QString::fromLatin1("killall ut-victim")); +} + +// =========================================================================== +// MD5 +// =========================================================================== +TEST_F(UtilsTest, GetStringMD5Hash_KnownInputs_ExpectedHexDigests) +{ + // Act / Assert:经典已知向量 + EXPECT_EQ(Utils::getStringMD5Hash(QString::fromLatin1("abc")), + QString::fromLatin1("900150983cd24fb0d6963f7d28e17f72")); + EXPECT_EQ(Utils::getStringMD5Hash(QString()), + QString::fromLatin1("d41d8cd98f00b204e9800998ecf8427e")); +} + +// =========================================================================== +// activeWindowFromDock(QDBusInterface::isValid stub,无真实 DBus 依赖) +// =========================================================================== +TEST_F(UtilsTest, ActiveWindowFromDock_DockUnavailable_ReturnsFalse) +{ + // Arrange:两个版本的 Dock 接口均 stub 为无效 + stub.set_lamda(VADDR(QDBusInterface, isValid), []() -> bool { return false; }); + + // Act:两个 winId 均尝试 + const bool result = Utils::activeWindowFromDock(12345); + + // Assert:V23/V20 均无效 → false + EXPECT_FALSE(result); + EXPECT_FALSE(Utils::activeWindowFromDock(0)); +} + +// =========================================================================== +// isShareDirAndReadOnly(QDir/QFile 全 stub 矩阵) +// =========================================================================== +namespace { +struct ShareDirCase { + bool dirExists; + bool nameExists; + bool openOk; + QByteArray content; + bool expected; +}; +} // namespace + +class IsShareDirTest : public UtilsTest, public ::testing::WithParamInterface { +}; + +TEST_P(IsShareDirTest, IsShareDirAndReadOnly_ParamMatrix_ReturnsExpected) +{ + // Arrange + const ShareDirCase c = GetParam(); + stub.set_lamda(static_cast(&QDir::exists), + [c](const QDir *) -> bool { return c.dirExists; }); + stub.set_lamda(static_cast(&QDir::exists), + [c](const QDir *, const QString &) -> bool { return c.nameExists; }); + stub.set_lamda(STUB_ADDR(static_cast(&QFile::open)), + [c](QFile *, QIODevice::OpenMode) -> bool { return c.openOk; }); + stub.set_lamda(&QIODevice::readAll, + [c](QIODevice *) -> QByteArray { return c.content; }); + + // Act:同参数调用两次(幂等性 + 确定性) + const bool actual = Utils::isShareDirAndReadOnly(QString::fromLatin1("/any/share/file.txt")); + const bool again = Utils::isShareDirAndReadOnly(QString::fromLatin1("/any/share/other.txt")); + + // Assert + EXPECT_EQ(actual, c.expected); + EXPECT_EQ(again, c.expected); +} + +INSTANTIATE_TEST_SUITE_P( + BranchMatrix, IsShareDirTest, + ::testing::Values( + ShareDirCase{ false, false, false, QByteArray(), false }, // samba 目录不存在 + ShareDirCase{ true, false, false, QByteArray(), false }, // 共享名不存在 + ShareDirCase{ true, true, false, QByteArray(), false }, // 打开失败 + ShareDirCase{ true, true, true, QByteArray("path=/x:R,other"), true }, // 只读标记 + // 源码用 contains(":R") 子串判断,":RW" 同样命中(记录源行为) + ShareDirCase{ true, true, true, QByteArray("path=/x:RW"), true }, + ShareDirCase{ true, true, true, QByteArray("path=/x:W"), false })); // 无 ":R" 子串 + +// =========================================================================== +// getSystemLan / getSystemVersion / isWayland +// =========================================================================== +TEST_F(UtilsTest, GetSystemVersion_MajorVersionBoundary_ReturnsExpectedEra) +{ + // Arrange & Act & Assert:>=23 → V23;否则 V20 + stub.set_lamda(&DSysInfo::majorVersion, []() -> QString { return QString::fromLatin1("23"); }); + EXPECT_EQ(Utils::getSystemVersion(), Utils::V23); + stub.set_lamda(&DSysInfo::majorVersion, []() -> QString { return QString::fromLatin1("24"); }); + EXPECT_EQ(Utils::getSystemVersion(), Utils::V23); + stub.set_lamda(&DSysInfo::majorVersion, []() -> QString { return QString::fromLatin1("20"); }); + EXPECT_EQ(Utils::getSystemVersion(), Utils::V20); + stub.set_lamda(&DSysInfo::majorVersion, []() -> QString { return QString(); }); + EXPECT_EQ(Utils::getSystemVersion(), Utils::V20); +} + +TEST_F(UtilsTest, GetSystemLan_V20LangSelector_ReturnsDbusLocale) +{ + // Arrange:V20 路径走 LangSelector DBus 属性(stub;property 实为 QObject 虚函数) + stub.set_lamda(&DSysInfo::majorVersion, []() -> QString { return QString::fromLatin1("20"); }); + stub.set_lamda(VADDR(QObject, property), + [](const QObject *, const char *) -> QVariant { + return QVariant(QString::fromLatin1("zh_CN")); + }); + + // Act + const QString lang = Utils::getSystemLan(); + + // Assert:DBus 属性值;第二次命中缓存返回同值 + EXPECT_EQ(lang, QString::fromLatin1("zh_CN")); + EXPECT_EQ(Utils::getSystemLan(), QString::fromLatin1("zh_CN")); +} + +TEST_F(UtilsTest, GetSystemLan_V23SystemLocale_ReturnsSystemName) +{ + // Arrange + stub.set_lamda(&DSysInfo::majorVersion, []() -> QString { return QString::fromLatin1("23"); }); + + // Act + const QString lang = Utils::getSystemLan(); + + // Assert:与 QLocale::system().name() 一致(同进程确定);二次调用命中缓存同值 + EXPECT_EQ(lang, QLocale::system().name()); + EXPECT_EQ(Utils::getSystemLan(), lang); +} + +TEST_F(UtilsTest, IsWayland_WaylandEnvFirstCall_ReturnsTrueThenCached) +{ + // Arrange:本用例必须是全二进制首个 isWayland 调用者(静态缓存) + qputenv("XDG_SESSION_TYPE", QByteArray("wayland")); + + // Act + const bool first = Utils::isWayland(); + + // Assert:wayland 会话被识别;后续调用命中同一缓存值 + EXPECT_TRUE(first); + EXPECT_TRUE(Utils::isWayland()); // 缓存于首次调用 +} + +TEST_F(UtilsTest, IsWayland_EnvChangedAfterFirstCall_ResultStaysCached) +{ + // Arrange:上一用例首次调用已缓存 "wayland",本用例再改 env 验证缓存语义 + qputenv("XDG_SESSION_TYPE", QByteArray("x11")); + + // Act + const bool result = Utils::isWayland(); + + // Assert:静态协议缓存优先于后续 env 变化 + EXPECT_TRUE(result); + EXPECT_EQ(QString::fromLocal8Bit(qgetenv("XDG_SESSION_TYPE")), QString::fromLatin1("x11")); + qunsetenv("XDG_SESSION_TYPE"); // 与本用例 qputenv 配对还原 +} + +// =========================================================================== +// lineFeed(TEST_P) +// =========================================================================== +namespace { +struct LineFeedCase { + QString text; + int width; + int elidedRow; +}; +} // namespace + +class LineFeedTest : public UtilsTest, public ::testing::WithParamInterface { +}; + +TEST_P(LineFeedTest, LineFeed_ParamVariants_ReturnsDeterministicWrappedText) +{ + // Arrange + QFont font; + font.setPointSize(11); + QFontMetrics fm(font); + const LineFeedCase c = GetParam(); + + // Act + const QString result = Utils::lineFeed(c.text, c.width, font, c.elidedRow); + + // Assert:单行模式 = 中部省略;多行模式含换行或原文 + if (c.elidedRow == 1) { + EXPECT_EQ(result, fm.elidedText(c.text, Qt::ElideMiddle, c.width)); + } else { + const bool wrapped = result.contains(QLatin1Char('\n')); + EXPECT_TRUE(wrapped || result == c.text || result == fm.elidedText(c.text, Qt::ElideLeft, c.width)); + } +} + +INSTANTIATE_TEST_SUITE_P( + Variants, LineFeedTest, + ::testing::Values( + LineFeedCase{ QString::fromLatin1("hello"), 1000, 2 }, // 宽度足够不换行 + LineFeedCase{ QString::fromLatin1("hello world foo bar"), 40, 2 }, // 多行换行 + LineFeedCase{ QString::fromLatin1("hello"), 30, 1 }, // 单行省略 + LineFeedCase{ QString::fromLatin1("hello"), 30, -1 }, // 负数回退 2 行 + LineFeedCase{ QString(), 30, 2 })); // 空文本 + +TEST_F(UtilsTest, LineFeed_NegativeRowTreatedAsTwo) +{ + // Arrange + QFont font; + font.setPointSize(11); + + // Act:宽度小且 elidedRow=-1 → 等价 2 行;结果非空且行为与 2 行一致 + const QString result = Utils::lineFeed(QString::fromLatin1("aaaa bbbb cccc dddd"), 30, font, -1); + const QString reference = Utils::lineFeed(QString::fromLatin1("aaaa bbbb cccc dddd"), 30, font, 2); + + // Assert + EXPECT_EQ(result, reference); + EXPECT_FALSE(result.isEmpty()); +} + +// =========================================================================== +// checkRegionIntersect(TEST_P 全 6 型 + 两侧不交) +// =========================================================================== +namespace { +struct RegionCase { + int x1, y1, x2, y2; + Utils::RegionIntersectType expected; +}; +} // namespace + +class CheckRegionTest : public UtilsTest, public ::testing::WithParamInterface { +}; + +TEST_P(CheckRegionTest, CheckRegionIntersect_AllTypes_ReturnsExpectedEnum) +{ + // Act + const Utils::RegionIntersectType actual = Utils::checkRegionIntersect( + GetParam().x1, GetParam().y1, GetParam().x2, GetParam().y2); + + // Assert + EXPECT_EQ(actual, GetParam().expected); + EXPECT_EQ(Utils::checkRegionIntersect(GetParam().x1, GetParam().y1, GetParam().x2, GetParam().y2), actual); // 重复调用结果一致 +} + +INSTANTIATE_TEST_SUITE_P( + RegionTypes, CheckRegionTest, + ::testing::Values( + RegionCase{ 0, 9, 10, 15, Utils::ERight }, // 右侧不交 + RegionCase{ 0, 9, -5, -1, Utils::ELeft }, // 左侧不交 + RegionCase{ 0, 9, -5, 5, Utils::EIntersectLeft }, // 左侧重叠 + RegionCase{ 0, 9, 5, 15, Utils::EIntersectRight }, // 右侧重叠 + RegionCase{ 0, 9, -10, 10, Utils::EIntersectOutter },// 完全包含 + RegionCase{ 0, 9, 5, 6, Utils::EIntersectInner }, // 内部 + RegionCase{ 0, 9, 0, 9, Utils::EIntersectInner })); // 完全相等边界 + +// =========================================================================== +// getSupportEncoding / getSupportEncodingList(测试 qrc encodes.ini) +// =========================================================================== +TEST_F(UtilsTest, GetSupportEncoding_ReadsQrcIniGroups_ReturnsPairs) +{ + // Act + const QVector> groups = Utils::getSupportEncoding(); + + // Assert:两组,键与列表与 encodes.ini 一致 + ASSERT_EQ(groups.size(), 2); + EXPECT_EQ(groups.at(0).first, QString::fromLatin1("West")); + EXPECT_EQ(groups.at(0).second, QStringList() << QString::fromLatin1("UTF-8") << QString::fromLatin1("ISO-8859-1")); + EXPECT_EQ(groups.at(1).first, QString::fromLatin1("CJK")); + EXPECT_EQ(groups.at(1).second, QStringList() << QString::fromLatin1("GB18030") << QString::fromLatin1("Big5")); +} + +TEST_F(UtilsTest, GetSupportEncodingList_AggregatesAndSorts) +{ + // Act + const QStringList list = Utils::getSupportEncodingList(); + + // Assert:聚合 + 排序(Big5 < GB18030 < ISO-8859-1 < UTF-8) + ASSERT_EQ(list.size(), 4); + EXPECT_EQ(list, QStringList() << QString::fromLatin1("Big5") << QString::fromLatin1("GB18030") + << QString::fromLatin1("ISO-8859-1") << QString::fromLatin1("UTF-8")); + EXPECT_TRUE(list.contains(QString::fromLatin1("GB18030"))); // CJK 组已聚合 +} + +// =========================================================================== +// libPath(QDir::entryList stub) +// =========================================================================== +TEST_F(UtilsTest, LibPath_ExactMatchInList_ReturnsNameAsIs) +{ + // Arrange + stub.set_lamda(static_cast(&QDir::entryList), + [](const QDir *, const QStringList &, QDir::Filters, QDir::SortFlags) -> QStringList { + return QStringList() << QString::fromLatin1("libut.so") << QString::fromLatin1("libut.so.1"); + }); + + // Act / Assert:列表内两个精确名都原样返回 + EXPECT_EQ(Utils::libPath(QString::fromLatin1("libut.so")), QString::fromLatin1("libut.so")); + EXPECT_EQ(Utils::libPath(QString::fromLatin1("libut.so.1")), QString::fromLatin1("libut.so.1")); +} + +TEST_F(UtilsTest, LibPath_NoCandidates_ReturnsEmpty) +{ + // Arrange + stub.set_lamda(static_cast(&QDir::entryList), + [](const QDir *, const QStringList &, QDir::Filters, QDir::SortFlags) -> QStringList { + return QStringList(); + }); + + // Act / Assert:无候选返回空 + EXPECT_TRUE(Utils::libPath(QString::fromLatin1("libnone.so")).isEmpty()); + EXPECT_TRUE(Utils::libPath(QString::fromLatin1("libnone2.so")).isEmpty()); +} + +TEST_F(UtilsTest, LibPath_VersionedCandidatesOnly_ReturnsSortedLast) +{ + // Arrange + stub.set_lamda(static_cast(&QDir::entryList), + [](const QDir *, const QStringList &, QDir::Filters, QDir::SortFlags) -> QStringList { + return QStringList() << QString::fromLatin1("libv.so.10") << QString::fromLatin1("libv.so.2"); + }); + + // Act / Assert:无精确匹配 → 字典序取最大("libv.so.2" > "libv.so.10" 按字符串比较) + EXPECT_EQ(Utils::libPath(QString::fromLatin1("libv.so")), QString::fromLatin1("libv.so.2")); + EXPECT_FALSE(Utils::libPath(QString::fromLatin1("libv.so")).isEmpty()); +} + +// =========================================================================== +// loadCustomDLL(libPath + QFile::exists stub) +// =========================================================================== +TEST_F(UtilsTest, LoadCustomDLL_LibraryAbsent_ClearsZpdDllName) +{ + // Arrange:libPath 精确匹配分支返回自身;exists=false → chZPDDLL=NULL + stub.set_lamda(&Utils::libPath, + [](const QString &) -> QString { return QString::fromLatin1("libzpdcallback.so"); }); + stub.set_lamda(static_cast(&QFile::exists), + [](const QString &) -> bool { return false; }); + + // Act + Utils::loadCustomDLL(); + + // Assert:不触发 dlopen(惰性),句柄均为空 + LoadLibs *libs = getLoadZPDLibsInstance(); + ASSERT_NE(libs, nullptr); + EXPECT_EQ(libs->m_document_clip_copy, nullptr); + EXPECT_EQ(libs->m_document_close, nullptr); +} + +TEST_F(UtilsTest, LoadCustomDLL_LibraryPresent_RecordsNameForLazyLoad) +{ + // Arrange + m_callCount = 0; + m_captured.clear(); + stub.set_lamda(&Utils::libPath, + [](const QString &) -> QString { return QString::fromLatin1("/unittest-mock/zpd/libzpdcallback.so"); }); + stub.set_lamda(static_cast(&QFile::exists), + [this](const QString &path) -> bool { + ++m_callCount; + m_captured = path; + return true; + }); + + // Act + Utils::loadCustomDLL(); + + // Assert:候选库名被记录(惰性 dlopen,此处不真正加载) + EXPECT_EQ(m_callCount, 1); + EXPECT_EQ(m_captured, QString::fromLatin1("/unittest-mock/zpd/libzpdcallback.so")); +} + +// =========================================================================== +// enableClipCopy / recordCloseFile(_ZPD_ 未启用 → 恒允许) +// =========================================================================== +TEST_F(UtilsTest, EnableClipCopy_AnyPath_ReturnsTrueAndRecordCloseIsSafe) +{ + // Act / Assert:无 ZPD 定制时剪切拷贝总是允许 + EXPECT_TRUE(Utils::enableClipCopy(QString::fromLatin1("/data/secret.txt"))); + EXPECT_TRUE(Utils::enableClipCopy(QString())); + + // recordCloseFile 仅记录,无返回值;验证不抛异常 + EXPECT_NO_THROW(Utils::recordCloseFile(QString::fromLatin1("/data/secret.txt"))); +} + +// =========================================================================== +// sendFloatMessageFixedFont(offscreen 浮动消息) +// =========================================================================== +TEST_F(UtilsTest, SendFloatMessageFixedFont_PublishesFloatingMessage) +{ + // Arrange + QWidget parent; + + // Act + Utils::sendFloatMessageFixedFont(&parent, QIcon(), QString::fromLatin1("ut message")); + + // Assert:父窗口内出现一条 Transient 浮动消息 + const auto messages = parent.findChildren(); + ASSERT_EQ(messages.size(), 1); + + // Act2:触发应用字体变更信号 → fontChanged lambda → 浮动消息字体跟随 qApp + const QFont newFont = qApp->font(); + QMetaObject::invokeMethod(DGuiApplicationHelper::instance(), "fontChanged", Q_ARG(QFont, newFont)); + + // Assert2:lambda 执行后消息对象仍存活且字体已同步(强异常安全) + EXPECT_EQ(parent.findChildren().size(), 1); + EXPECT_EQ(messages.first()->font().family(), newFont.family()); +} + +TEST_F(UtilsTest, SendFloatMessageFixedFont_MoreThanThreeTransient_Throttled) +{ + // Arrange + QWidget parent; + + // Act:连续 4 次(TransientType 上限 3) + for (int i = 0; i < 3; ++i) + Utils::sendFloatMessageFixedFont(&parent, QIcon(), QString::fromLatin1("msg-%1").arg(i)); + const int afterThree = parent.findChildren().size(); + Utils::sendFloatMessageFixedFont(&parent, QIcon(), QString::fromLatin1("msg-4")); + + // Assert:第 4 条被限流 + EXPECT_EQ(afterThree, 3); + EXPECT_EQ(parent.findChildren().size(), 3); +} + +// =========================================================================== +// getSystemMemoryInfo(/proc/meminfo;open/readAll stub 控制分支) +// =========================================================================== +TEST_F(UtilsTest, GetSystemMemoryInfo_RealProcMeminfo_ParsesPositiveValues) +{ + // Arrange(Linux CI 必有 /proc/meminfo,只读) + qlonglong total = 0, freeMem = 0; + + // Act + const bool ok = Utils::getSystemMemoryInfo(total, freeMem); + + // Assert + EXPECT_TRUE(ok); + EXPECT_GT(total, 0); + EXPECT_GT(freeMem, 0); +} + +TEST_F(UtilsTest, GetSystemMemoryInfo_OpenFailed_ReturnsFalse) +{ + // Arrange + stub.set_lamda(STUB_ADDR(static_cast(&QFile::open)), + [](QFile *, QIODevice::OpenMode) -> bool { return false; }); + qlonglong total = 1, freeMem = 1; + + // Act + const bool ok = Utils::getSystemMemoryInfo(total, freeMem); + + // Assert:打开失败 → false(强异常安全:出参保持原值) + EXPECT_FALSE(ok); + EXPECT_EQ(total, 1); + EXPECT_EQ(freeMem, 1); +} + +TEST_F(UtilsTest, GetSystemMemoryInfo_NoMemAvailable_SumsFreeBuffersCached) +{ + // Arrange:无 MemAvailable 行 → free = MemFree + Buffers + Cached + const QByteArray crafted = + "MemTotal: 1000 kB\n" + "MemFree: 100 kB\n" + "Buffers: 20 kB\n" + "Cached: 30 kB\n"; + stub.set_lamda(STUB_ADDR(static_cast(&QFile::open)), + [](QFile *, QIODevice::OpenMode) -> bool { return true; }); + stub.set_lamda(&QIODevice::readAll, + [crafted](QIODevice *) -> QByteArray { return crafted; }); + qlonglong total = 0, freeMem = 0; + + // Act + const bool ok = Utils::getSystemMemoryInfo(total, freeMem); + + // Assert + EXPECT_TRUE(ok); + EXPECT_EQ(total, 1000); + EXPECT_EQ(freeMem, 150); +} + +// =========================================================================== +// isMemorySufficientForOperation(getSystemMemoryInfo stub 固定内存) +// =========================================================================== +namespace { +void stubMemory(stub_ext::StubExt &stub, qlonglong totalKb, qlonglong freeKb, bool ok = true) +{ + stub.set_lamda(&Utils::getSystemMemoryInfo, + [totalKb, freeKb, ok](qlonglong &total, qlonglong &freeMemory) -> bool { + total = totalKb; + freeMemory = freeKb; + return ok; + }); +} +} // namespace + +TEST_F(UtilsTest, IsMemorySufficientForOperation_RawOperation_RespectsAvailableMemory) +{ + // Arrange:free=500KB → available=512000B + stubMemory(stub, 1000, 500); + + // Act / Assert:超限拒绝,未超允许 + EXPECT_FALSE(Utils::isMemorySufficientForOperation(Utils::RawOperation, 600000, 0)); + EXPECT_TRUE(Utils::isMemorySufficientForOperation(Utils::RawOperation, 1000, 0)); +} + +TEST_F(UtilsTest, IsMemorySufficientForOperation_CopyOperation_AppliesNineXFactor) +{ + // Arrange:1000*9=9000 ≤ 512000;60000*9=540000 > 512000 + stubMemory(stub, 1000, 500); + + // Act / Assert + EXPECT_TRUE(Utils::isMemorySufficientForOperation(Utils::CopyOperation, 1000, 0)); + EXPECT_FALSE(Utils::isMemorySufficientForOperation(Utils::CopyOperation, 60000, 0)); +} + +TEST_F(UtilsTest, IsMemorySufficientForOperation_PasteOperation_AllSubConditions) +{ + // Arrange:小内存场景 + stubMemory(stub, 1000, 500); // available=512000B, total=1024000B + // 粘贴数据自身超限:60000*7=420000 OK;文档总量超总内存: + EXPECT_FALSE(Utils::isMemorySufficientForOperation(Utils::PasteOperation, 60000, 600000)); // (600000+60000)*7>1024000 + EXPECT_TRUE(Utils::isMemorySufficientForOperation(Utils::PasteOperation, 1000, 1000)); + + // Arrange:大内存场景触发 800MB/500KB 专门阈值 + stubMemory(stub, 100000000, 100000000); + EXPECT_FALSE(Utils::isMemorySufficientForOperation(Utils::PasteOperation, 600000, 838860900)); // doc>800MB 且 paste>500KB + EXPECT_TRUE(Utils::isMemorySufficientForOperation(Utils::PasteOperation, 400000, 838860900)); // paste 未超 500KB +} + +TEST_F(UtilsTest, IsMemorySufficientForOperation_MemoryInfoUnavailable_ConservativelyAllows) +{ + // Arrange:读不到内存信息 → 保守允许 + stubMemory(stub, 0, 0, false); + + // Act / Assert:读不到内存信息时任何操作都保守允许 + EXPECT_TRUE(Utils::isMemorySufficientForOperation(Utils::CopyOperation, 1, 1)); + EXPECT_TRUE(Utils::isMemorySufficientForOperation(Utils::PasteOperation, 1, 1)); +} + +TEST_F(UtilsTest, IsMemorySufficientForOperation_UnknownOperationType_DefaultAllows) +{ + // Arrange + stubMemory(stub, 1000, 500); + + // Act:非法枚举值 → default 分支 → 允许 + const bool ok = Utils::isMemorySufficientForOperation(static_cast(99), 100, 100); + + // Assert:任意非法值一致 + EXPECT_TRUE(ok); + EXPECT_TRUE(Utils::isMemorySufficientForOperation(static_cast(100), 100, 100)); +} diff --git a/tests/common/ut_resources.qrc.in b/tests/common/ut_resources.qrc.in new file mode 100644 index 00000000..0a54c3ca --- /dev/null +++ b/tests/common/ut_resources.qrc.in @@ -0,0 +1,12 @@ + + + + @TEST_DATA_DIR@/test.qss + @TEST_DATA_DIR@/test.svg + @TEST_DATA_DIR@/encodes.ini + @TEST_DATA_DIR@/settings.json + + diff --git a/tests/common2/CMakeLists.txt b/tests/common2/CMakeLists.txt new file mode 100644 index 00000000..bc3e59de --- /dev/null +++ b/tests/common2/CMakeLists.txt @@ -0,0 +1,88 @@ +# SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# 模块 common2:src/common 第 2 部分 + src/encodes + src/basepub +# 类:TextFileSaver / FileLoadThread / PerformanceMonitor / CSyntaxHighlighter / +# SaveFileInterface(dbusinterface) / IflytekAiAssistant / DetectCode / load_libs + +enable_language(C) # 编译 src/basepub/load_libs.c + +# C 源码同样启用 gcov 插桩(顶层仅设置了 CXX 标志) +if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fprofile-arcs -ftest-coverage -O0") +endif() + +find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets DBus Concurrent Test Core5Compat) +find_package(Dtk6 REQUIRED COMPONENTS Core Widget) +find_package(KF6SyntaxHighlighting REQUIRED) +find_package(ICU COMPONENTS i18n uc REQUIRED) +find_package(PkgConfig REQUIRED) +pkg_check_modules(chardet REQUIRED chardet) +pkg_check_modules(uchardet REQUIRED uchardet) + +set(CMAKE_AUTOMOC ON) + +file(GLOB TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/test_*.cpp") +set(STUB_SHADOW "${CMAKE_SOURCE_DIR}/tests/3rdparty/stub/stub-shadow.cpp") + +# 被测源码(含传递依赖:detectcode -> config;text_file_saver/fileloadthread -> detectcode) +# 注意:utils.cpp 为重 GUI 模块(DSettings/DMessageManager 等),不入目标; +# 其唯一被引用符号 Utils::isMemorySufficientForOperation 由测试文件本地定义并 stub。 +set(COMMON2_SOURCES + ${CMAKE_SOURCE_DIR}/src/common/text_file_saver.cpp + ${CMAKE_SOURCE_DIR}/src/common/fileloadthread.cpp + ${CMAKE_SOURCE_DIR}/src/common/performancemonitor.cpp + ${CMAKE_SOURCE_DIR}/src/common/CSyntaxHighlighter.cpp + ${CMAKE_SOURCE_DIR}/src/common/dbusinterface.cpp + ${CMAKE_SOURCE_DIR}/src/common/iflytek_ai_assistant.cpp + ${CMAKE_SOURCE_DIR}/src/common/config.cpp + ${CMAKE_SOURCE_DIR}/src/encodes/detectcode.cpp + ${CMAKE_SOURCE_DIR}/src/basepub/load_libs.c +) + +add_executable(test_common2 + ${TEST_SOURCES} + ${COMMON2_SOURCES} + ${STUB_SHADOW} +) + +target_include_directories(test_common2 + PRIVATE + ${CMAKE_SOURCE_DIR}/tests/3rdparty/stub + ${CMAKE_SOURCE_DIR}/src/common + ${CMAKE_SOURCE_DIR}/src/encodes + ${CMAKE_SOURCE_DIR}/src/basepub + ${chardet_INCLUDE_DIRS} + ${uchardet_INCLUDE_DIRS} + ${ICU_INCLUDE_DIRS} +) + +target_link_libraries(test_common2 + PRIVATE + GTest::gtest + GTest::gtest_main + Qt6::Core + Qt6::Gui + Qt6::Widgets + Qt6::DBus + Qt6::Concurrent + Qt6::Test + Qt6::Core5Compat + Dtk6::Core + Dtk6::Widget + KF6::SyntaxHighlighting + ${ICU_LIBRARIES} + ${chardet_LIBRARIES} + ${uchardet_LIBRARIES} + pthread + dl +) + +if(CMAKE_BUILD_TYPE STREQUAL "Debug") + target_link_libraries(test_common2 PRIVATE gcov) +endif() + +gtest_discover_tests(test_common2 PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + +message(STATUS "UT: test_common2 configured with ${TEST_SOURCES}") diff --git a/tests/common2/test_csyntaxhighlighter.cpp b/tests/common2/test_csyntaxhighlighter.cpp new file mode 100644 index 00000000..950aa495 --- /dev/null +++ b/tests/common2/test_csyntaxhighlighter.cpp @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +/* + * CSyntaxHighlighter 单元测试 + * + * 分支清单(来源:CSyntaxHighlighter::highlightBlock / setInvalidCharHighlight) + * B1 : !m_bHighlight → 直接 return(不高亮,无格式区间) + * B2 : m_bHighlight && !invalid → 仅执行 KSyntaxHighlighting 基类高亮 + * B3 : m_bHighlight && invalid → 基类高亮 + "\\00" 序列红底白字格式 + * B4 : setInvalidCharHighlight(true) → 联动开启 m_bHighlight 并 rehighlight + * B5 : setInvalidCharHighlight(false) → 仅关闭 invalid,联动 rehighlight + * B6 : 多个 "\\00" 匹配 → while 迭代为每处设置格式 + * + * 用例映射: + * - Ctor_QObjectParent_DefaultDisabled_NoFormatRange → B1(QObject* 构造) + * - Ctor_DocumentParent_DefaultDisabled_NoFormatRange → B1(QTextDocument* 构造) + * - SetEnableHighlight_TrueWithoutInvalidFlag_NoRedMarks → B2 + * - SetInvalidCharHighlight_True_MarksEachMatchRed → B3+B4+B6 + * - SetInvalidCharHighlight_False_RemovesRedMarks → B5 + * - SetEnableHighlight_Toggle_MarksFollowLatestState → B1/B3 组合 + * + * 隔离:QTextDocument + offscreen QGuiApplication,不涉及窗口系统; + * 断言经由块布局 QTextLayout::formats()(高亮器最终把格式区间应用到块布局, + * 为最稳定的可观察出口);高亮驱动统一使用公开接口 rehighlight()。 + */ + +#include +#include "stubext.h" + +#include "CSyntaxHighlighter.h" + +#include +#include +#include +#include +#include + +namespace { + +QCoreApplication *ensureApp() +{ + if (!QCoreApplication::instance()) { + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) + qputenv("QT_QPA_PLATFORM", "offscreen"); + static int argc = 1; + static char argv0[] = "test_common2"; + static char *argv[2] = { argv0, nullptr }; + static QGuiApplication app(argc, argv); + return &app; + } + return QCoreApplication::instance(); +} + +const QColor kInvalidBg(QStringLiteral("#FF0000")); +const QColor kInvalidFg(QStringLiteral("#FFFFFF")); + +// 统计块布局中红底格式区间数量(无效字符标记的可观察出口) +int redMarkCount(const QTextDocument *doc) +{ + int count = 0; + const QList ranges = doc->firstBlock().layout()->formats(); + for (const QTextLayout::FormatRange &r : ranges) { + if (r.format.background().color() == kInvalidBg) + ++count; + } + return count; +} + +// 统计白字前景区间数量(仅无效字符标记会设置白前景) +int whiteForegroundCount(const QTextDocument *doc) +{ + int count = 0; + const QList ranges = doc->firstBlock().layout()->formats(); + for (const QTextLayout::FormatRange &r : ranges) { + if (r.format.foreground().color() == kInvalidFg) + ++count; + } + return count; +} + +QList redMarks(const QTextDocument *doc) +{ + QList out; + const QList ranges = doc->firstBlock().layout()->formats(); + for (const QTextLayout::FormatRange &r : ranges) { + if (r.format.background().color() == kInvalidBg) + out.append(r); + } + return out; +} + +} // namespace + +class CSyntaxHighlighterTest : public ::testing::Test +{ +protected: + void SetUp() override + { + ensureApp(); + stub.clear(); + doc = new QTextDocument; + } + + void TearDown() override + { + stub.clear(); + delete doc; + doc = nullptr; + } + + QTextDocument *doc = nullptr; + stub_ext::StubExt stub; +}; + +// B1(QObject* 父对象构造:默认不高亮) +TEST_F(CSyntaxHighlighterTest, Ctor_QObjectParent_DefaultDisabled_NoFormatRange) +{ + // Arrange + CSyntaxHighlighter highlighter; + highlighter.setDocument(doc); + doc->setPlainText(QStringLiteral("a\\00b")); + // Act + highlighter.rehighlight(); + // Assert: 未开启高亮 → 不产生任何格式区间(含红底标记) + EXPECT_EQ(redMarkCount(doc), 0); + EXPECT_TRUE(doc->firstBlock().layout()->formats().isEmpty()); +} + +// B1(QTextDocument* 构造) +TEST_F(CSyntaxHighlighterTest, Ctor_DocumentParent_DefaultDisabled_NoFormatRange) +{ + // Arrange + CSyntaxHighlighter highlighter(doc); + doc->setPlainText(QStringLiteral("x\\00y")); + // Act + highlighter.rehighlight(); + // Assert + EXPECT_EQ(redMarkCount(doc), 0); + EXPECT_TRUE(doc->firstBlock().layout()->formats().isEmpty()); +} + +// B2 +TEST_F(CSyntaxHighlighterTest, SetEnableHighlight_TrueWithoutInvalidFlag_NoRedMarks) +{ + // Arrange + CSyntaxHighlighter highlighter(doc); + doc->setPlainText(QStringLiteral("a\\00b")); + highlighter.setEnableHighlight(true); + // Act + highlighter.rehighlight(); + // Assert: 已开启高亮但未开启无效字符标记 → 无红底/白字区间 + //(K 基类无 Definition 时可能产生整块默认格式区间,属正常,不算无效字符标记) + EXPECT_EQ(redMarkCount(doc), 0); + EXPECT_EQ(whiteForegroundCount(doc), 0); +} + +// B3+B4+B6 +TEST_F(CSyntaxHighlighterTest, SetInvalidCharHighlight_True_MarksEachMatchRed) +{ + // Arrange: 两个 "\\00" 匹配(覆盖 while 迭代为每处设置格式) + CSyntaxHighlighter highlighter(doc); + doc->setPlainText(QStringLiteral("a\\00b\\00c")); + // Act: setInvalidCharHighlight(true) 联动开启高亮并自动 rehighlight + highlighter.setInvalidCharHighlight(true); + // Assert: 两处 "\\00"(位置 1、5)各产生一个红底区间 + ASSERT_EQ(redMarkCount(doc), 2); + const QList marks = redMarks(doc); + EXPECT_EQ(marks.first().start, 1); + EXPECT_EQ(marks.first().length, 3); // '\\' '0' '0' + EXPECT_EQ(marks.last().start, 5); + EXPECT_EQ(marks.last().length, 3); + EXPECT_EQ(marks.first().format.foreground().color(), kInvalidFg); +} + +// B5 +TEST_F(CSyntaxHighlighterTest, SetInvalidCharHighlight_False_RemovesRedMarks) +{ + // Arrange: 先开启标记产生格式 + CSyntaxHighlighter highlighter(doc); + doc->setPlainText(QStringLiteral("a\\00b")); + highlighter.setInvalidCharHighlight(true); + ASSERT_EQ(redMarkCount(doc), 1); + // Act + highlighter.setInvalidCharHighlight(false); + // Assert: 关闭后 rehighlight 清除标记;高亮总开关仍为开(基类高亮继续不崩溃) + EXPECT_EQ(redMarkCount(doc), 0); + EXPECT_EQ(whiteForegroundCount(doc), 0); +} + +// B1/B3 组合:显式 enable 后切换,格式跟随最新状态 +TEST_F(CSyntaxHighlighterTest, SetEnableHighlight_Toggle_MarksFollowLatestState) +{ + // Arrange + CSyntaxHighlighter highlighter(doc); + doc->setPlainText(QStringLiteral("\\00")); + highlighter.setEnableHighlight(true); + highlighter.setInvalidCharHighlight(true); + ASSERT_EQ(redMarkCount(doc), 1); + // Act: 关闭总开关后重新 rehighlight + highlighter.setEnableHighlight(false); + highlighter.rehighlight(); + // Assert: 总开关关闭 → 任何格式都不再设置(B1 分支) + EXPECT_EQ(redMarkCount(doc), 0); + EXPECT_TRUE(doc->firstBlock().layout()->formats().isEmpty()); +} diff --git a/tests/common2/test_detectcode.cpp b/tests/common2/test_detectcode.cpp new file mode 100644 index 00000000..002aec6e --- /dev/null +++ b/tests/common2/test_detectcode.cpp @@ -0,0 +1,892 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +/* + * DetectCode 单元测试 + * + * 分支清单(来源:detectcode.cpp 全部导出函数) + * GetFileEncodingFormat: + * G1 中文内容分支(附加中文字符+尾部重试) G2 非中文低置信重试(chop) + * G3 需 uchardet 兜底 G4 ASCII → 默认编码 + * G5 非 ASCII → icu + selectCoding G6 结果空/ASCII → 默认编码 + * UchardetCode: U1 文件打开失败 U2 正常识别 U3 MAC-CENTRALEUROPE 纠正 + * U4 MAC-CYRILLIC 纠正 U5 WINDOWS-→CP 纠正 U6 handle_data 非零 continue + * icuDetectTextEncoding: I1 打开失败 I2 正常识别 I3 超 1MB 读上限 break + * detectTextEncoding: D1 正常返回列表(readMax 3/6 分支) + * selectCoding: S1 icu 空 S2 低置信+中文+GB18030 S3 包含 S4 含 GB18030 + * S5 icu[0] 包含 uchar S6 兜底 uchar S7 空 uchar + * ChartDet_DetectingTextCoding: C1 ASCII C2 UTF-8 中文 C3 空串 + * ChangeFileEncodingFormat: E1 同码 E2 空输入 E3 GB18030↔UTF-8 E4 BOM 附加 + * E5 EILSEQ '?' 替换 E6 EINVAL 截断 E7 非法码 false E8 PUA E9 补丁 iconv 路径 + * convertEncodingTextCodec: T1/T2 正反向 T3/T4 非法码 T5 BOM 尾部 + * 全局函数: utf8MultiByteCount / checkGB18030ToUtf8Error / checkUTF8ToGB18030Error + * + * 用例映射见各 TEST 名称(分支号在注释中标注)。 + * + * 隔离: + * - 文件全部经 QTemporaryDir; + * - Config 三个读取方法在需要固定取值的用例中 stub(Config 真实方法另设用例直跑, + * 保证 FN 覆盖);QLocale::system 固定 stub 为中文(selectCoding 语言分支确定性); + * - iconv/glibc 行为(无 BOM UTF-16 按 LE、GB18030 PUA 82359037→EEA09E)已实测固定。 + */ + +#include +#include "stubext.h" + +#include "../encodes/detectcode.h" +#include "../common/config.h" + +#include +#include +#include +#include + +#include + +// detectcode.cpp 中的全局导出函数(头文件未声明,按定义补充声明以便直测) +int utf8MultiByteCount(char *buf, size_t size); +bool checkGB18030ToUtf8Error(char *buf, size_t size, size_t &replaceLen, QByteArray &appendChar); +bool checkUTF8ToGB18030Error(char *buf, size_t size, size_t &replaceLen, QByteArray &appendChar); + +namespace { + +QCoreApplication *ensureApp() +{ + if (!QCoreApplication::instance()) { + static int argc = 1; + static char argv0[] = "test_common2"; + static char *argv[2] = { argv0, nullptr }; + static QCoreApplication app(argc, argv); + return &app; + } + return QCoreApplication::instance(); +} + +} // namespace + +// ---------------- 参数化用例数据 ---------------- + +struct Utf8CountCase { + QByteArray bytes; + int expected; +}; + +struct Gb2Utf8ErrCase { + QByteArray bytes; + int size; + bool found; + QByteArray append; + int replaceLen; +}; + +struct Utf2GbErrCase { + QByteArray bytes; + int size; + bool found; + QByteArray append; + int replaceLen; +}; + +struct SelectCase { + QByteArray ucharRet; + QByteArrayList icu; + float conf; + bool improve; + bool chinese; + QByteArray expected; +}; + +struct TextCodecCase { + QByteArray input; + QString from; + QString to; + bool ok; + QByteArray expected; +}; + +// ---------------- Fixture ---------------- + +class DetectCodeTest : public ::testing::Test +{ +protected: + void SetUp() override + { + ensureApp(); + stub.clear(); + chardetCalls = 0; + uchardetCalls = 0; + icuOuterCalls = 0; + detectInnerCalls = 0; + tmpDir = std::make_unique(); + ASSERT_TRUE(tmpDir->isValid()); + // 固定系统语言为中文,selectCoding 的中文优先分支完全确定 + installLocaleStub(true); + } + + void TearDown() override + { + stub.clear(); + } + + void installLocaleStub(bool chinese) + { + const QLocale::Language lang = chinese ? QLocale::Chinese : QLocale::English; + stub.set_lamda(&QLocale::system, [lang]() -> QLocale { + return QLocale(lang); + }); + } + + void installConfigStubs(bool improve, bool patched, const QByteArray &defEncoding) + { + stub.set_lamda(static_cast(&Config::enableImproveGB18030), + [improve]() -> bool { + return improve; + }); + stub.set_lamda(static_cast(&Config::enablePatchedIconv), + [patched]() -> bool { + return patched; + }); + stub.set_lamda(static_cast(&Config::defaultEncoding), + [defEncoding]() -> QByteArray { + return defEncoding; + }); + } + + QString writeTempFile(const QString &name, const QByteArray &data) + { + const QString path = tmpDir->path() + QLatin1Char('/') + name; + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return QString(); + f.write(data); + f.close(); + return path; + } + + stub_ext::StubExt stub; + std::unique_ptr tmpDir; + int chardetCalls = 0; + int uchardetCalls = 0; + int icuOuterCalls = 0; + int detectInnerCalls = 0; +}; + +// ---------------- utf8MultiByteCount(纯函数,TEST_P) ---------------- + +class DetectCodeUtf8CountTest : public DetectCodeTest, + public ::testing::WithParamInterface +{ +}; + +TEST_P(DetectCodeUtf8CountTest, Utf8MultiByteCount_ByLeadBytes_ReturnsExpectedCount) +{ + // Arrange + const auto &c = GetParam(); + QByteArray buf = c.bytes; // 拷贝为可写缓冲(data() 返回 char*) + ASSERT_FALSE(buf.isEmpty()); + // Act + const int ret = utf8MultiByteCount(buf.data(), size_t(buf.size())); + // Assert + EXPECT_EQ(ret, c.expected); + EXPECT_EQ(int(buf.size()), c.bytes.size()); // 输入不被修改 +} + +INSTANTIATE_TEST_SUITE_P( + Utf8CountCases, DetectCodeUtf8CountTest, + ::testing::Values( + Utf8CountCase{QByteArrayLiteral("a"), 1}, // 单字节 + Utf8CountCase{QByteArray::fromHex("C3A9"), 2}, // 双字节 + Utf8CountCase{QByteArray::fromHex("E4B8AD"), 3}, // 三字节 + Utf8CountCase{QByteArray::fromHex("F09F9880"), 4}, // 四字节 + Utf8CountCase{QByteArray::fromHex("80C3"), 2}, // Mid 后遇双字节 + Utf8CountCase{QByteArray::fromHex("80808080") + QByteArray::fromHex("C3"), 4})); // Mid 计满 4 退出 + +TEST_F(DetectCodeTest, Utf8MultiByteCount_ZeroSize_ReturnsZero) +{ + // Arrange + char dummy = 'a'; + // Act + const int ret = utf8MultiByteCount(&dummy, 0); + // Assert: size==0 不进入循环 + EXPECT_EQ(ret, 0); + EXPECT_EQ(dummy, 'a'); +} + +// ---------------- checkGB18030ToUtf8Error(纯函数,TEST_P) ---------------- + +class DetectCodeGb2Utf8ErrTest : public DetectCodeTest, + public ::testing::WithParamInterface +{ +}; + +TEST_P(DetectCodeGb2Utf8ErrTest, Gb2Utf8Error_ByInputBytes_ReturnsExpectedMapping) +{ + // Arrange + const auto &c = GetParam(); + QByteArray buf = c.bytes; + size_t replaceLen = 0; + QByteArray appendChar; + // Act + const bool found = checkGB18030ToUtf8Error(buf.data(), size_t(c.size), replaceLen, appendChar); + // Assert + EXPECT_EQ(found, c.found); + EXPECT_EQ(appendChar, c.append); + EXPECT_EQ(int(replaceLen), c.replaceLen); +} + +INSTANTIATE_TEST_SUITE_P( + Gb2Utf8ErrCases, DetectCodeGb2Utf8ErrTest, + ::testing::Values( + Gb2Utf8ErrCase{QByteArrayLiteral("AB"), 2, false, QByteArrayLiteral("?"), 1}, // 长度不足 + Gb2Utf8ErrCase{QByteArray::fromHex("41424344"), 4, false, QByteArrayLiteral("?"), 1}, // 无匹配 + Gb2Utf8ErrCase{QByteArray::fromHex("82359037"), 4, true, QByteArray::fromHex("EEA09E"), 4}, // PUA 0x37903582 → \uE81E + Gb2Utf8ErrCase{QByteArray::fromHex("FFFFFF01"), 4, true, QByteArray::fromHex("EEA096"), 4})); // FFFFFF01 → \uE816 + +// ---------------- checkUTF8ToGB18030Error(纯函数,TEST_P) ---------------- + +class DetectCodeUtf2GbErrTest : public DetectCodeTest, + public ::testing::WithParamInterface +{ +}; + +TEST_P(DetectCodeUtf2GbErrTest, Utf2GbError_ByInputBytes_ReturnsExpectedMapping) +{ + // Arrange + const auto &c = GetParam(); + QByteArray buf = c.bytes; + size_t replaceLen = 0; + QByteArray appendChar; + // Act + const bool found = checkUTF8ToGB18030Error(buf.data(), size_t(c.size), replaceLen, appendChar); + // Assert + EXPECT_EQ(found, c.found); + EXPECT_EQ(appendChar, c.append); + EXPECT_EQ(int(replaceLen), c.replaceLen); +} + +INSTANTIATE_TEST_SUITE_P( + Utf2GbErrCases, DetectCodeUtf2GbErrTest, + ::testing::Values( + Utf2GbErrCase{QByteArrayLiteral("AB"), 2, false, QByteArrayLiteral("?"), 1}, // 长度不足 + Utf2GbErrCase{QByteArrayLiteral("ABC"), 3, false, QByteArrayLiteral("?"), 1}, // 无匹配 + Utf2GbErrCase{QByteArray::fromHex("EEA09E"), 3, true, QByteArray::fromHex("82359037"), 3}, // \uE81E → PUA GB 码 + Utf2GbErrCase{QByteArray::fromHex("EEA096"), 3, true, QByteArray::fromHex("FE51"), 3}, // \uE816 → 0xFE51 + Utf2GbErrCase{QByteArray::fromHex("FFFF11"), 3, true, QByteArray::fromHex("95329031"), 3})); // 标识 → 0x95329031 + +// ---------------- selectCoding(纯函数,TEST_P) ---------------- + +class DetectCodeSelectTest : public DetectCodeTest, + public ::testing::WithParamInterface +{ +}; + +TEST_P(DetectCodeSelectTest, SelectCoding_ByInputs_ReturnsExpectedEncoding) +{ + // Arrange + const auto &c = GetParam(); + installConfigStubs(c.improve, false, QByteArrayLiteral("UTF-8")); + installLocaleStub(c.chinese); + const QByteArrayList icuCopy = c.icu; + // Act + const QByteArray ret = DetectCode::selectCoding(c.ucharRet, c.icu, c.conf); + // Assert + EXPECT_EQ(ret, c.expected); + EXPECT_EQ(c.icu, icuCopy); // 输入列表不被修改 +} + +INSTANTIATE_TEST_SUITE_P( + SelectCases, DetectCodeSelectTest, + ::testing::Values( + SelectCase{QByteArrayLiteral("UTF-8"), {}, 0.99f, true, true, QByteArrayLiteral("")}, // S1 icu 空 + SelectCase{QByteArrayLiteral("UTF-8"), {QByteArrayLiteral("UTF-8"), QByteArrayLiteral("GB18030")}, 0.99f, true, true, QByteArrayLiteral("UTF-8")}, // S3 包含 + SelectCase{QByteArrayLiteral("GBK"), {QByteArrayLiteral("GB18030")}, 0.5f, true, true, QByteArrayLiteral("GB18030")}, // S2 低置信中文 GB + SelectCase{QByteArrayLiteral("UTF-8"), {QByteArrayLiteral("UTF-8")}, 0.5f, true, false, QByteArrayLiteral("UTF-8")}, // S2 反例:非中文 + SelectCase{QByteArrayLiteral("BIG5"), {QByteArrayLiteral("GB18030")}, 0.99f, false, true, QByteArrayLiteral("GB18030")},// S4 含 GB18030 + SelectCase{QByteArrayLiteral("UTF-16"), {QByteArrayLiteral("UTF-16BE")}, 0.99f, false, true, QByteArrayLiteral("UTF-16BE")}, // S5 icu[0] 包含 + SelectCase{QByteArrayLiteral("EUC-JP"), {QByteArrayLiteral("SHIFT-JIS")}, 0.99f, false, true, QByteArrayLiteral("EUC-JP")}, // S6 兜底 + SelectCase{QByteArrayLiteral(""), {QByteArrayLiteral("GB18030")}, 0.99f, false, true, QByteArrayLiteral("GB18030")}, // S7 空 uchar+GB + SelectCase{QByteArrayLiteral(""), {QByteArrayLiteral("UTF-16LE")}, 0.99f, false, true, QByteArrayLiteral("UTF-16LE")})); // S7 空 uchar+首项 + +// ---------------- convertEncodingTextCodec(TEST_P) ---------------- + +class DetectCodeTextCodecTest : public DetectCodeTest, + public ::testing::WithParamInterface +{ +}; + +TEST_P(DetectCodeTextCodecTest, ConvertEncodingTextCodec_ByCodes_ReturnsExpectedOutput) +{ + // Arrange + const auto &c = GetParam(); + QByteArray input = c.input; + QByteArray out; + // Act + const bool ok = DetectCode::convertEncodingTextCodec(input, out, c.from, c.to); + // Assert + EXPECT_EQ(ok, c.ok); + if (c.ok) + EXPECT_EQ(out, c.expected); + else + EXPECT_TRUE(out.isEmpty()); // 失败时不产生输出 +} + +INSTANTIATE_TEST_SUITE_P( + TextCodecCases, DetectCodeTextCodecTest, + ::testing::Values( + TextCodecCase{QStringLiteral("中文").toUtf8(), QStringLiteral("UTF-8"), QStringLiteral("GB18030"), true, QByteArray::fromHex("D6D0CEC4")}, + TextCodecCase{QByteArray::fromHex("D6D0CEC4"), QStringLiteral("GB18030"), QStringLiteral("UTF-8"), true, QStringLiteral("中文").toUtf8()}, + TextCodecCase{QByteArrayLiteral("abc"), QStringLiteral("UTF-8"), QStringLiteral("UT-NONE-T"), false, QByteArrayLiteral("")}, + TextCodecCase{QByteArray::fromHex("D6D0"), QStringLiteral("UT-NONE-F"), QStringLiteral("UTF-8"), false, QByteArrayLiteral("")}, + // QTextCodec 路径 BOM 附加在转换数据之后(尾部) + TextCodecCase{QByteArrayLiteral("hi"), QStringLiteral("UTF-8"), QStringLiteral("UTF-16LE"), true, QByteArray::fromHex("68006900FFFE")})); + +// ---------------- ChartDet_DetectingTextCoding(真实 chardet) ---------------- + +TEST_F(DetectCodeTest, ChartDet_DetectingTextCoding_AsciiInput_ReturnsAsciiHighConfidence) +{ + // Arrange + QString encoding; + float confidence = 0.0f; + // Act + const int ret = DetectCode::ChartDet_DetectingTextCoding("hello world", encoding, confidence); + // Assert + EXPECT_EQ(ret, CHARDET_SUCCESS); + EXPECT_EQ(encoding, QStringLiteral("ASCII")); + EXPECT_GE(confidence, 0.9f); +} + +TEST_F(DetectCodeTest, ChartDet_DetectingTextCoding_ChineseUtf8_ReturnsUtf8HighConfidence) +{ + // Arrange + const QByteArray sample = QStringLiteral("这是一段用于编码探测的中文样本文本").toUtf8(); + const std::string raw(sample.constData(), size_t(sample.size())); + QString encoding; + float confidence = 0.0f; + // Act + const int ret = DetectCode::ChartDet_DetectingTextCoding(raw.c_str(), encoding, confidence); + // Assert + EXPECT_EQ(ret, CHARDET_SUCCESS); + EXPECT_EQ(encoding, QStringLiteral("UTF-8")); + EXPECT_GE(confidence, 0.9f); +} + +TEST_F(DetectCodeTest, ChartDet_DetectingTextCoding_EmptyInput_ReturnsSuccessWithNoCrash) +{ + // Arrange + QString encoding = QStringLiteral("sentinel"); + float confidence = -1.0f; + // Act + const int ret = DetectCode::ChartDet_DetectingTextCoding("", encoding, confidence); + // Assert: 空串安全返回,出参被重置语义不崩溃 + EXPECT_EQ(ret, CHARDET_SUCCESS); + EXPECT_GE(confidence, 0.0f); +} + +// ---------------- UchardetCode ---------------- + +TEST_F(DetectCodeTest, UchardetCode_AsciiFile_ReturnsAscii) +{ + // Arrange + const QString path = writeTempFile(QStringLiteral("uc_ascii.txt"), QByteArrayLiteral("plain ascii text")); + ASSERT_FALSE(path.isEmpty()); + // Act + const QByteArray ret = DetectCode::UchardetCode(path); + // Assert + EXPECT_EQ(ret, QByteArrayLiteral("ASCII")); + EXPECT_EQ(ret.size(), 5); // "ASCII" 五字符 +} + +TEST_F(DetectCodeTest, UchardetCode_MissingFile_ReturnsEmpty) +{ + // Arrange: 不存在的文件 → fopen 失败分支 + const QString path = tmpDir->path() + QStringLiteral("/uc_missing.txt"); + // Act + const QByteArray ret = DetectCode::UchardetCode(path); + const QByteArray ret2 = DetectCode::UchardetCode(tmpDir->path() + QStringLiteral("/uc_missing2.txt")); + // Assert: 任意不存在路径均安全返回空 + EXPECT_TRUE(ret.isEmpty()); + EXPECT_TRUE(ret2.isEmpty()); +} + +TEST_F(DetectCodeTest, UchardetCode_MacCentralEurope_CorrectedWithoutDash) +{ + // Arrange: stub uchardet_get_charset 返回待纠正的字符集名 + int charsetCalls = 0; + stub.set_lamda(uchardet_get_charset, [&charsetCalls](uchardet_t) -> const char * { + ++charsetCalls; + return "MAC-CENTRALEUROPE"; + }); + const QString path = writeTempFile(QStringLiteral("uc_mac.txt"), QByteArrayLiteral("x")); + ASSERT_FALSE(path.isEmpty()); + // Act + const QByteArray ret = DetectCode::UchardetCode(path); + // Assert + EXPECT_EQ(ret, QByteArrayLiteral("MACCENTRALEUROPE")); + EXPECT_EQ(charsetCalls, 1); +} + +TEST_F(DetectCodeTest, UchardetCode_MacCyrillic_CorrectedWithoutDash) +{ + // Arrange + int charsetCalls = 0; + stub.set_lamda(uchardet_get_charset, [&charsetCalls](uchardet_t) -> const char * { + ++charsetCalls; + return "MAC-CYRILLIC"; + }); + const QString path = writeTempFile(QStringLiteral("uc_cy.txt"), QByteArrayLiteral("x")); + ASSERT_FALSE(path.isEmpty()); + // Act + const QByteArray ret = DetectCode::UchardetCode(path); + // Assert + EXPECT_EQ(ret, QByteArrayLiteral("MACCYRILLIC")); + EXPECT_EQ(charsetCalls, 1); +} + +TEST_F(DetectCodeTest, UchardetCode_WindowsCharset_CorrectedToCpPrefix) +{ + // Arrange + int charsetCalls = 0; + stub.set_lamda(uchardet_get_charset, [&charsetCalls](uchardet_t) -> const char * { + ++charsetCalls; + return "WINDOWS-1251"; + }); + const QString path = writeTempFile(QStringLiteral("uc_win.txt"), QByteArrayLiteral("x")); + ASSERT_FALSE(path.isEmpty()); + // Act + const QByteArray ret = DetectCode::UchardetCode(path); + // Assert + EXPECT_EQ(ret, QByteArrayLiteral("CP1251")); + EXPECT_EQ(charsetCalls, 1); +} + +TEST_F(DetectCodeTest, UchardetCode_HandleDataFails_SkipsAndStillReturnsCharset) +{ + // Arrange: handle_data 返回非零 → continue 分支 + int handleCalls = 0; + stub.set_lamda(uchardet_handle_data, [&handleCalls](uchardet_t, const char *, size_t) -> int { + ++handleCalls; + return 1; + }); + stub.set_lamda(uchardet_get_charset, [](uchardet_t) -> const char * { + return "ASCII"; + }); + const QString path = writeTempFile(QStringLiteral("uc_hd.txt"), QByteArrayLiteral("abc")); + ASSERT_FALSE(path.isEmpty()); + // Act + const QByteArray ret = DetectCode::UchardetCode(path); + // Assert: 数据处理失败被跳过,仍完成探测流程 + EXPECT_EQ(ret, QByteArrayLiteral("ASCII")); + EXPECT_GE(handleCalls, 1); +} + +// ---------------- icuDetectTextEncoding / detectTextEncoding ---------------- + +TEST_F(DetectCodeTest, IcuDetectTextEncoding_Utf8File_ReturnsNonEmptyList) +{ + // Arrange + const QString path = writeTempFile(QStringLiteral("icu_utf8.txt"), + QStringLiteral("english content 中文内容").toUtf8()); + ASSERT_FALSE(path.isEmpty()); + QByteArrayList list; + // Act + DetectCode::icuDetectTextEncoding(path, list); + // Assert + EXPECT_GE(list.size(), 1); + EXPECT_LE(list.size(), 6); // readMax = min(6 或 3, matchCount) + EXPECT_FALSE(list.first().isEmpty()); +} + +TEST_F(DetectCodeTest, IcuDetectTextEncoding_MissingFile_LeavesListEmpty) +{ + // Arrange + const QString path = tmpDir->path() + QStringLiteral("/icu_missing.txt"); + QByteArrayList list; + // Act + DetectCode::icuDetectTextEncoding(path, list); + // Assert + EXPECT_TRUE(list.isEmpty()); + EXPECT_EQ(list.size(), 0); +} + +TEST_F(DetectCodeTest, IcuDetectTextEncoding_OverReadLimit_StopsAfterOneMb) +{ + // Arrange: 内层 detectTextEncoding 恒 false → 持续读取直至超 1MB 上限 break + stub.set_lamda(&DetectCode::detectTextEncoding, + [this](const char *, size_t, char **, QByteArrayList &) -> bool { + ++detectInnerCalls; + return false; + }); + QByteArray big(1200 * 1024, 'a'); + const QString path = writeTempFile(QStringLiteral("icu_big.txt"), big); + ASSERT_FALSE(path.isEmpty()); + QByteArrayList list; + // Act + DetectCode::icuDetectTextEncoding(path, list); + // Assert: 4096 字节/块,1MB ≈ 256 块后触发上限 + EXPECT_TRUE(list.isEmpty()); + EXPECT_GE(detectInnerCalls, 250); +} + +TEST_F(DetectCodeTest, DetectTextEncoding_AsciiData_ReturnsTrueWithList) +{ + // Arrange + QByteArrayList list; + // Act + const bool ok = DetectCode::detectTextEncoding("hello", 5, nullptr, list); + // Assert + EXPECT_TRUE(ok); + EXPECT_GE(list.size(), 1); + EXPECT_LE(list.size(), 6); +} + +// ---------------- GetFileEncodingFormat(stub 分支控制 + 真实集成) ---------------- + +TEST_F(DetectCodeTest, GetFileEncodingFormat_ChineseLowConfidence_RetriesThenUtf8) +{ + // Arrange: G1 中文分支 + 尾部重试一次达标 + const QByteArray content = QStringLiteral("中文内容 abc").toUtf8(); + const QString path = writeTempFile(QStringLiteral("gf_zh.txt"), content); + ASSERT_FALSE(path.isEmpty()); + int idx = 0; + const float confs[2] = { 0.5f, 0.99f }; + stub.set_lamda(&DetectCode::ChartDet_DetectingTextCoding, + [this, &idx, &confs](const char *, QString &encoding, float &confidence) -> int { + ++chardetCalls; + encoding = QStringLiteral("UTF-8"); + confidence = confs[idx < 1 ? idx++ : 1]; + return CHARDET_SUCCESS; + }); + stub.set_lamda(&DetectCode::UchardetCode, + [this](QString) -> QByteArray { + ++uchardetCalls; + return QByteArrayLiteral("UTF-8"); + }); + stub.set_lamda(&DetectCode::icuDetectTextEncoding, + [](const QString &, QByteArrayList &list) { + list.clear(); + list << QByteArrayLiteral("UTF-8"); + }); + // Act + const QByteArray ret = DetectCode::GetFileEncodingFormat(path, content); + // Assert: 初次 + 1 次重试;置信度达标不落 uchardet + EXPECT_EQ(ret, QByteArrayLiteral("UTF-8")); + EXPECT_EQ(chardetCalls, 2); + EXPECT_EQ(uchardetCalls, 0); +} + +TEST_F(DetectCodeTest, GetFileEncodingFormat_NonChineseLowConfidence_RetriesFiveTimesThenDefault) +{ + // Arrange: G2 非中文 chop 重试 5 次(tryCount=5)仍低置信 → G3 uchardet 兜底 → G4 ASCII → 默认编码 + const QByteArray content = QByteArrayLiteral("abcdef"); + const QString path = writeTempFile(QStringLiteral("gf_retry.txt"), content); + ASSERT_FALSE(path.isEmpty()); + stub.set_lamda(&DetectCode::ChartDet_DetectingTextCoding, + [this](const char *, QString &encoding, float &confidence) -> int { + ++chardetCalls; + encoding = QStringLiteral("ASCII"); + confidence = 0.5f; + return CHARDET_SUCCESS; + }); + stub.set_lamda(&DetectCode::icuDetectTextEncoding, + [this](const QString &, QByteArrayList &) { + ++icuOuterCalls; + }); + // Act + const QByteArray ret = DetectCode::GetFileEncodingFormat(path, content); + // Assert: 1 次初始 + 5 次重试;ASCII 走默认编码(真实文件被 uchardet 识别为 ASCII) + EXPECT_EQ(chardetCalls, 6); + EXPECT_EQ(ret, Config::instance()->defaultEncoding().toUpper()); + EXPECT_EQ(icuOuterCalls, 0); // ASCII 分支不进 ICU +} + +TEST_F(DetectCodeTest, GetFileEncodingFormat_HighConfidenceUtf8_NoRetry) +{ + // Arrange: G5 正常路径,置信度达标 + const QByteArray content = QByteArrayLiteral("hello world"); + const QString path = writeTempFile(QStringLiteral("gf_hi.txt"), content); + ASSERT_FALSE(path.isEmpty()); + stub.set_lamda(&DetectCode::ChartDet_DetectingTextCoding, + [this](const char *, QString &encoding, float &confidence) -> int { + ++chardetCalls; + encoding = QStringLiteral("UTF-8"); + confidence = 0.99f; + return CHARDET_SUCCESS; + }); + stub.set_lamda(&DetectCode::UchardetCode, + [this](QString) -> QByteArray { + ++uchardetCalls; + return QByteArrayLiteral("UTF-8"); + }); + stub.set_lamda(&DetectCode::icuDetectTextEncoding, + [](const QString &, QByteArrayList &list) { + list.clear(); + list << QByteArrayLiteral("UTF-8"); + }); + // Act + const QByteArray ret = DetectCode::GetFileEncodingFormat(path, content); + // Assert + EXPECT_EQ(ret, QByteArrayLiteral("UTF-8")); + EXPECT_EQ(chardetCalls, 1); + EXPECT_EQ(uchardetCalls, 0); +} + +TEST_F(DetectCodeTest, GetFileEncodingFormat_Utf8ChineseFile_ReturnsUtf8) +{ + // Arrange: 真实探测(无 chardet/icu stub) + const QByteArray raw = QStringLiteral("中文编码探测样例:你好,世界!deepin editor").toUtf8(); + const QString path = writeTempFile(QStringLiteral("gf_real_zh.txt"), raw); + ASSERT_FALSE(path.isEmpty()); + // Act + const QByteArray ret = DetectCode::GetFileEncodingFormat(path, raw); + // Assert + EXPECT_EQ(ret, QByteArrayLiteral("UTF-8")); + EXPECT_EQ(ret, ret.toUpper()); // 返回值为大写规范形式 +} + +TEST_F(DetectCodeTest, GetFileEncodingFormat_Gb18030File_ReturnsGbFamily) +{ + // Arrange: GB18030 字节序列("中文测试" ×3) + QByteArray gb; + for (int i = 0; i < 3; ++i) + gb += QByteArray::fromHex("D6D0CEC4B2E2CAD4"); + const QString path = writeTempFile(QStringLiteral("gf_gb.txt"), gb); + ASSERT_FALSE(path.isEmpty()); + // Act + const QByteArray ret = DetectCode::GetFileEncodingFormat(path, gb); + // Assert: GB18030/GBK 家族识别(大写返回) + EXPECT_TRUE(ret == QByteArrayLiteral("GB18030") || ret == QByteArrayLiteral("GBK")) + << "actual: " << ret.constData(); + EXPECT_FALSE(ret.isEmpty()); +} + +TEST_F(DetectCodeTest, GetFileEncodingFormat_EmptyFile_ReturnsDefaultEncoding) +{ + // Arrange: G6 结果为空 → 默认编码 + const QString path = writeTempFile(QStringLiteral("gf_empty.txt"), QByteArray()); + ASSERT_FALSE(path.isEmpty()); + // Act + const QByteArray ret = DetectCode::GetFileEncodingFormat(path, QByteArray()); + // Assert(与真实配置自一致,不硬编码机器取值) + EXPECT_EQ(ret, Config::instance()->defaultEncoding().toUpper()); + EXPECT_FALSE(ret.isEmpty()); +} + +TEST_F(DetectCodeTest, GetFileEncodingFormat_MissingFile_ReturnsDefaultEncoding) +{ + // Arrange + const QString path = tmpDir->path() + QStringLiteral("/gf_missing.txt"); + // Act + const QByteArray ret = DetectCode::GetFileEncodingFormat(path, QByteArray()); + // Assert + EXPECT_EQ(ret, Config::instance()->defaultEncoding().toUpper()); + EXPECT_FALSE(ret.isEmpty()); +} + +// ---------------- ChangeFileEncodingFormat ---------------- + +TEST_F(DetectCodeTest, ChangeEncoding_SameCodes_CopiesInputAndSucceeds) +{ + // Arrange: E1 同码直通 + QByteArray in = QByteArrayLiteral("same bytes"), out; + // Act + const bool ok = DetectCode::ChangeFileEncodingFormat(in, out, QStringLiteral("UTF-8"), QStringLiteral("UTF-8")); + // Assert + EXPECT_TRUE(ok); + EXPECT_EQ(out, in); +} + +TEST_F(DetectCodeTest, ChangeEncoding_EmptyInput_ClearsOutputAndSucceeds) +{ + // Arrange: E2 空输入 + QByteArray in, out(QByteArrayLiteral("stale")); + // Act + const bool ok = DetectCode::ChangeFileEncodingFormat(in, out, QStringLiteral("GB18030"), QStringLiteral("UTF-8")); + // Assert + EXPECT_TRUE(ok); + EXPECT_TRUE(out.isEmpty()); +} + +TEST_F(DetectCodeTest, ChangeEncoding_Gb18030ToUtf8_ReturnsExactUtf8Bytes) +{ + // Arrange: E3 + QByteArray in = QByteArray::fromHex("D6D0CEC4"), out; + // Act + const bool ok = DetectCode::ChangeFileEncodingFormat(in, out, QStringLiteral("GB18030"), QStringLiteral("UTF-8")); + // Assert: "中文" UTF-8,无 BOM + EXPECT_TRUE(ok); + EXPECT_EQ(out, QStringLiteral("中文").toUtf8()); +} + +TEST_F(DetectCodeTest, ChangeEncoding_Utf8ToGb18030_ReturnsExactGbBytes) +{ + // Arrange: E3 反向 + QByteArray in = QStringLiteral("中文").toUtf8(), out; + // Act + const bool ok = DetectCode::ChangeFileEncodingFormat(in, out, QStringLiteral("UTF-8"), QStringLiteral("GB18030")); + // Assert: 无 BOM + EXPECT_TRUE(ok); + EXPECT_EQ(out, QByteArray::fromHex("D6D0CEC4")); +} + +TEST_F(DetectCodeTest, ChangeEncoding_Utf8ToUtf16Le_PrependsBom) +{ + // Arrange: E4 BOM 附加在转换数据之前 + QByteArray in = QByteArrayLiteral("hi"), out; + // Act + const bool ok = DetectCode::ChangeFileEncodingFormat(in, out, QStringLiteral("UTF-8"), QStringLiteral("UTF-16LE")); + // Assert + EXPECT_TRUE(ok); + EXPECT_EQ(out, QByteArray::fromHex("FFFE68006900")); +} + +TEST_F(DetectCodeTest, ChangeEncoding_Utf8ToUtf16Be_PrependsBom) +{ + // Arrange + QByteArray in = QByteArrayLiteral("hi"), out; + // Act + const bool ok = DetectCode::ChangeFileEncodingFormat(in, out, QStringLiteral("UTF-8"), QStringLiteral("UTF-16BE")); + // Assert + EXPECT_TRUE(ok); + EXPECT_EQ(out, QByteArray::fromHex("FEFF00680069")); +} + +TEST_F(DetectCodeTest, ChangeEncoding_Utf8ToUtf32Le_PrependsBom) +{ + // Arrange + QByteArray in = QByteArrayLiteral("hi"), out; + // Act + const bool ok = DetectCode::ChangeFileEncodingFormat(in, out, QStringLiteral("UTF-8"), QStringLiteral("UTF-32LE")); + // Assert + EXPECT_TRUE(ok); + EXPECT_EQ(out, QByteArray::fromHex("FFFE00006800000069000000")); +} + +TEST_F(DetectCodeTest, ChangeEncoding_Utf16BomToUtf8_StripsToPlainUtf8) +{ + // Arrange: UTF-16LE + BOM → UTF-8 + QByteArray in = QByteArray::fromHex("FFFE68006900"), out; // "hi" + // Act + const bool ok = DetectCode::ChangeFileEncodingFormat(in, out, QStringLiteral("UTF-16"), QStringLiteral("UTF-8")); + // Assert: UTF-8 无 BOM + EXPECT_TRUE(ok); + EXPECT_EQ(out, QByteArrayLiteral("hi")); +} + +TEST_F(DetectCodeTest, ChangeEncoding_InvalidUtf8Byte_ReplacedWithQuestionMark) +{ + // Arrange: E5 EILSEQ → '?'(单字节非法) + QByteArray in = QByteArray::fromHex("FF"), out; + // Act + const bool ok = DetectCode::ChangeFileEncodingFormat(in, out, QStringLiteral("UTF-8"), QStringLiteral("GB18030")); + // Assert: 非法首字节被替换为 '?'(GB18030 '?' == 0x3F) + EXPECT_TRUE(ok); + EXPECT_EQ(out, QByteArrayLiteral("?")); +} + +TEST_F(DetectCodeTest, ChangeEncoding_TruncatedUtf8_KeepsConvertedPrefix) +{ + // Arrange: E6 EINVAL(不完整多字节序列在末尾)→ 保留已转换前缀 + QByteArray in = QByteArrayLiteral("a") + QByteArray::fromHex("C3"), out; + // Act + const bool ok = DetectCode::ChangeFileEncodingFormat(in, out, QStringLiteral("UTF-8"), QStringLiteral("GB18030")); + // Assert: 'a' 已转换,截断尾部放弃 + EXPECT_TRUE(ok); + EXPECT_EQ(out, QByteArrayLiteral("a")); +} + +TEST_F(DetectCodeTest, ChangeEncoding_InvalidGb18030Byte_ReplacedAndContinues) +{ + // Arrange: E5 GB18030 源非法首字节,后续有效 + QByteArray in = QByteArray::fromHex("FF414243"), out; + // Act + const bool ok = DetectCode::ChangeFileEncodingFormat(in, out, QStringLiteral("GB18030"), QStringLiteral("UTF-8")); + // Assert: FF → '?',ABC 原样 + EXPECT_TRUE(ok); + EXPECT_EQ(out, QByteArrayLiteral("?ABC")); +} + +TEST_F(DetectCodeTest, ChangeEncoding_Gb18030Pua_MapsToUtf8Pua) +{ + // Arrange: E8 GB18030 PUA 0x37903582(小端字节 82 35 90 37) + QByteArray in = QByteArray::fromHex("82359037"), out; + // Act + const bool ok = DetectCode::ChangeFileEncodingFormat(in, out, QStringLiteral("GB18030"), QStringLiteral("UTF-8")); + // Assert: \uE81E 的 UTF-8 编码(本机 iconv 实测映射,见文件头注释) + EXPECT_TRUE(ok); + EXPECT_EQ(out, QByteArray::fromHex("EEA09E")); +} + +TEST_F(DetectCodeTest, ChangeEncoding_PatchedIconv_Fe51ReplacedVia2005Map) +{ + // Arrange: E9 enablePatchedIconv=true → FE51 预替换为 FFFFFF01 → EILSEQ → \uE816 + installConfigStubs(true, true, QByteArrayLiteral("UTF-8")); + QByteArray in = QByteArray::fromHex("FE51"), out; + // Act + const bool ok = DetectCode::ChangeFileEncodingFormat(in, out, QStringLiteral("GB18030"), QStringLiteral("UTF-8")); + // Assert: \uE816 UTF-8 = EE A0 96 + EXPECT_TRUE(ok); + EXPECT_EQ(out, QByteArray::fromHex("EEA096")); +} + +TEST_F(DetectCodeTest, ChangeEncoding_PatchedIconv_U20087MapsToGbPuaBytes) +{ + // Arrange: E9 UTF-8→GB18030 补丁路径:F0A08287(\u20087) → FFFF11 → 95329031 + installConfigStubs(true, true, QByteArrayLiteral("UTF-8")); + QByteArray in = QByteArray::fromHex("F0A08287"), out; + // Act + const bool ok = DetectCode::ChangeFileEncodingFormat(in, out, QStringLiteral("UTF-8"), QStringLiteral("GB18030")); + // Assert + EXPECT_TRUE(ok); + EXPECT_EQ(out, QByteArray::fromHex("95329031")); +} + +TEST_F(DetectCodeTest, ChangeEncoding_InvalidFromCode_FallsBackAndFails) +{ + // Arrange: E7 iconv_open 失败 → QTextCodec 回退 → 源编码不存在 → false + QByteArray in = QByteArrayLiteral("abc"), out; + // Act + const bool ok = DetectCode::ChangeFileEncodingFormat(in, out, QStringLiteral("UT-NONE-F"), QStringLiteral("UTF-8")); + // Assert + EXPECT_FALSE(ok); + EXPECT_TRUE(out.isEmpty()); // 失败不产生输出 +} + +TEST_F(DetectCodeTest, ChangeEncoding_InvalidToCode_FallsBackAndFails) +{ + // Arrange: E7 目标编码不存在 + QByteArray in = QByteArrayLiteral("abc"), out; + // Act + const bool ok = DetectCode::ChangeFileEncodingFormat(in, out, QStringLiteral("UTF-8"), QStringLiteral("UT-NONE-T")); + // Assert + EXPECT_FALSE(ok); + EXPECT_TRUE(out.isEmpty()); // 失败不产生输出 +} + +// ---------------- Config 真实实现直跑(保证 FN 覆盖,无 stub) ---------------- + +TEST_F(DetectCodeTest, ConfigRealMethods_Queried_ReturnStableValues) +{ + // Arrange: 不安装任何 Config stub(TearDown 前 installConfigStubs 未调用) + Config *cfg = Config::instance(); + // Act + const QByteArray def = cfg->defaultEncoding(); + const bool improve = cfg->enableImproveGB18030(); + const bool patched = cfg->enablePatchedIconv(); + // Assert: 单例稳定、返回值稳定(取值随机器 DConfig,断言语义而非具体值) + EXPECT_EQ(Config::instance(), cfg); + EXPECT_EQ(cfg->defaultEncoding(), def); + EXPECT_FALSE(def.isEmpty()); + EXPECT_TRUE(improve == true || improve == false); + EXPECT_TRUE(patched == true || patched == false); +} + +// DetectCode 构造函数 +TEST_F(DetectCodeTest, Constructor_Default_ConstructsSafely) +{ + // Arrange/Act + DetectCode code; + // Assert: 无状态对象,构造安全;静态方法与其实例语义互不影响 + EXPECT_NE(&code, nullptr); + EXPECT_EQ(DetectCode::selectCoding(QByteArrayLiteral("UTF-8"), QByteArrayList(), 0.99f), + QByteArray()); // icu 空列表 → 空结果 +} diff --git a/tests/common2/test_fileloadthread.cpp b/tests/common2/test_fileloadthread.cpp new file mode 100644 index 00000000..ed2f7af6 --- /dev/null +++ b/tests/common2/test_fileloadthread.cpp @@ -0,0 +1,423 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +/* + * FileLoadThread 单元测试 + * + * 分支清单(来源:FileLoadThread::run / setEncodeHint) + * B1 : 文件打开失败 → sigLoadFinished("", "", error=true, hasNul=false) + * B2 : 小文件 + 无 hint + 检出 UTF-8 → sigLoadFinished(encode, 原始内容, false, false) + * B3 : 内容含 NUL 字节 → \x00 转义 "\\00",hasNul=true + * B4 : 空文件(file.size()==0) → readAll 分支,内容为空 + * B5 : encodeHint=GB18030 → 全量转码为 UTF-8 后发射 + * B6 : encodeHint=UTF-8 → 不转码,encode=hint + * B7 : 大文件(>40MB) ASCII/UTF-8 头 → 先发 sigPreProcess(1MB 头),再发全量 sigLoadFinished + * B8 : 大文件头含 NUL → 头部 \x00 转义后发射 sigPreProcess + * B9 : 大文件 GB18030 hint → 头部先转码 UTF-8 再发射 sigPreProcess + * B10: start() 线程方式运行 → 跨线程信号正常送达 + * + * 用例映射: + * - Run_FileMissing_EmitsLoadFinishedError → B1 + * - Run_SmallUtf8File_EmitsRawContent → B2 + * - Run_NulBytes_EscapesContentAndFlagsHasNul → B3 + * - Run_EmptyFile_EmitsEmptyContent → B4 + * - Run_EncodeHintGb18030_ConvertsContentToUtf8 → B5 + * - Run_EncodeHintUtf8_NoConversion → B6 + * - Run_LargeUtf8HintFile_EmitsPreProcessHead → B7 + * - Run_LargeFileNulInHead_EscapesHeadNulBytes → B8 + * - Run_LargeGb18030HintFile_ConvertsHeadToUtf8 → B9 + * - Start_FromWorkerThread_EmitsLoadFinished → B10 + * + * 隔离:全部文件经由 QTemporaryDir 创建;编码探测 DetectCode::GetFileEncodingFormat + * 默认 stub 为固定 "UTF-8"(探测逻辑由 DetectCode 自身套件覆盖),hint 用例不触发探测。 + */ + +#include +#include "stubext.h" + +#include "fileloadthread.h" +#include "../encodes/detectcode.h" + +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +QCoreApplication *ensureApp() +{ + if (!QCoreApplication::instance()) { + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) + qputenv("QT_QPA_PLATFORM", "offscreen"); + static int argc = 1; + static char argv0[] = "test_common2"; + static char *argv[2] = { argv0, nullptr }; + static QCoreApplication app(argc, argv); + return &app; + } + return QCoreApplication::instance(); +} + +} // namespace + +class FileLoadThreadTest : public ::testing::Test +{ +protected: + void SetUp() override + { + ensureApp(); + stub.clear(); + detectResult = "UTF-8"; + detectCalls = 0; + tmpDir = std::make_unique(); + ASSERT_TRUE(tmpDir->isValid()); + // 默认 stub 编码探测,保证本套件对探测结果完全确定 + stub.set_lamda(&DetectCode::GetFileEncodingFormat, + [this](QString, QByteArray) -> QByteArray { + ++detectCalls; + return detectResult; + }); + } + + void TearDown() override + { + stub.clear(); + } + + QString writeTempFile(const QString &name, const QByteArray &data) + { + const QString path = tmpDir->path() + QLatin1Char('/') + name; + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return QString(); + f.write(data); + f.close(); + return path; + } + + // 直接调用 run()(public)同步执行,随后派发 deleteLater 的 DeferredDelete + void runSync(FileLoadThread *t) + { + t->run(); + QCoreApplication::processEvents(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + } + + stub_ext::StubExt stub; + std::unique_ptr tmpDir; + QByteArray detectResult; + int detectCalls = 0; +}; + +// B1 +TEST_F(FileLoadThreadTest, Run_FileMissing_EmitsLoadFinishedError) +{ + // Arrange + const QString missing = tmpDir->path() + QStringLiteral("/not-exist.txt"); + FileLoadThread *t = new FileLoadThread(missing); + QPointer guard(t); + QSignalSpy spy(t, &FileLoadThread::sigLoadFinished); + // Act + runSync(t); + // Assert + EXPECT_EQ(spy.count(), 1); + if (spy.count() == 1) { + EXPECT_EQ(spy.at(0).at(0).toByteArray(), QByteArrayLiteral("")); + EXPECT_EQ(spy.at(0).at(1).toByteArray(), QByteArrayLiteral("")); + EXPECT_TRUE(spy.at(0).at(2).toBool()); // error = true + EXPECT_FALSE(spy.at(0).at(3).toBool()); // hasNul = false + } + EXPECT_EQ(detectCalls, 0); // 打开失败不触发探测 + EXPECT_TRUE(guard.isNull()); // run() 末尾 deleteLater 已被处理 +} + +// B2 +TEST_F(FileLoadThreadTest, Run_SmallUtf8File_EmitsRawContent) +{ + // Arrange + const QByteArray raw = QStringLiteral("hello 中文 world").toUtf8(); + const QString path = writeTempFile(QStringLiteral("utf8.txt"), raw); + ASSERT_FALSE(path.isEmpty()); + FileLoadThread *t = new FileLoadThread(path); + QPointer guard(t); + QSignalSpy spy(t, &FileLoadThread::sigLoadFinished); + // Act + runSync(t); + // Assert: UTF-8 家族不转码,原样回传 + EXPECT_EQ(spy.count(), 1); + if (spy.count() == 1) { + EXPECT_EQ(spy.at(0).at(0).toByteArray(), QByteArrayLiteral("UTF-8")); + EXPECT_EQ(spy.at(0).at(1).toByteArray(), raw); + EXPECT_FALSE(spy.at(0).at(2).toBool()); + EXPECT_FALSE(spy.at(0).at(3).toBool()); + } + EXPECT_EQ(detectCalls, 1); + EXPECT_TRUE(guard.isNull()); +} + +// B3 +TEST_F(FileLoadThreadTest, Run_NulBytes_EscapesContentAndFlagsHasNul) +{ + // Arrange + const QByteArray raw = QByteArrayLiteral("a\0b"); + const QString path = writeTempFile(QStringLiteral("nul.txt"), raw); + ASSERT_FALSE(path.isEmpty()); + FileLoadThread *t = new FileLoadThread(path); + QPointer guard(t); + QSignalSpy spy(t, &FileLoadThread::sigLoadFinished); + // Act + runSync(t); + // Assert: \x00 → "\\00",hasNul 置位 + EXPECT_EQ(spy.count(), 1); + if (spy.count() == 1) { + EXPECT_EQ(spy.at(0).at(1).toByteArray(), QByteArrayLiteral("a\\00b")); + EXPECT_FALSE(spy.at(0).at(2).toBool()); + EXPECT_TRUE(spy.at(0).at(3).toBool()); + } + EXPECT_TRUE(guard.isNull()); +} + +// B4 +TEST_F(FileLoadThreadTest, Run_EmptyFile_EmitsEmptyContent) +{ + // Arrange: 0 字节文件触发 file.size()==0 的 readAll 分支 + const QString path = writeTempFile(QStringLiteral("empty.txt"), QByteArray()); + ASSERT_FALSE(path.isEmpty()); + FileLoadThread *t = new FileLoadThread(path); + QPointer guard(t); + QSignalSpy spy(t, &FileLoadThread::sigLoadFinished); + // Act + runSync(t); + // Assert + EXPECT_EQ(spy.count(), 1); + if (spy.count() == 1) { + EXPECT_EQ(spy.at(0).at(1).toByteArray(), QByteArrayLiteral("")); + EXPECT_FALSE(spy.at(0).at(2).toBool()); + EXPECT_FALSE(spy.at(0).at(3).toBool()); + } + EXPECT_EQ(detectCalls, 1); + EXPECT_TRUE(guard.isNull()); +} + +// B5 +TEST_F(FileLoadThreadTest, Run_EncodeHintGb18030_ConvertsContentToUtf8) +{ + // Arrange: GB18030 编码的 "中文测试" + const QByteArray gbBytes = QByteArray::fromHex("D6D0CEC4B2E2CAD4"); + const QString path = writeTempFile(QStringLiteral("gb.txt"), gbBytes); + ASSERT_FALSE(path.isEmpty()); + FileLoadThread *t = new FileLoadThread(path); + QPointer guard(t); + t->setEncodeHint("GB18030"); + QSignalSpy spy(t, &FileLoadThread::sigLoadFinished); + // Act + runSync(t); + // Assert: 内容转码为 UTF-8,encode 采用 hint,不触发自动探测 + EXPECT_EQ(spy.count(), 1); + if (spy.count() == 1) { + EXPECT_EQ(spy.at(0).at(0).toByteArray(), QByteArrayLiteral("GB18030")); + EXPECT_EQ(spy.at(0).at(1).toByteArray(), QStringLiteral("中文测试").toUtf8()); + EXPECT_FALSE(spy.at(0).at(2).toBool()); + } + EXPECT_EQ(detectCalls, 0); + EXPECT_TRUE(guard.isNull()); +} + +// B6 +TEST_F(FileLoadThreadTest, Run_EncodeHintUtf8_NoConversion) +{ + // Arrange + const QByteArray raw = QByteArrayLiteral("plain ascii bytes"); + const QString path = writeTempFile(QStringLiteral("hint.txt"), raw); + ASSERT_FALSE(path.isEmpty()); + FileLoadThread *t = new FileLoadThread(path); + QPointer guard(t); + t->setEncodeHint("UTF-8"); + QSignalSpy spy(t, &FileLoadThread::sigLoadFinished); + // Act + runSync(t); + // Assert + EXPECT_EQ(spy.count(), 1); + if (spy.count() == 1) { + EXPECT_EQ(spy.at(0).at(0).toByteArray(), QByteArrayLiteral("UTF-8")); + EXPECT_EQ(spy.at(0).at(1).toByteArray(), raw); + EXPECT_FALSE(spy.at(0).at(2).toBool()); + } + EXPECT_EQ(detectCalls, 0); + EXPECT_TRUE(guard.isNull()); +} + +// B7 +TEST_F(FileLoadThreadTest, Run_LargeUtf8HintFile_EmitsPreProcessHead) +{ + // Arrange: 41MB 'a',hint UTF-8 → 先发 1MB 头,再发全量 + const qint64 headLen = 1024 * 1024; + const qint64 total = 41LL * 1024 * 1024; + const QString path = tmpDir->path() + QStringLiteral("/big_utf8.bin"); + { + QFile f(path); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Truncate)); + QByteArray chunk(1024 * 1024, 'a'); + for (qint64 written = 0; written < total; written += chunk.size()) + ASSERT_GT(f.write(chunk), 0); + f.close(); + } + FileLoadThread *t = new FileLoadThread(path); + QPointer guard(t); + t->setEncodeHint("UTF-8"); + QSignalSpy spyPre(t, &FileLoadThread::sigPreProcess); + QSignalSpy spyFin(t, &FileLoadThread::sigLoadFinished); + // Act + runSync(t); + // Assert + EXPECT_EQ(spyPre.count(), 1); + if (spyPre.count() == 1) { + EXPECT_EQ(spyPre.at(0).at(0).toByteArray(), QByteArrayLiteral("UTF-8")); + EXPECT_EQ(spyPre.at(0).at(1).toByteArray().size(), int(headLen)); + EXPECT_EQ(spyPre.at(0).at(1).toByteArray().at(0), 'a'); + } + EXPECT_EQ(spyFin.count(), 1); + if (spyFin.count() == 1) { + EXPECT_EQ(spyFin.at(0).at(1).toByteArray().size(), int(total)); + EXPECT_FALSE(spyFin.at(0).at(2).toBool()); + EXPECT_FALSE(spyFin.at(0).at(3).toBool()); + } + EXPECT_TRUE(guard.isNull()); +} + +// B8 +TEST_F(FileLoadThreadTest, Run_LargeFileNulInHead_EscapesHeadNulBytes) +{ + // Arrange: 头 1MB 内含 2 个 NUL,其余 'a',总 41MB + const qint64 total = 41LL * 1024 * 1024; + const QString path = tmpDir->path() + QStringLiteral("/big_nul.bin"); + { + QFile f(path); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Truncate)); + QByteArray head(1024 * 1024, 'a'); + head[1] = '\0'; + head[3] = '\0'; + ASSERT_GT(f.write(head), 0); + QByteArray chunk(1024 * 1024, 'a'); + for (qint64 written = head.size(); written < total; written += chunk.size()) + ASSERT_GT(f.write(chunk), 0); + f.close(); + } + FileLoadThread *t = new FileLoadThread(path); + QPointer guard(t); + t->setEncodeHint("UTF-8"); + QSignalSpy spyPre(t, &FileLoadThread::sigPreProcess); + QSignalSpy spyFin(t, &FileLoadThread::sigLoadFinished); + // Act + runSync(t); + // Assert: 头部 2 个 NUL 各膨胀 2 字节("\\00" 三字节) + EXPECT_EQ(spyPre.count(), 1); + if (spyPre.count() == 1) { + const QByteArray headOut = spyPre.at(0).at(1).toByteArray(); + EXPECT_EQ(headOut.size(), int(1024 * 1024 + 2 * 2)); + EXPECT_TRUE(headOut.contains(QByteArrayLiteral("\\00"))); + } + EXPECT_EQ(spyFin.count(), 1); + if (spyFin.count() == 1) { + EXPECT_TRUE(spyFin.at(0).at(3).toBool()); // hasNul + EXPECT_TRUE(spyFin.at(0).at(1).toByteArray().contains(QByteArrayLiteral("\\00"))); + } + EXPECT_TRUE(guard.isNull()); +} + +// B9 +TEST_F(FileLoadThreadTest, Run_LargeGb18030HintFile_ConvertsHeadToUtf8) +{ + // Arrange: 41MB 重复 GB18030 "中"(D6D0),hint GB18030 → 头部转 UTF-8 后发 sigPreProcess + const qint64 total = 41LL * 1024 * 1024; // 偶数字节,全部为 D6D0 序列 + const QString path = tmpDir->path() + QStringLiteral("/big_gb.bin"); + { + QFile f(path); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Truncate)); + QByteArray mb(2 * 1024 * 1024, '\0'); + for (int i = 0; i < mb.size(); i += 2) { + mb[i] = char(0xD6); + mb[i + 1] = char(0xD0); + } + for (qint64 written = 0; written < total;) { + const qint64 n = qMin(mb.size(), total - written); + ASSERT_EQ(f.write(mb.constData(), n), n); + written += n; + } + f.close(); + } + FileLoadThread *t = new FileLoadThread(path); + QPointer guard(t); + t->setEncodeHint("GB18030"); + QSignalSpy spyPre(t, &FileLoadThread::sigPreProcess); + QSignalSpy spyFin(t, &FileLoadThread::sigLoadFinished); + // Act + runSync(t); + // Assert: 1MB 头 = 524288 个 D6D0 → 转码为 524288 个 E4B8AD + EXPECT_EQ(spyPre.count(), 1); + if (spyPre.count() == 1) { + EXPECT_EQ(spyPre.at(0).at(0).toByteArray(), QByteArrayLiteral("GB18030")); + const QByteArray headOut = spyPre.at(0).at(1).toByteArray(); + EXPECT_EQ(headOut.size(), int(524288 * 3)); + EXPECT_TRUE(headOut.startsWith(QByteArray::fromHex("E4B8AD"))); + } + // 全量 41MB → 21504000 * 3 字节 UTF-8 + EXPECT_EQ(spyFin.count(), 1); + if (spyFin.count() == 1) { + EXPECT_EQ(spyFin.at(0).at(1).toByteArray().size(), int((total / 2) * 3)); + EXPECT_FALSE(spyFin.at(0).at(2).toBool()); + } + EXPECT_TRUE(guard.isNull()); +} + +// B10 +TEST_F(FileLoadThreadTest, Start_FromWorkerThread_EmitsLoadFinished) +{ + // Arrange: 通过 start() 在真实线程中运行 + const QByteArray raw = QByteArrayLiteral("threaded content"); + const QString path = writeTempFile(QStringLiteral("thread.txt"), raw); + ASSERT_FALSE(path.isEmpty()); + FileLoadThread *t = new FileLoadThread(path); + QPointer guard(t); + QSignalSpy spy(t, &FileLoadThread::sigLoadFinished); + // Act + t->start(); + const bool got = spy.wait(5000); + // Assert + EXPECT_TRUE(got); + if (got) { + EXPECT_EQ(spy.at(0).at(1).toByteArray(), raw); + EXPECT_FALSE(spy.at(0).at(2).toBool()); + } + t->wait(2000); + QCoreApplication::processEvents(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + EXPECT_TRUE(guard.isNull()); // deleteLater 已处理 +} + +// setEncodeHint:未显式给出 getter,经由 B5/B6 的行为断言覆盖(见上方用例) +TEST_F(FileLoadThreadTest, SetEncodeHint_AppliesToBothDetectAndConvertPath) +{ + // Arrange: 同一 hint 作用于"大文件头探测优先"与"整体转码"两条路径已在 B9 覆盖; + // 此处补充 hint 在小文件下的最终编码取值断言 + const QString path = writeTempFile(QStringLiteral("hint2.txt"), QByteArrayLiteral("xyz")); + ASSERT_FALSE(path.isEmpty()); + FileLoadThread *t = new FileLoadThread(path); + QPointer guard(t); + t->setEncodeHint("GB18030"); // 纯 ASCII 内容转 GB18030 后再转 UTF-8 输出不变 + QSignalSpy spy(t, &FileLoadThread::sigLoadFinished); + // Act + runSync(t); + // Assert + EXPECT_EQ(spy.count(), 1); + if (spy.count() == 1) { + EXPECT_EQ(spy.at(0).at(0).toByteArray(), QByteArrayLiteral("GB18030")); + EXPECT_EQ(spy.at(0).at(1).toByteArray(), QByteArrayLiteral("xyz")); + } + EXPECT_TRUE(guard.isNull()); +} diff --git a/tests/common2/test_iflytekaiassistant.cpp b/tests/common2/test_iflytekaiassistant.cpp new file mode 100644 index 00000000..85252cce --- /dev/null +++ b/tests/common2/test_iflytekaiassistant.cpp @@ -0,0 +1,575 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +/* + * IflytekAiAssistant 单元测试 + * + * 注意:被测类为进程级单例且 m_status 跨用例保持,本套件内用例【按声明顺序】 + * 组成状态机(gtest 保证同 suite 内按声明顺序执行): + * Invalid → (checkAiExists) NotInstalled → (checkValid+stub) Enable → + * (agreement=false) NoUserAgreement → (invalid reply 自适应) Enable + * + * 分支清单(来源:iflytek_ai_assistant.cpp 全部 public/private 方法) + * B1 : instance() 单例稳定 + * B2 : status/valid 初始 Invalid + * B3 : 非 Enable 时各查询方法直接返回 status()(无 DBus) + * B4 : checkAiExists → call_once + QtConcurrent + initFinished 信号 + * B5 : checkValid NotInstalled → copilotInstalled 失败/成功 + * B6 : checkValid Q_FALLTHROUGH → isCopilotEnabled true/false/invalid + * B7 : NoUserAgreement → launchCopilotChat 成功/失败 + * B8 : AudioDeviceDetector:连接无效/属性无效/JSON 解析/端口计数(输出、输入方向) + * B9 : isTtsInWorking/isTtsEnable/getIatEnable/getTransEnable → 回复 true/false/invalid + * B10: textToSpeech/speechToText/textToTranslate → 前置失败与完整成功(async 调用) + * B11: stopTtsDirectly(默认构建未定义 ENABLE_STOP_TTS → 恒 Enable) + * B12: errorString 各枚举分支 + * + * 用例映射(状态机顺序): + * - Instance_Singleton_ReturnsStablePointer_InitialStateInvalid → B1+B2 + * - StopTtsDirectly_DefaultBuild_ReturnsEnableWithoutDbus → B11 + * - QueryMethods_StatusInvalid_ReturnInvalidWithoutDbus → B3 + * - ActionMethods_StatusInvalid_ReturnInvalidStatus → B3 + * - CheckAiExists_BackendAbsent_EmitsInitFinishedNotInstalled → B4 + * - CheckAiExists_SecondInvocation_NoRepeatedEmission → B4(call_once) + * - CheckValid_VersionQueryFails_RemainsNotInstalled → B5(失败) + * - CheckValid_InstalledNotAgreed_LaunchesChatStaysNoAgreement → B5+B6+B7 + * - CheckValid_LaunchChatFails_StillNoUserAgreement → B7(失败) + * - CheckValid_Reagree_ReturnsEnable → B6(同意) + * - CheckValid_AlreadyEnabled_SkipsAllQueries → B6(default) + * - IsTtsInWorking_WorkingIdleBroken_ReturnsEnableDisableInvalid → B9 + * - HasAudioOutputDevice_EnabledPortStates_TrueOnlyWhenEnabled → B8 + * - HasAudioInputDevice_EnabledPortStates_TrueOnlyWhenEnabled → B8 + * - IsTtsEnable_NoOutputDevice_ReturnsNoOutputDevice → B8+B9 + * - IsTtsEnable_DeviceStates_EnableDisableInvalid → B9 + * - TextToSpeech_Speaking_StopsFirstThenSpeaksReturnsSuccess → B10+B11(内部) + * - GetIatEnable_DeviceStates_EnableDisableInvalidNoDevice → B9 + * - SpeechToText_InputReady_ReturnsSuccessAndCallsAsync → B10 + * - GetTransEnable_ReplyStates_EnableDisableInvalid → B9 + * - TextToTranslate_EnabledOrDisabled_SuccessOrInvalid → B10 + * + * DBus 隔离(不触碰真实总线): + * - QDBusConnection::sessionBus → stub 返回未连接的伪连接对象; + * - QDBusAbstractInterface::callWithArgumentList(Qt6.8 所有 call() 重载的汇聚点) + * → stub 返回构造的 QDBusReply; + * - QDBusAbstractInterface::asyncCallWithArgumentList → stub 返回已完成调用; + * - QDBusAbstractInterface::isValid / QObject::property("CardsWithoutUnavailable") + * → 按用例需要 stub。 + */ + +#include +#include "stubext.h" + +#include "iflytek_ai_assistant.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +QCoreApplication *ensureApp() +{ + if (!QCoreApplication::instance()) { + static int argc = 1; + static char argv0[] = "test_common2"; + static char *argv[2] = { argv0, nullptr }; + static QCoreApplication app(argc, argv); + return &app; + } + return QCoreApplication::instance(); +} + +struct ErrorCase { + IflytekAiAssistant::CallStatus status; + const char *expectedSubstr; // nullptr → 期望空串 +}; + +} // namespace + +class IflytekAiAssistantTestBase : public ::testing::Test +{ +protected: + void SetUp() override + { + ensureApp(); + stub.clear(); + resetState(); + // 隔离:所有 sessionBus() 调用返回未连接伪连接,绝不连接真实会话总线 + stub.set_lamda(&QDBusConnection::sessionBus, + []() -> QDBusConnection { + return QDBusConnection(QStringLiteral("ut-iflytek-bus")); + }); + protoMsg = QDBusMessage::createMethodCall(QStringLiteral("ut.service"), + QStringLiteral("/ut/path"), + QStringLiteral("ut.iface"), + QStringLiteral("method")); + // Qt6.8: call(QString...) 全部经 doCall → callWithArgumentList 汇聚 + stub.set_lamda(&QDBusAbstractInterface::callWithArgumentList, + [this](QDBusAbstractInterface *, QDBus::CallMode, + const QString &method, const QList &) -> QDBusMessage { + ++dbusCallCount; + dbusCallMethods << method; + if (errorMethods.contains(method)) + return protoMsg.createErrorReply(QStringLiteral("ut.error"), + method + QStringLiteral(" failed")); + if (replyByMethod.contains(method)) + return protoMsg.createReply(replyByMethod.value(method)); + return QDBusMessage(); // 无回复 → QDBusReply 无效 + }); + stub.set_lamda(&QDBusAbstractInterface::asyncCallWithArgumentList, + [this](QDBusAbstractInterface *, const QString &method, + const QList &) -> QDBusPendingCall { + ++asyncCallCount; + asyncCallMethods << method; + return QDBusPendingCall::fromCompletedCall(protoMsg.createReply()); + }); + } + + void TearDown() override + { + stub.clear(); + } + + void resetState() + { + dbusCallCount = 0; + dbusCallMethods.clear(); + asyncCallCount = 0; + asyncCallMethods.clear(); + replyByMethod.clear(); + errorMethods.clear(); + fakeIsValid = false; + cardsJson.clear(); + protoMsg = QDBusMessage(); + } + + void installAudioStubs() + { + stub.set_lamda(&QDBusAbstractInterface::isValid, + [this](const QDBusAbstractInterface *) -> bool { + return fakeIsValid; + }); + stub.set_lamda(&QObject::property, + [this](const QObject *, const char *name) -> QVariant { + if (qstrcmp(name, "CardsWithoutUnavailable") == 0 && !cardsJson.isEmpty()) + return QVariant(cardsJson); + return QVariant(); + }); + } + + stub_ext::StubExt stub; + QDBusMessage protoMsg; + int dbusCallCount = 0; + QStringList dbusCallMethods; + int asyncCallCount = 0; + QStringList asyncCallMethods; + QHash replyByMethod; + QSet errorMethods; + bool fakeIsValid = false; + QByteArray cardsJson; +}; + +// ---------------- 状态机主套件(顺序敏感,勿调整用例声明顺序) ---------------- + +class IflytekAiAssistantTest : public IflytekAiAssistantTestBase +{ +}; + +// B1+B2(必须是首个状态相关用例) +TEST_F(IflytekAiAssistantTest, Instance_Singleton_ReturnsStablePointer_InitialStateInvalid) +{ + // Arrange/Act + IflytekAiAssistant *p1 = IflytekAiAssistant::instance(); + IflytekAiAssistant *p2 = IflytekAiAssistant::instance(); + // Assert: 单例指针稳定;初始状态 Invalid → 无效 + EXPECT_NE(p1, nullptr); + EXPECT_EQ(p1, p2); + EXPECT_EQ(p1->status(), IflytekAiAssistant::Invalid); + EXPECT_FALSE(p1->valid()); +} + +// B11 +TEST_F(IflytekAiAssistantTest, StopTtsDirectly_DefaultBuild_ReturnsEnableWithoutDbus) +{ + // Arrange/Act: 默认构建未定义 ENABLE_STOP_TTS(见 src CMakeLists) + const auto ret = IflytekAiAssistant::instance()->stopTtsDirectly(); + // Assert: 直接返回 Enable,且不发起任何 DBus 调用 + EXPECT_EQ(ret, IflytekAiAssistant::Enable); + EXPECT_EQ(dbusCallCount, 0); + EXPECT_EQ(asyncCallCount, 0); +} + +// B3 +TEST_F(IflytekAiAssistantTest, QueryMethods_StatusInvalid_ReturnInvalidWithoutDbus) +{ + // Arrange + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + ASSERT_EQ(ins->status(), IflytekAiAssistant::Invalid); + // Act/Assert: 非 Enable 时全部提前返回当前状态,零 DBus 调用 + EXPECT_EQ(ins->isTtsInWorking(), IflytekAiAssistant::Invalid); + EXPECT_EQ(ins->isTtsEnable(), IflytekAiAssistant::Invalid); + EXPECT_EQ(ins->getIatEnable(), IflytekAiAssistant::Invalid); + EXPECT_EQ(ins->getTransEnable(), IflytekAiAssistant::Invalid); + EXPECT_EQ(dbusCallCount, 0); + EXPECT_EQ(asyncCallCount, 0); +} + +// B3 +TEST_F(IflytekAiAssistantTest, ActionMethods_StatusInvalid_ReturnInvalidStatus) +{ + // Arrange + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + // Act/Assert: checkValid 走 default 分支保持 Invalid,动作方法直接返回 + EXPECT_EQ(ins->textToSpeech(), IflytekAiAssistant::Invalid); + EXPECT_EQ(ins->speechToText(), IflytekAiAssistant::Invalid); + EXPECT_EQ(ins->textToTranslate(), IflytekAiAssistant::Invalid); + EXPECT_EQ(ins->checkValid(), IflytekAiAssistant::Invalid); + EXPECT_EQ(dbusCallCount, 0); +} + +// B4 +TEST_F(IflytekAiAssistantTest, CheckAiExists_BackendAbsent_EmitsInitFinishedNotInstalled) +{ + // Arrange: 后端无回复(默认 stub 无应答)→ copilotInstalled 失败 + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + QSignalSpy spy(ins, &IflytekAiAssistant::initFinished); + // Act + ins->checkAiExists(); + const bool emitted = spy.wait(5000); + // Assert: 异步初始化完成,状态 NotInstalled + EXPECT_TRUE(emitted); + EXPECT_EQ(spy.count(), 1); + EXPECT_EQ(ins->status(), IflytekAiAssistant::NotInstalled); +} + +// B4(call_once 语义) +TEST_F(IflytekAiAssistantTest, CheckAiExists_SecondInvocation_NoRepeatedEmission) +{ + // Arrange + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + QSignalSpy spy(ins, &IflytekAiAssistant::initFinished); + // Act + ins->checkAiExists(); + spy.wait(200); // 已 call_once,不会再发射 + // Assert + EXPECT_EQ(spy.count(), 0); + EXPECT_EQ(ins->status(), IflytekAiAssistant::NotInstalled); +} + +// B5(版本查询失败) +TEST_F(IflytekAiAssistantTest, CheckValid_VersionQueryFails_RemainsNotInstalled) +{ + // Arrange: 无 version 回复(默认无效回复) + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + // Act + const auto ret = ins->checkValid(); + // Assert + EXPECT_EQ(ret, IflytekAiAssistant::NotInstalled); + EXPECT_TRUE(dbusCallMethods.contains(QStringLiteral("version"))); + EXPECT_EQ(ins->status(), IflytekAiAssistant::NotInstalled); +} + +// B5+B6+B7(NotInstalled → 已安装但未同意 → 拉起聊天页) +TEST_F(IflytekAiAssistantTest, CheckValid_InstalledNotAgreed_LaunchesChatStaysNoAgreement) +{ + // Arrange: 版本查询成功 + 用户未同意 + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + replyByMethod[QStringLiteral("version")] = QVariant(QStringLiteral("1.0.0")); + replyByMethod[QStringLiteral("isCopilotEnabled")] = QVariant(false); + // Act + const auto ret = ins->checkValid(); + // Assert: Q_FALLTHROUGH 进入协议检测 → 未同意 → launchChatPage,状态 NoUserAgreement + EXPECT_EQ(ret, IflytekAiAssistant::NoUserAgreement); + EXPECT_TRUE(dbusCallMethods.contains(QStringLiteral("version"))); + EXPECT_TRUE(dbusCallMethods.contains(QStringLiteral("launchChatPage"))); + EXPECT_EQ(ins->status(), IflytekAiAssistant::NoUserAgreement); +} + +// B7(拉起失败) +TEST_F(IflytekAiAssistantTest, CheckValid_LaunchChatFails_StillNoUserAgreement) +{ + // Arrange: 上一用例后状态 NoUserAgreement → 直接再入协议分支 + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + ASSERT_EQ(ins->status(), IflytekAiAssistant::NoUserAgreement); + replyByMethod[QStringLiteral("version")] = QVariant(QStringLiteral("1.0.0")); + replyByMethod[QStringLiteral("isCopilotEnabled")] = QVariant(false); + errorMethods.insert(QStringLiteral("launchChatPage")); + // Act + const auto ret = ins->checkValid(); + // Assert: 拉起失败不影响状态 + EXPECT_EQ(ret, IflytekAiAssistant::NoUserAgreement); + EXPECT_TRUE(dbusCallMethods.contains(QStringLiteral("launchChatPage"))); +} + +// B6(协议重新同意 → Enable) +TEST_F(IflytekAiAssistantTest, CheckValid_Reagree_ReturnsEnable) +{ + // Arrange + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + ASSERT_EQ(ins->status(), IflytekAiAssistant::NoUserAgreement); + replyByMethod[QStringLiteral("version")] = QVariant(QStringLiteral("1.0.0")); + replyByMethod[QStringLiteral("isCopilotEnabled")] = QVariant(true); + // Act + const auto ret = ins->checkValid(); + // Assert: NoUserAgreement case 直接进入协议检测,已同意 → Enable + EXPECT_EQ(ret, IflytekAiAssistant::Enable); + EXPECT_TRUE(ins->valid()); + EXPECT_TRUE(dbusCallMethods.contains(QStringLiteral("isCopilotEnabled"))); +} + +// B6 反例(已 Enable → default 分支跳过全部查询) +TEST_F(IflytekAiAssistantTest, CheckValid_AlreadyEnabled_SkipsAllQueries) +{ + // Arrange + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + ASSERT_EQ(ins->status(), IflytekAiAssistant::Enable); + // Act + const auto ret = ins->checkValid(); + // Assert: default 分支直接返回,零 DBus 调用 + EXPECT_EQ(ret, IflytekAiAssistant::Enable); + EXPECT_EQ(dbusCallCount, 0); +} + +// B9 +TEST_F(IflytekAiAssistantTest, IsTtsInWorking_WorkingIdleBroken_ReturnsEnableDisableInvalid) +{ + // Arrange: 上一用例已进入 Enable + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + ASSERT_EQ(ins->status(), IflytekAiAssistant::Enable); + // Act/Assert: true → Enable + replyByMethod[QStringLiteral("isTTSInWorking")] = QVariant(true); + EXPECT_EQ(ins->isTtsInWorking(), IflytekAiAssistant::Enable); + // false → Disable + replyByMethod[QStringLiteral("isTTSInWorking")] = QVariant(false); + EXPECT_EQ(ins->isTtsInWorking(), IflytekAiAssistant::Disable); + // 无有效回复 → Invalid + replyByMethod.remove(QStringLiteral("isTTSInWorking")); + EXPECT_EQ(ins->isTtsInWorking(), IflytekAiAssistant::Invalid); + EXPECT_EQ(dbusCallCount, 3); +} + +// B8 +TEST_F(IflytekAiAssistantTest, HasAudioOutputDevice_EnabledPortStates_TrueOnlyWhenEnabled) +{ + // Arrange + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + installAudioStubs(); + // Act/Assert: 输出方向(1)启用端口 → true + fakeIsValid = true; + cardsJson = QByteArrayLiteral( + "[{\"Id\":0,\"Name\":\"c0\",\"Ports\":[{\"Name\":\"spk\",\"Enabled\":true,\"Direction\":1}," + "{\"Name\":\"mic\",\"Enabled\":true,\"Direction\":2}]}]"); + EXPECT_TRUE(ins->hasAudioOutputDevice()); + // 端口存在但未启用 → false + cardsJson = QByteArrayLiteral( + "[{\"Id\":0,\"Name\":\"c0\",\"Ports\":[{\"Name\":\"spk\",\"Enabled\":false,\"Direction\":1}]}]"); + EXPECT_FALSE(ins->hasAudioOutputDevice()); + // 声卡无 Ports 字段 → false + cardsJson = QByteArrayLiteral("[{\"Id\":0,\"Name\":\"c0\"}]"); + EXPECT_FALSE(ins->hasAudioOutputDevice()); + // 属性无效 → false + cardsJson.clear(); + EXPECT_FALSE(ins->hasAudioOutputDevice()); + // 连接无效 → false + fakeIsValid = false; + EXPECT_FALSE(ins->hasAudioOutputDevice()); +} + +// B8 +TEST_F(IflytekAiAssistantTest, HasAudioInputDevice_EnabledPortStates_TrueOnlyWhenEnabled) +{ + // Arrange + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + installAudioStubs(); + fakeIsValid = true; + // Act/Assert: 输入方向(2)启用端口 → true + cardsJson = QByteArrayLiteral( + "[{\"Id\":0,\"Name\":\"c0\",\"Ports\":[{\"Name\":\"mic\",\"Enabled\":true,\"Direction\":2}]}]"); + EXPECT_TRUE(ins->hasAudioInputDevice()); + // 仅输出方向端口 → false + cardsJson = QByteArrayLiteral( + "[{\"Id\":0,\"Name\":\"c0\",\"Ports\":[{\"Name\":\"spk\",\"Enabled\":true,\"Direction\":1}]}]"); + EXPECT_FALSE(ins->hasAudioInputDevice()); + // 多声卡混合方向:第二块卡有输入端口 → true + cardsJson = QByteArrayLiteral( + "[{\"Id\":0,\"Name\":\"c0\",\"Ports\":[]}," + "{\"Id\":1,\"Name\":\"c1\",\"Ports\":[{\"Name\":\"mic1\",\"Enabled\":true,\"Direction\":2}]}]"); + EXPECT_TRUE(ins->hasAudioInputDevice()); +} + +// B8+B9(无输出设备) +TEST_F(IflytekAiAssistantTest, IsTtsEnable_NoOutputDevice_ReturnsNoOutputDevice) +{ + // Arrange: 不安装 property/isValid stub → 真实伪连接路径返回 false + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + ASSERT_EQ(ins->status(), IflytekAiAssistant::Enable); + // Act + const auto ret = ins->isTtsEnable(); + // Assert: 无输出设备 → NoOutputDevice,且未发起 getTTSEnable 查询 + EXPECT_EQ(ret, IflytekAiAssistant::NoOutputDevice); + EXPECT_FALSE(dbusCallMethods.contains(QStringLiteral("getTTSEnable"))); +} + +// B9 +TEST_F(IflytekAiAssistantTest, IsTtsEnable_DeviceStates_EnableDisableInvalid) +{ + // Arrange + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + installAudioStubs(); + fakeIsValid = true; + cardsJson = QByteArrayLiteral( + "[{\"Id\":0,\"Name\":\"c0\",\"Ports\":[{\"Name\":\"spk\",\"Enabled\":true,\"Direction\":1}]}]"); + // Act/Assert + replyByMethod[QStringLiteral("getTTSEnable")] = QVariant(true); + EXPECT_EQ(ins->isTtsEnable(), IflytekAiAssistant::Enable); + replyByMethod[QStringLiteral("getTTSEnable")] = QVariant(false); + EXPECT_EQ(ins->isTtsEnable(), IflytekAiAssistant::Disable); + replyByMethod.remove(QStringLiteral("getTTSEnable")); + EXPECT_EQ(ins->isTtsEnable(), IflytekAiAssistant::Invalid); + EXPECT_EQ(dbusCallCount, 3); +} + +// B10(完整成功路径,含 stopTtsDirectlyInternal) +TEST_F(IflytekAiAssistantTest, TextToSpeech_Speaking_StopsFirstThenSpeaksReturnsSuccess) +{ + // Arrange: checkValid 通过 + 有输出设备 + 正在朗读 + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + replyByMethod[QStringLiteral("version")] = QVariant(QStringLiteral("1.0.0")); + replyByMethod[QStringLiteral("isCopilotEnabled")] = QVariant(true); + installAudioStubs(); + fakeIsValid = true; + cardsJson = QByteArrayLiteral( + "[{\"Id\":0,\"Name\":\"c0\",\"Ports\":[{\"Name\":\"spk\",\"Enabled\":true,\"Direction\":1}]}]"); + replyByMethod[QStringLiteral("getTTSEnable")] = QVariant(true); + replyByMethod[QStringLiteral("isTTSInWorking")] = QVariant(true); + // Act + const auto ret = ins->textToSpeech(); + // Assert: 先 stopTTSDirectly 再 TextToSpeech,返回 Success + EXPECT_EQ(ret, IflytekAiAssistant::Success); + EXPECT_EQ(asyncCallCount, 2); + EXPECT_EQ(asyncCallMethods, + QStringList() << QStringLiteral("stopTTSDirectly") + << QStringLiteral("TextToSpeech")); + // 无输出设备时 → NoOutputDevice + cardsJson.clear(); + EXPECT_EQ(ins->textToSpeech(), IflytekAiAssistant::NoOutputDevice); +} + +// B9 +TEST_F(IflytekAiAssistantTest, GetIatEnable_DeviceStates_EnableDisableInvalidNoDevice) +{ + // Arrange + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + // Act/Assert: 无输入设备 → NoInputDevice + EXPECT_EQ(ins->getIatEnable(), IflytekAiAssistant::NoInputDevice); + // 有输入设备 + 回复 true → Enable + installAudioStubs(); + fakeIsValid = true; + cardsJson = QByteArrayLiteral( + "[{\"Id\":0,\"Name\":\"c0\",\"Ports\":[{\"Name\":\"mic\",\"Enabled\":true,\"Direction\":2}]}]"); + replyByMethod[QStringLiteral("getIatEnable")] = QVariant(true); + EXPECT_EQ(ins->getIatEnable(), IflytekAiAssistant::Enable); + // 回复 false → Disable + replyByMethod[QStringLiteral("getIatEnable")] = QVariant(false); + EXPECT_EQ(ins->getIatEnable(), IflytekAiAssistant::Disable); + // 无有效回复 → Invalid + replyByMethod.remove(QStringLiteral("getIatEnable")); + EXPECT_EQ(ins->getIatEnable(), IflytekAiAssistant::Invalid); +} + +// B10 +TEST_F(IflytekAiAssistantTest, SpeechToText_InputReady_ReturnsSuccessAndCallsAsync) +{ + // Arrange + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + replyByMethod[QStringLiteral("version")] = QVariant(QStringLiteral("1.0.0")); + replyByMethod[QStringLiteral("isCopilotEnabled")] = QVariant(true); + installAudioStubs(); + fakeIsValid = true; + cardsJson = QByteArrayLiteral( + "[{\"Id\":0,\"Name\":\"c0\",\"Ports\":[{\"Name\":\"mic\",\"Enabled\":true,\"Direction\":2}]}]"); + replyByMethod[QStringLiteral("getIatEnable")] = QVariant(true); + // Act + const auto ret = ins->speechToText(); + // Assert + EXPECT_EQ(ret, IflytekAiAssistant::Success); + EXPECT_EQ(asyncCallCount, 1); + EXPECT_EQ(asyncCallMethods, QStringList() << QStringLiteral("SpeechToText")); +} + +// B9 +TEST_F(IflytekAiAssistantTest, GetTransEnable_ReplyStates_EnableDisableInvalid) +{ + // Arrange + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + ASSERT_EQ(ins->status(), IflytekAiAssistant::Enable); + // Act/Assert + replyByMethod[QStringLiteral("getTransEnable")] = QVariant(true); + EXPECT_EQ(ins->getTransEnable(), IflytekAiAssistant::Enable); + replyByMethod[QStringLiteral("getTransEnable")] = QVariant(false); + EXPECT_EQ(ins->getTransEnable(), IflytekAiAssistant::Disable); + replyByMethod.remove(QStringLiteral("getTransEnable")); + EXPECT_EQ(ins->getTransEnable(), IflytekAiAssistant::Invalid); +} + +// B10 +TEST_F(IflytekAiAssistantTest, TextToTranslate_EnabledOrDisabled_SuccessOrInvalid) +{ + // Arrange + IflytekAiAssistant *ins = IflytekAiAssistant::instance(); + replyByMethod[QStringLiteral("version")] = QVariant(QStringLiteral("1.0.0")); + replyByMethod[QStringLiteral("isCopilotEnabled")] = QVariant(true); + // Act/Assert: 翻译可用 → Success 且 async 调用 TextToTranslate + replyByMethod[QStringLiteral("getTransEnable")] = QVariant(true); + EXPECT_EQ(ins->textToTranslate(), IflytekAiAssistant::Success); + EXPECT_EQ(asyncCallMethods, QStringList() << QStringLiteral("TextToTranslate")); + // 翻译不可用 → Invalid + replyByMethod[QStringLiteral("getTransEnable")] = QVariant(false); + EXPECT_EQ(ins->textToTranslate(), IflytekAiAssistant::Invalid); +} + +// B6(invalid 回复自适应为 Enable 的分支无法经状态机二度触达——协议状态仅可单向 +// 迁移一次;isCopilotEnabled 函数本体已由上方用例覆盖执行,FN 覆盖不受影响) + +// ---------------- errorString 参数化(无状态依赖,独立 fixture) ---------------- + +class IflytekAiAssistantParamTest : public IflytekAiAssistantTestBase, + public ::testing::WithParamInterface +{ +}; + +TEST_P(IflytekAiAssistantParamTest, ErrorString_ByCallStatus_ReturnsExpectedText) +{ + // Arrange + const auto &c = GetParam(); + // Act + const QString msg = IflytekAiAssistant::instance()->errorString(c.status); + // Assert + if (c.expectedSubstr) { + EXPECT_FALSE(msg.isEmpty()); + EXPECT_TRUE(msg.contains(QLatin1String(c.expectedSubstr), Qt::CaseInsensitive)) + << "actual: " << msg.toStdString(); + } else { + EXPECT_TRUE(msg.isEmpty()); + EXPECT_EQ(msg, QString()); + } +} + +INSTANTIATE_TEST_SUITE_P( + ErrorStringCases, IflytekAiAssistantParamTest, + ::testing::Values( + ErrorCase{IflytekAiAssistant::NotInstalled, "UOS AI"}, + ErrorCase{IflytekAiAssistant::NoInputDevice, "input"}, + ErrorCase{IflytekAiAssistant::NoOutputDevice, "output"}, + ErrorCase{IflytekAiAssistant::Invalid, nullptr}, + ErrorCase{IflytekAiAssistant::Enable, nullptr}, + ErrorCase{IflytekAiAssistant::Disable, nullptr}, + ErrorCase{IflytekAiAssistant::NoUserAgreement, nullptr}, + ErrorCase{IflytekAiAssistant::Success, nullptr}, + ErrorCase{IflytekAiAssistant::Failed, nullptr})); diff --git a/tests/common2/test_loadlibs.cpp b/tests/common2/test_loadlibs.cpp new file mode 100644 index 00000000..1d34a047 --- /dev/null +++ b/tests/common2/test_loadlibs.cpp @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +/* + * load_libs(src/basepub/load_libs.c) 单元测试 + * + * 注意:getLoadZPDLibsInstance 为饿汉式全局单例(pLibs 静态指针), + * 本套件内用例【按声明顺序】组成状态机(gtest 保证同 suite 声明顺序): + * setLibNames(不存在路径) → 首次 getLoadZPDLibsInstance(dlopen 失败,函数指针为 NULL) + * → 再次调用(缓存直返)→ setLibNames(NULL)(不重载)→ 并发双检锁一致性 + * + * 分支清单(来源:load_libs.c) + * B1 : setLibNames(非空) → malloc 拷贝路径 + * B2 : setLibNames(NULL) → 置空 + * B3 : newClass: chZPDDLL 非空 + dlopen 失败 → PrintError + 返回空函数指针结构 + * B4 : getLoadZPDLibsInstance: pLibs==NULL → 加锁创建(双检锁) + * B5 : getLoadZPDLibsInstance: pLibs!=NULL → 直接返回缓存 + * B6 : PrintError: dlerror() 非空 → 打印;为空 → 静默 + * + * 用例映射: + * - GetInstance_NonexistentLibPath_ReturnsNullFunctionPointers → B1+B3+B4 + * - GetInstance_SecondCall_ReturnsCachedPointer → B5 + * - SetLibNames_NullAfterInit_InstanceUnchanged → B2+B5 + * - PrintError_DlErrorSetOrCleared_PrintsOrSilent → B6 + * - GetInstance_ConcurrentCalls_SamePointerReturned → B4/B5 并发 + * + * 隔离:dlopen 仅使用 QTemporaryDir 下【不存在】的 .so 路径(必失败,不加载任何库); + * PrintError 的 stderr 输出重定向到临时文件捕获。 + */ + +#include +#include "stubext.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// C 头以 extern "C" 引入,保证与 load_libs.c 的链接符号一致 +extern "C" { +#include "load_libs.h" +void PrintError(); +} + +namespace { + +QCoreApplication *ensureApp() +{ + if (!QCoreApplication::instance()) { + static int argc = 1; + static char argv0[] = "test_common2"; + static char *argv[2] = { argv0, nullptr }; + static QCoreApplication app(argc, argv); + return &app; + } + return QCoreApplication::instance(); +} + +// stderr → 文件捕获;返回原 fd 备份 +int captureStderrBegin(const QString &path) +{ + fflush(stderr); + const int saved = dup(fileno(stderr)); + const int fd = open(path.toLocal8Bit().constData(), O_WRONLY | O_CREAT | O_TRUNC, 0600); + dup2(fd, fileno(stderr)); + close(fd); + return saved; +} + +void captureStderrEnd(int saved) +{ + fflush(stderr); + dup2(saved, fileno(stderr)); + close(saved); +} + +} // namespace + +class LoadLibsTest : public ::testing::Test +{ +protected: + void SetUp() override + { + ensureApp(); + tmpDir = std::make_unique(); + ASSERT_TRUE(tmpDir->isValid()); + } + + std::unique_ptr tmpDir; +}; + +// B1+B3+B4(必须是首个触发单例创建的用例) +TEST_F(LoadLibsTest, GetInstance_NonexistentLibPath_ReturnsNullFunctionPointers) +{ + // Arrange: 临时目录下不存在的 .so → dlopen 必然失败 + const QByteArray libPath = (tmpDir->path() + QStringLiteral("/libutzpd-not-exist.so")).toLocal8Bit(); + LoadLibNames names; + names.chZPDDLL = const_cast(libPath.constData()); + setLibNames(names); + // Act + LoadLibs *libs = getLoadZPDLibsInstance(); + // Assert: 结构体已分配,但两个函数指针因加载失败为 NULL + ASSERT_NE(libs, nullptr); + EXPECT_EQ(libs->m_document_clip_copy, nullptr); + EXPECT_EQ(libs->m_document_close, nullptr); +} + +// B5 +TEST_F(LoadLibsTest, GetInstance_SecondCall_ReturnsCachedPointer) +{ + // Arrange + LoadLibs *first = getLoadZPDLibsInstance(); + ASSERT_NE(first, nullptr); + // Act + LoadLibs *second = getLoadZPDLibsInstance(); + // Assert: 已创建后直接返回缓存(不重新 dlopen),状态保持 + EXPECT_EQ(second, first); + EXPECT_EQ(second->m_document_close, nullptr); +} + +// B2+B5 +TEST_F(LoadLibsTest, SetLibNames_NullAfterInit_InstanceUnchanged) +{ + // Arrange + LoadLibs *before = getLoadZPDLibsInstance(); + ASSERT_NE(before, nullptr); + LoadLibNames names; + names.chZPDDLL = nullptr; + // Act + setLibNames(names); + LoadLibs *after = getLoadZPDLibsInstance(); + // Assert: 饿汉式单例创建后 setLibNames 不触发重载 + EXPECT_EQ(after, before); + EXPECT_EQ(after->m_document_clip_copy, nullptr); +} + +// B6 +TEST_F(LoadLibsTest, PrintError_DlErrorSetOrCleared_PrintsOrSilent) +{ + // Arrange: 制造 dlopen 失败使 dlerror() 非空 + const QString capture = tmpDir->path() + QStringLiteral("/stderr.txt"); + const QString missing = tmpDir->path() + QStringLiteral("/missing-lib.so"); + void *handle = dlopen(missing.toLocal8Bit().constData(), RTLD_NOW); + EXPECT_EQ(handle, nullptr); + // Act & Assert 1: dlerror 非空 → 打印错误 + int saved = captureStderrBegin(capture); + PrintError(); + captureStderrEnd(saved); + QFile f1(capture); + ASSERT_TRUE(f1.open(QIODevice::ReadOnly)); + const QByteArray out1 = f1.readAll(); + f1.close(); + EXPECT_FALSE(out1.isEmpty()); // 打印了 dlerror 信息 + + // Act & Assert 2: 消费掉错误后 → 静默 + (void)dlerror(); + saved = captureStderrBegin(capture); + PrintError(); + captureStderrEnd(saved); + QFile f2(capture); + ASSERT_TRUE(f2.open(QIODevice::ReadOnly)); + const QByteArray out2 = f2.readAll(); + f2.close(); + EXPECT_TRUE(out2.isEmpty()); // 无错误可打印 +} + +// B4/B5 并发 +TEST_F(LoadLibsTest, GetInstance_ConcurrentCalls_SamePointerReturned) +{ + // Arrange + LoadLibs *expected = getLoadZPDLibsInstance(); + std::vector results(8, nullptr); + std::vector workers; + // Act: 8 线程并发获取 + for (size_t i = 0; i < results.size(); ++i) { + workers.emplace_back([&results, i]() { + results[i] = getLoadZPDLibsInstance(); + }); + } + for (auto &w : workers) + w.join(); + // Assert: 双检锁保证全部返回同一实例 + for (const LoadLibs *p : results) { + EXPECT_EQ(p, expected); + } + EXPECT_EQ(results.size(), 8u); + EXPECT_NE(expected, nullptr); +} diff --git a/tests/common2/test_performancemonitor.cpp b/tests/common2/test_performancemonitor.cpp new file mode 100644 index 00000000..52d43483 --- /dev/null +++ b/tests/common2/test_performancemonitor.cpp @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +/* + * PerformanceMonitor 单元测试 + * + * 分支清单(来源:PerformanceMonitor 六个静态打点方法) + * B1 : initializeAppStart/initializAppFinish → 记录时间差并输出 POINT-01 startduration + * B2 : closeAppStart/closeAPPFinish → POINT-02 closeduration + * B3 : openFileStart/openFileFinish(file,size)→ POINT-04 filename/filezise/opentime + * B4 : 构造函数 → 仅 qDebug + * + * 用例映射: + * - InitializeApp_StartThenFinish_LogsPoint01Duration → B1 + * - CloseApp_StartThenFinish_LogsPoint02Duration → B2 + * - OpenFile_StartThenFinish_LogsPoint04Info → B3 + * - Constructor_NewInstance_LogsCreated → B4 + * + * 隔离:QDateTime::currentDateTime 全部 stub 为固定队列时间(确定性时间差), + * qInfo/qDebug 经 qInstallMessageHandler 捕获后断言(TearDown 恢复默认 handler)。 + */ + +#include +#include "stubext.h" + +#include "performancemonitor.h" + +#include +#include +#include +#include +#include + +namespace { + +QCoreApplication *ensureApp() +{ + if (!QCoreApplication::instance()) { + static int argc = 1; + static char argv0[] = "test_common2"; + static char *argv[2] = { argv0, nullptr }; + static QCoreApplication app(argc, argv); + return &app; + } + return QCoreApplication::instance(); +} + +} // namespace + +// 消息捕获:静态指针指向当前用例的捕获缓冲(TearDown 置空,避免跨用例污染) +static QStringList *g_capturedLogs = nullptr; + +static void utCaptureHandler(QtMsgType type, const QMessageLogContext &, const QString &msg) +{ + if (g_capturedLogs) + g_capturedLogs->append(msg); + // 保持默认行为需要的最小输出抑制:不再回显 + Q_UNUSED(type); +} + +class PerformanceMonitorTest : public ::testing::Test +{ +protected: + void SetUp() override + { + ensureApp(); + stub.clear(); + logs.clear(); + timeQueue.clear(); + timeCalls = 0; + g_capturedLogs = &logs; + qInstallMessageHandler(utCaptureHandler); + stub.set_lamda(static_cast(&QDateTime::currentDateTime), + [this]() -> QDateTime { + ++timeCalls; + const qint64 ms = timeQueue.isEmpty() ? 0 : timeQueue.takeFirst(); + return QDateTime::fromMSecsSinceEpoch(ms); + }); + } + + void TearDown() override + { + qInstallMessageHandler(nullptr); // 恢复默认 handler + g_capturedLogs = nullptr; + stub.clear(); + } + + // 便捷:压入两次相同时间(每个方法内部会取 currentDateTime 两次:赋值 + 日志格式化) + void pushTime(qint64 ms) + { + timeQueue.append(ms); + timeQueue.append(ms); + } + + bool logsContain(const QString &sub) const + { + for (const QString &l : logs) + if (l.contains(sub, Qt::CaseSensitive)) + return true; + return false; + } + + stub_ext::StubExt stub; + QStringList logs; + QVector timeQueue; + int timeCalls = 0; +}; + +// B1 +TEST_F(PerformanceMonitorTest, InitializeApp_StartThenFinish_LogsPoint01Duration) +{ + // Arrange: start=1000ms, finish=2000ms → duration 1000ms + pushTime(1000); + pushTime(2000); + // Act + PerformanceMonitor::initializeAppStart(); + PerformanceMonitor::initializAppFinish(); + // Assert + EXPECT_TRUE(logsContain(QStringLiteral("[GRABPOINT] POINT-01 startduration=1000ms"))); + EXPECT_TRUE(logsContain(QStringLiteral("start to initialize app"))); + EXPECT_EQ(timeCalls, 4); +} + +// B2 +TEST_F(PerformanceMonitorTest, CloseApp_StartThenFinish_LogsPoint02Duration) +{ + // Arrange: closeAppStart 仅取一次当前时间;closeAPPFinish 取两次(赋值+日志格式化) + timeQueue.append(3000); + pushTime(4500); + // Act + PerformanceMonitor::closeAppStart(); + PerformanceMonitor::closeAPPFinish(); + // Assert + EXPECT_TRUE(logsContain(QStringLiteral("[GRABPOINT] POINT-02 closeduration=1500ms"))); + EXPECT_TRUE(logsContain(QStringLiteral("finish to close app"))); + EXPECT_EQ(timeCalls, 3); +} + +// B3 +TEST_F(PerformanceMonitorTest, OpenFile_StartThenFinish_LogsPoint04Info) +{ + // Arrange: 2MB 文件,耗时 500ms + pushTime(100000); + pushTime(100500); + // Act + PerformanceMonitor::openFileStart(); + PerformanceMonitor::openFileFinish(QStringLiteral("test-doc.txt"), 2LL * 1024 * 1024); + // Assert + EXPECT_TRUE(logsContain(QStringLiteral("[GRABPOINT] POINT-04"))); + EXPECT_TRUE(logsContain(QStringLiteral("filename=test-doc.txt"))); + EXPECT_TRUE(logsContain(QStringLiteral("filezise=2.000000M"))); + EXPECT_TRUE(logsContain(QStringLiteral("opentime=500ms"))); + EXPECT_EQ(timeCalls, 4); +} + +// B4 +TEST_F(PerformanceMonitorTest, Constructor_NewInstance_LogsCreated) +{ + // Arrange/Act + { + PerformanceMonitor monitor; + } + // Assert: 构造与析构不依赖任何外部资源,仅日志 + EXPECT_TRUE(logsContain(QStringLiteral("PerformanceMonitor instance created"))); + EXPECT_EQ(timeCalls, 0); +} diff --git a/tests/common2/test_savefileinterface.cpp b/tests/common2/test_savefileinterface.cpp new file mode 100644 index 00000000..eb8f6c78 --- /dev/null +++ b/tests/common2/test_savefileinterface.cpp @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +/* + * SaveFileInterface(dbusinterface) 单元测试 + * + * 分支清单(来源:SaveFileInterface 构造/析构/staticInterfaceName) + * B1 : staticInterfaceName() → "com.deepin.editor.daemon" + * B2 : 构造(service/path/connection) → QDBusAbstractInterface 记录三元组 + * B3 : 传入伪连接(未连接) → isValid() == false(不触碰真实 DBus) + * B4 : 析构 → 安全清理 + * B5 : DBusDaemon::dbus 类型别名 → 与 SaveFileInterface 同型 + * + * 用例映射: + * - StaticInterfaceName_ReturnsDaemonServiceName → B1 + * - Construct_WithFakeConnection_StoresEndpointInfo → B2+B3 + * - Construct_WithParent_KeepsQObjectParent → B2(parent 路径) + * - Destruct_ScopedInterface_CleansUpQuietly → B4 + * - TypeAlias_DBusDaemonDBus_MatchesSaveFileInterface → B5 + * + * 隔离:使用 QDBusConnection("ut-fake-bus")(不存在的连接名 → 未连接的连接对象), + * 全程不 connectToBus、不发起任何真实 DBus 会话/系统总线交互。 + */ + +#include +#include "stubext.h" + +#include "dbusinterface.h" + +#include +#include +#include +#include + +namespace { + +QCoreApplication *ensureApp() +{ + if (!QCoreApplication::instance()) { + static int argc = 1; + static char argv0[] = "test_common2"; + static char *argv[2] = { argv0, nullptr }; + static QCoreApplication app(argc, argv); + return &app; + } + return QCoreApplication::instance(); +} + +// 伪连接:名称不存在 → 未连接、isValid()==false 的连接对象(无真实总线访问) +QDBusConnection fakeConnection() +{ + return QDBusConnection(QStringLiteral("ut-fake-bus")); +} + +} // namespace + +class SaveFileInterfaceTest : public ::testing::Test +{ +protected: + void SetUp() override { ensureApp(); } +}; + +// B1 +TEST_F(SaveFileInterfaceTest, StaticInterfaceName_ReturnsDaemonServiceName) +{ + // Arrange/Act/Assert + EXPECT_STREQ(SaveFileInterface::staticInterfaceName(), "com.deepin.editor.daemon"); + EXPECT_EQ(QByteArray(SaveFileInterface::staticInterfaceName()).size(), 24); +} + +// B2+B3 +TEST_F(SaveFileInterfaceTest, Construct_WithFakeConnection_StoresEndpointInfo) +{ + // Arrange + const QDBusConnection conn = fakeConnection(); + // Act + { + SaveFileInterface iface(QStringLiteral("com.deepin.editor.daemon"), + QStringLiteral("/com/deepin/editor"), + conn); + // Assert: service/path/interface 三元组被记录 + EXPECT_EQ(iface.service(), QStringLiteral("com.deepin.editor.daemon")); + EXPECT_EQ(iface.path(), QStringLiteral("/com/deepin/editor")); + EXPECT_EQ(iface.interface(), QLatin1String(SaveFileInterface::staticInterfaceName())); + // 伪连接未连接 → 接口无效 + EXPECT_FALSE(iface.isValid()); + } // B4: 作用域析构安全 +} + +// B2(parent 路径) +TEST_F(SaveFileInterfaceTest, Construct_WithParent_KeepsQObjectParent) +{ + // Arrange + QObject parent; + // Act + SaveFileInterface *iface = new SaveFileInterface(QStringLiteral("com.deepin.editor.daemon"), + QStringLiteral("/p"), + fakeConnection(), + &parent); + // Assert + EXPECT_EQ(iface->parent(), &parent); + EXPECT_EQ(iface->path(), QStringLiteral("/p")); + delete iface; // 父对象不接管,直接删除验证析构安全 +} + +// B4 +TEST_F(SaveFileInterfaceTest, Destruct_ScopedInterface_CleansUpQuietly) +{ + // Arrange/Act: 构造后立即析构(含伪连接清理路径) + { + SaveFileInterface iface(QStringLiteral("svc"), QStringLiteral("/path"), fakeConnection()); + EXPECT_FALSE(iface.isValid()); + } + // Assert: 析构后仍可再次构造(连接对象复用不残留) + SaveFileInterface again(QStringLiteral("svc"), QStringLiteral("/path"), fakeConnection()); + EXPECT_EQ(again.service(), QStringLiteral("svc")); +} + +// B5 +TEST_F(SaveFileInterfaceTest, TypeAlias_DBusDaemonDBus_MatchesSaveFileInterface) +{ + // Arrange/Act/Assert: DBusDaemon::dbus 是 SaveFileInterface 的类型别名 + EXPECT_TRUE((std::is_same::value)); + EXPECT_STREQ(DBusDaemon::dbus::staticInterfaceName(), "com.deepin.editor.daemon"); +} diff --git a/tests/common2/test_textfilesaver.cpp b/tests/common2/test_textfilesaver.cpp new file mode 100644 index 00000000..0fd1604e --- /dev/null +++ b/tests/common2/test_textfilesaver.cpp @@ -0,0 +1,355 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +/* + * TextFileSaver 单元测试 + * + * 分支清单(来源:TextFileSaver::save / saveToFile / convertEncoding / saveAs) + * B1 : save() m_filePath 为空 → false + errorString "File path is empty" + * B2 : saveToFile() QFile::open 失败(目录) → false + errorString = 文件系统错误 + * B3 : 文档内存不足(第 1 次 memcheck false) → "Insufficient memory to load document content" + * B4 : 转换内存不足(第 2 次 memcheck false) → "Insufficient memory for encoding conversion" + * B5 : convertEncoding 失败(非法目标编码) → "Encoding conversion failed" + * B6 : file.write 返回值 != 数据长度 → false(写失败分支) + * B7 : m_useCRLF = true → 内容 \n 替换为 \r\n + * B8 : 分块循环 chunkSize(10MB) 多次迭代 → 全量写入 + * B9 : convertEncoding from==to(UTF-16) → 原样写入原始 UTF-16 字节 + * B10: saveAs 成功 → 新路径写入成功 + * B11: saveAs 失败 → m_filePath 回退原路径 + * B12: 默认 UTF-8 保存(UTF-16→UTF-8 iconv) → 文件字节 == toUtf8() + * + * 用例映射: + * - Save_EmptyFilePath_ReturnsFalseWithError → B1 + * - Save_Utf8Default_WritesExactBytes → B12 + * - Save_CrlfEnabled_ConvertsLineEndings → B7 + * - Save_EmptyDocument_WritesEmptyFileSucceeds → B12(空内容循环 0 次) + * - Save_TargetIsDirectory_OpenFailsReturnsFalse → B2 + * - Save_MemoryInsufficientDocument_ReturnsFalse → B3 + * - Save_MemoryInsufficientConversion_ReturnsFalse → B4 + * - Save_InvalidTargetEncoding_ConversionFails → B5 + * - Save_SameEncodingUtf16_WritesRawUtf16Bytes → B9 + * - Save_WriteShortCount_ReturnsFalse → B6 + * - SaveAs_NewPath_SucceedsAndWritesFile → B10 + * - SaveAs_FailureRestoresOriginalPath → B11 + * - Save_LargeDocumentMultiChunk_WritesAllBytes → B8 + * + * 隐式依赖处理: + * - Utils::isMemorySufficientForOperation:utils.cpp 为重 GUI 模块(DSettings/ + * DMessageManager/DGuiApplicationHelper 等),与保存逻辑无关,不入编译目标; + * 本文件提供本地链接定义并用 stub_ext 控制返回值(fixture 成员计数,无全局状态)。 + * - QApplication::processEvents:由 ensureApp() 的 QGuiApplication(offscreen) 承载。 + * - DetectCode::ChangeFileEncodingFormat:真实执行(iconv 行为已在本机验证确定性)。 + */ + +#include +#include "stubext.h" + +#include "text_file_saver.h" +#include "utils.h" + +#include +#include +#include +#include +#include +#include + +#include + +// 本地链接定义:行为由 fixture 内 stub 控制(见 SetUp) +bool Utils::isMemorySufficientForOperation(Utils::OperationType, qlonglong, qlonglong) +{ + return true; +} + +namespace { + +QCoreApplication *ensureApp() +{ + if (!QCoreApplication::instance()) { + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) + qputenv("QT_QPA_PLATFORM", "offscreen"); + static int argc = 1; + static char argv0[] = "test_common2"; + static char *argv[2] = { argv0, nullptr }; + static QGuiApplication app(argc, argv); + return &app; + } + return QCoreApplication::instance(); +} + +} // namespace + +class TextFileSaverTest : public ::testing::Test +{ +protected: + void SetUp() override + { + ensureApp(); + stub.clear(); + memResult = true; + memCallCount = 0; + writeCallCount = 0; + doc = new QTextDocument; + doc->setPlainText(QStringLiteral("hello 中文")); + obj = new TextFileSaver(doc); + tmpDir = std::make_unique(); + ASSERT_TRUE(tmpDir->isValid()); + installMemoryStub(); + } + + void TearDown() override + { + stub.clear(); + delete obj; + obj = nullptr; + delete doc; + doc = nullptr; + } + + void installMemoryStub() + { + stub.set_lamda(&Utils::isMemorySufficientForOperation, + [this](Utils::OperationType, qlonglong, qlonglong) -> bool { + ++memCallCount; + return memResult; + }); + } + + QString filePath(const QString &name) const { return tmpDir->path() + QLatin1Char('/') + name; } + + stub_ext::StubExt stub; + QTextDocument *doc = nullptr; + TextFileSaver *obj = nullptr; + std::unique_ptr tmpDir; + bool memResult = true; + int memCallCount = 0; + int writeCallCount = 0; +}; + +// B1 +TEST_F(TextFileSaverTest, Save_EmptyFilePath_ReturnsFalseWithError) +{ + // Arrange: 不设置文件路径(保持为空) + // Act + const bool ret = obj->save(); + // Assert: 返回失败且错误信息为未翻译源文(未加载翻译时 tr 原样返回) + EXPECT_FALSE(ret); + EXPECT_EQ(obj->errorString(), QStringLiteral("File path is empty")); +} + +// B12 +TEST_F(TextFileSaverTest, Save_Utf8Default_WritesExactBytes) +{ + // Arrange + doc->setPlainText(QStringLiteral("hello 中文\n")); + obj->setFilePath(filePath(QStringLiteral("utf8.txt"))); + // Act + const bool ret = obj->save(); + // Assert + EXPECT_TRUE(ret); + EXPECT_TRUE(obj->errorString().isEmpty()); + QFile f(filePath(QStringLiteral("utf8.txt"))); + ASSERT_TRUE(f.open(QIODevice::ReadOnly)); + EXPECT_EQ(f.readAll(), QStringLiteral("hello 中文\n").toUtf8()); + f.close(); +} + +// B7 +TEST_F(TextFileSaverTest, Save_CrlfEnabled_ConvertsLineEndings) +{ + // Arrange + doc->setPlainText(QStringLiteral("a\nb\nc")); + obj->setEndlineFormat(true); + obj->setFilePath(filePath(QStringLiteral("crlf.txt"))); + // Act + const bool ret = obj->save(); + // Assert + EXPECT_TRUE(ret); + QFile f(filePath(QStringLiteral("crlf.txt"))); + ASSERT_TRUE(f.open(QIODevice::ReadOnly)); + EXPECT_EQ(f.readAll(), QByteArrayLiteral("a\r\nb\r\nc")); + f.close(); +} + +// B12(空内容:分块循环 0 次迭代,仍成功创建空文件) +TEST_F(TextFileSaverTest, Save_EmptyDocument_WritesEmptyFileSucceeds) +{ + // Arrange + doc->setPlainText(QString()); + obj->setFilePath(filePath(QStringLiteral("empty.txt"))); + // Act + const bool ret = obj->save(); + // Assert + EXPECT_TRUE(ret); + QFile f(filePath(QStringLiteral("empty.txt"))); + ASSERT_TRUE(f.open(QIODevice::ReadOnly)); + EXPECT_EQ(f.size(), 0); + f.close(); +} + +// B2 +TEST_F(TextFileSaverTest, Save_TargetIsDirectory_OpenFailsReturnsFalse) +{ + // Arrange: 目标路径是已存在目录,QFile::open(WriteOnly) 必然失败 + obj->setFilePath(tmpDir->path()); + // Act + const bool ret = obj->save(); + // Assert + EXPECT_FALSE(ret); + EXPECT_FALSE(obj->errorString().isEmpty()); +} + +// B3 +TEST_F(TextFileSaverTest, Save_MemoryInsufficientDocument_ReturnsFalse) +{ + // Arrange + memResult = false; + obj->setFilePath(filePath(QStringLiteral("mem1.txt"))); + // Act + const bool ret = obj->save(); + // Assert: 首次内存检查即失败 + EXPECT_FALSE(ret); + EXPECT_EQ(obj->errorString(), QStringLiteral("Insufficient memory to load document content")); + EXPECT_EQ(memCallCount, 1); + // 文件已先被打开截断,应为空文件 + QFile f(filePath(QStringLiteral("mem1.txt"))); + ASSERT_TRUE(f.open(QIODevice::ReadOnly)); + EXPECT_EQ(f.size(), 0); + f.close(); +} + +// B4 +TEST_F(TextFileSaverTest, Save_MemoryInsufficientConversion_ReturnsFalse) +{ + // Arrange: 第 1 次检查(文档)通过、第 2 次检查(转换)失败 + stub.reset(&Utils::isMemorySufficientForOperation); + stub.set_lamda(&Utils::isMemorySufficientForOperation, + [this](Utils::OperationType, qlonglong, qlonglong) -> bool { + ++memCallCount; + return memCallCount == 1; // 首次 true,后续 false + }); + obj->setFilePath(filePath(QStringLiteral("mem2.txt"))); + // Act + const bool ret = obj->save(); + // Assert + EXPECT_FALSE(ret); + EXPECT_EQ(obj->errorString(), QStringLiteral("Insufficient memory for encoding conversion")); + EXPECT_EQ(memCallCount, 2); +} + +// B5 +TEST_F(TextFileSaverTest, Save_InvalidTargetEncoding_ConversionFails) +{ + // Arrange: 非法目标编码 → iconv_open 失败 → QTextCodec 也找不到 → 转换失败 + obj->setEncoding("UT-NOT-EXIST"); + obj->setFilePath(filePath(QStringLiteral("badenc.txt"))); + // Act + const bool ret = obj->save(); + // Assert + EXPECT_FALSE(ret); + EXPECT_EQ(obj->errorString(), QStringLiteral("Encoding conversion failed")); +} + +// B9 +TEST_F(TextFileSaverTest, Save_SameEncodingUtf16_WritesRawUtf16Bytes) +{ + // Arrange: m_fromEncode(UTF-16) == m_toEncode → 无转换,原样写入 QString::utf16 字节 + const QString text = QStringLiteral("hi 中文"); + doc->setPlainText(text); + obj->setEncoding("UTF-16"); + obj->setFilePath(filePath(QStringLiteral("utf16.txt"))); + // Act + const bool ret = obj->save(); + // Assert + EXPECT_TRUE(ret); + QFile f(filePath(QStringLiteral("utf16.txt"))); + ASSERT_TRUE(f.open(QIODevice::ReadOnly)); + const QByteArray raw(reinterpret_cast(text.utf16()), text.size() * 2); + EXPECT_EQ(f.readAll(), raw); + f.close(); +} + +// B6 +TEST_F(TextFileSaverTest, Save_WriteShortCount_ReturnsFalse) +{ + // Arrange: stub QIODevice::write 返回 maxSize-1,触发写入不完整分支 + using WritePtr = qint64 (QIODevice::*)(const char *, qint64); + stub.set_lamda(static_cast(&QIODevice::write), + [this](QIODevice *, const char *, qint64 maxSize) -> qint64 { + ++writeCallCount; + return maxSize - 1; + }); + obj->setFilePath(filePath(QStringLiteral("wfail.txt"))); + // Act + const bool ret = obj->save(); + // Assert + EXPECT_FALSE(ret); + EXPECT_EQ(writeCallCount, 1); +} + +// B10 +TEST_F(TextFileSaverTest, SaveAs_NewPath_SucceedsAndWritesFile) +{ + // Arrange + obj->setFilePath(filePath(QStringLiteral("origin.txt"))); + const QString newPath = filePath(QStringLiteral("saved-as.txt")); + // Act + const bool ret = obj->saveAs(newPath); + // Assert + EXPECT_TRUE(ret); + QFile f(newPath); + ASSERT_TRUE(f.open(QIODevice::ReadOnly)); + EXPECT_EQ(f.readAll(), QStringLiteral("hello 中文").toUtf8()); + f.close(); +} + +// B11 +TEST_F(TextFileSaverTest, SaveAs_FailureRestoresOriginalPath) +{ + // Arrange + const QString originPath = filePath(QStringLiteral("restore.txt")); + obj->setFilePath(originPath); + // Act: saveAs 到目录路径 → 失败并回退 + const bool failed = obj->saveAs(tmpDir->path()); + // Assert: 失败后原路径保持,后续 save() 写入原路径成功 + EXPECT_FALSE(failed); + EXPECT_TRUE(obj->save()); + QFile f(originPath); + ASSERT_TRUE(f.open(QIODevice::ReadOnly)); + EXPECT_EQ(f.readAll(), QStringLiteral("hello 中文").toUtf8()); + f.close(); +} + +// B8 +TEST_F(TextFileSaverTest, Save_LargeDocumentMultiChunk_WritesAllBytes) +{ + // Arrange: 11MB 文本 → chunkSize = max(10MB, 11MB/8) = 10MB → 2 个分块 + const int chunkChars = 11 * 1024 * 1024; + QString big(chunkChars, QChar(QLatin1Char('a'))); + big.append(QStringLiteral("END")); + doc->setPlainText(big); + obj->setFilePath(filePath(QStringLiteral("big.txt"))); + // Act + const bool ret = obj->save(); + // Assert + EXPECT_TRUE(ret); + QFile f(filePath(QStringLiteral("big.txt"))); + ASSERT_TRUE(f.open(QIODevice::ReadOnly)); + const QByteArray data = f.readAll(); + f.close(); + EXPECT_EQ(data.size(), big.toUtf8().size()); + EXPECT_EQ(data.left(1), QByteArrayLiteral("a")); + EXPECT_TRUE(data.endsWith(QByteArrayLiteral("END"))); + EXPECT_GE(memCallCount, 3); // 1 次文档检查 + 每分块 1 次转换检查 +} + +// 构造/析构与 errorString 取值(每个用例的 SetUp/TearDown 均覆盖构造析构) +TEST_F(TextFileSaverTest, ErrorString_InitiallyEmpty_ReturnsEmptyByDefault) +{ + // Arrange/Act/Assert: 新建对象错误串为空;getter 语义为返回最近一次错误 + EXPECT_TRUE(obj->errorString().isEmpty()); + obj->setFilePath(QString()); + EXPECT_FALSE(obj->save()); + EXPECT_FALSE(obj->errorString().isEmpty()); +}