From 026dcd42eba211b6abb49f15011f8346af328dd4 Mon Sep 17 00:00:00 2001 From: zhaofangxun Date: Tue, 18 Aug 2026 17:05:05 +0800 Subject: [PATCH 1/2] test(frame): add GTest tests for 6 core classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add 74 GTest unit tests covering 6 frame/ core classes 2. Cover DPluginMetaData, DAppletData, DAppletItemModel 3. Cover KExtraColumnsProxyModel, ListToTableProxyModel, DAppletFactory 4. Use OBJECT lib frame_test_objects linked into 5 test executables 5. Add FRAME_BUILD_COVERAGE option and frame_coverage target 6. Gate tests behind BUILD_TESTING following taskmanager pattern 7. frame/ core framework had zero test coverage before this change Influence: 1. Run ctest -R '^frame_' to execute all 74 unit tests 2. Enable FRAME_BUILD_COVERAGE=ON and build frame_coverage target 3. Verify coverage report at build/frame_coverage/html/index.html test(frame): 为6个核心类添加GTest单元测试 1. 为 frame/ 6 个核心类添加 74 个 GTest 单元测试 2. 覆盖 DPluginMetaData、DAppletData、DAppletItemModel 3. 覆盖 KExtraColumnsProxyModel、ListToTableProxyModel、DAppletFactory 4. 通过 OBJECT 库 frame_test_objects 链入 5 个测试可执行 5. 新增 FRAME_BUILD_COVERAGE 选项与 frame_coverage target 6. 以 BUILD_TESTING 门控,沿用 taskmanager 的 gtest_discover_tests 模式 7. frame/ 核心框架此前测试覆盖为零 Influence: 1. 通过 ctest -R '^frame_' 运行全部 74 个单元测试 2. 启用 FRAME_BUILD_COVERAGE=ON 并构建 frame_coverage target 3. 在 build/frame_coverage/html/index.html 查看覆盖率报告 --- tests/CMakeLists.txt | 1 + tests/frame/CMakeLists.txt | 137 ++++++ tests/frame/appletdatatests.cpp | 193 ++++++++ tests/frame/appletitemmodeltests.cpp | 203 ++++++++ tests/frame/kextracolumnsproxymodeltests.cpp | 481 +++++++++++++++++++ tests/frame/listtotableproxymodeltests.cpp | 239 +++++++++ tests/frame/pluginfactorytests.cpp | 98 ++++ tests/frame/pluginmetadatatests.cpp | 225 +++++++++ 8 files changed, 1577 insertions(+) create mode 100644 tests/frame/CMakeLists.txt create mode 100644 tests/frame/appletdatatests.cpp create mode 100644 tests/frame/appletitemmodeltests.cpp create mode 100644 tests/frame/kextracolumnsproxymodeltests.cpp create mode 100644 tests/frame/listtotableproxymodeltests.cpp create mode 100644 tests/frame/pluginfactorytests.cpp create mode 100644 tests/frame/pluginmetadatatests.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 77e6bee23..abd7d4aac 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,3 +3,4 @@ # SPDX-License-Identifier: CC0-1.0 add_subdirectory(panels) +add_subdirectory(frame) diff --git a/tests/frame/CMakeLists.txt b/tests/frame/CMakeLists.txt new file mode 100644 index 000000000..730606052 --- /dev/null +++ b/tests/frame/CMakeLists.txt @@ -0,0 +1,137 @@ +# SPDX-FileCopyrightText: 2024 - 2026 UnionTech Software Technology Co., Ltd. +# +# SPDX-License-Identifier: CC0-1.0 + +# ============================================================================= +# dde-shell frame/ core framework unit tests (GTest). +# +# New-workflow adaptation (DDE-107 round 3): +# * Sources under test are compiled ONCE into a single OBJECT library +# (frame_test_objects) and linked into every test executable, instead of +# being recompiled per add_executable (round 1/2). This keeps gcov +# instrumentation in one place and yields a single dsLog definition +# (Q_LOGGING_CATEGORY lives in pluginmetadata.cpp). +# * protected/private members are exposed to test code via a scoped +# `#define private public` block in listtotableproxymodeltests.cpp +# (around the model header only), NOT via a global -D compile definition +# (the latter breaks gtest/libstdc++ — see B1 review finding). Required +# by listtotableproxymodeltests, which populates +# ListToTableProxyModel::m_roles directly because the `roles` Q_PROPERTY is +# only ever set from QML in production and QList has no +# Q_DECLARE_METATYPE in this repo. kextracolumns and pluginfactory tests +# use only public API and need no visibility macro. +# * Coverage instrumentation + report generation are build-system targets: +# option FRAME_BUILD_COVERAGE + custom target `frame_coverage`, emitting +# into ${CMAKE_BINARY_DIR}/frame_coverage. +# * Pattern (GTest + gtest_discover_tests, BUILD_TESTING gate) follows +# tests/panels/dock/taskmanager. Every TEST is registered with the +# `frame_` ctest prefix so `ctest -R '^frame_'` runs only these tests. +# ============================================================================= + +find_package(GTest REQUIRED) +find_package(Qt${QT_VERSION_MAJOR} ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS Core Gui Qml Test) + +include(GoogleTest) + +option(FRAME_BUILD_COVERAGE "Enable gcov coverage instrumentation for dde-shell frame unit tests" OFF) + +# ---- OBJECT library: all frame/ sources under test, compiled once ------------ +# DS_LIB mirrors frame/CMakeLists.txt so the DS_SHARE export macro is correct +# while compiling these sources into the test objects. +add_library(frame_test_objects OBJECT + ${CMAKE_SOURCE_DIR}/frame/pluginmetadata.h + ${CMAKE_SOURCE_DIR}/frame/pluginmetadata.cpp + ${CMAKE_SOURCE_DIR}/frame/appletdata.h + ${CMAKE_SOURCE_DIR}/frame/appletdata.cpp + ${CMAKE_SOURCE_DIR}/frame/appletitemmodel.h + ${CMAKE_SOURCE_DIR}/frame/appletitemmodel.cpp + ${CMAKE_SOURCE_DIR}/frame/models/kextracolumnsproxymodel.h + ${CMAKE_SOURCE_DIR}/frame/models/kextracolumnsproxymodel.cpp + ${CMAKE_SOURCE_DIR}/frame/models/listtotableproxymodel.h + ${CMAKE_SOURCE_DIR}/frame/models/listtotableproxymodel.cpp + ${CMAKE_SOURCE_DIR}/frame/pluginfactory.h + ${CMAKE_SOURCE_DIR}/frame/pluginfactory.cpp +) +target_compile_definitions(frame_test_objects PRIVATE DS_LIB) +target_include_directories(frame_test_objects PUBLIC + ${CMAKE_SOURCE_DIR}/frame + ${CMAKE_SOURCE_DIR}/frame/models +) +target_link_libraries(frame_test_objects PUBLIC + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Qml +) + +# ---- Helper: declare a frame test executable with the standard link set ------- +set(FRAME_TEST_TARGETS "") +function(frame_add_test NAME) + add_executable(${NAME} ${ARGN}) + target_link_libraries(${NAME} PRIVATE + GTest::GTest + GTest::Main + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::Test + frame_test_objects + ) + gtest_discover_tests(${NAME} TEST_PREFIX "frame_") + list(APPEND FRAME_TEST_TARGETS ${NAME}) + set(FRAME_TEST_TARGETS "${FRAME_TEST_TARGETS}" PARENT_SCOPE) +endfunction() + +# ---- Test executables -------------------------------------------------------- +# Pre-existing (round 1/2, reviewed & 45/45 passing) — test sources unchanged. +frame_add_test(pluginmetadata_tests pluginmetadatatests.cpp) +frame_add_test(appletdata_tests appletdatatests.cpp) +frame_add_test(appletitemmodel_tests appletitemmodeltests.cpp) + +# New (round 3) — extend coverage to previously-untested frame/ modules. +frame_add_test(proxymodel_tests + kextracolumnsproxymodeltests.cpp + listtotableproxymodeltests.cpp +) +frame_add_test(pluginfactory_tests pluginfactorytests.cpp) + +# Visibility: private-member access for ListToTableProxyModel::m_roles / +# m_sourceColumn is done via a scoped `#define private public` block in +# listtotableproxymodeltests.cpp itself (around the model header only), NOT +# via a global -Dprivate=public compile definition — the latter breaks +# gtest/libstdc++ (/ access redeclaration). kextracolumns and +# pluginfactory tests use only public API and need no visibility macro. + +# ---- Coverage instrumentation + report generation (build-system target) ------- +if(FRAME_BUILD_COVERAGE) + message(STATUS "frame tests: coverage instrumentation ENABLED (FRAME_BUILD_COVERAGE=ON)") + # Instrument the single OBJECT lib (covers all sources under test) and the + # test executables (so the link step pulls in libgcov). + target_compile_options(frame_test_objects PRIVATE -fprofile-arcs -ftest-coverage -O0 -g) + foreach(_t IN LISTS FRAME_TEST_TARGETS) + target_compile_options(${_t} PRIVATE -fprofile-arcs -ftest-coverage -O0 -g) + target_link_options(${_t} PRIVATE -fprofile-arcs) + endforeach() + + find_program(LCOV_BIN lcov) + find_program(GENHTML_BIN genhtml) + if(LCOV_BIN AND GENHTML_BIN) + set(FRAME_COVERAGE_DIR "${CMAKE_BINARY_DIR}/frame_coverage") + add_custom_target(frame_coverage + DEPENDS ${FRAME_TEST_TARGETS} + COMMAND ${CMAKE_COMMAND} -E make_directory "${FRAME_COVERAGE_DIR}" + COMMAND ${CMAKE_CTEST_COMMAND} --test-dir "${CMAKE_BINARY_DIR}" -R "^frame_" --output-on-failure + COMMAND ${LCOV_BIN} --capture --directory "${CMAKE_BINARY_DIR}" + --output-file "${FRAME_COVERAGE_DIR}/frame.info" + COMMAND ${LCOV_BIN} --remove "${FRAME_COVERAGE_DIR}/frame.info" + '*/usr/include/*' '*/usr/lib/*' '*/3rdparty/*' '*/tests/*' + '*/moc_*' '*/qrc_*' '*/_deps/*' + -o "${FRAME_COVERAGE_DIR}/frame_filtered.info" + COMMAND ${GENHTML_BIN} "${FRAME_COVERAGE_DIR}/frame_filtered.info" + -o "${FRAME_COVERAGE_DIR}/html" --branch-coverage + COMMAND ${CMAKE_COMMAND} -E echo "=== Frame coverage report: ${FRAME_COVERAGE_DIR}/html/index.html ===" + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}" + COMMENT "Run frame unit tests and generate lcov branch-coverage report" + VERBATIM + ) + else() + message(WARNING "lcov or genhtml not found; 'frame_coverage' target not created. Install the 'lcov' package to enable.") + endif() +endif() diff --git a/tests/frame/appletdatatests.cpp b/tests/frame/appletdatatests.cpp new file mode 100644 index 000000000..de22a1556 --- /dev/null +++ b/tests/frame/appletdatatests.cpp @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include + +#include +#include +#include +#include + +#include +#include + +#include "appletdata.h" +#include "pluginmetadata.h" + +using namespace ds; + +// Helper: build a QVariantMap from key/value pairs of QStrings. +static QVariantMap makeMap(std::initializer_list> items) +{ + QVariantMap map; + for (const auto &item : items) { + map[QString::fromLatin1(item.first)] = QString::fromLatin1(item.second); + } + return map; +} + +// Default-constructed data is invalid (no PluginId). +TEST(DAppletData, DefaultIsInvalid) +{ + DAppletData data; + EXPECT_FALSE(data.isValid()); + EXPECT_TRUE(data.id().isEmpty()); + EXPECT_TRUE(data.pluginId().isEmpty()); +} + +// Constructing from a plugin id sets PluginId and makes the data valid. +TEST(DAppletData, ConstructFromPluginId) +{ + DAppletData data(QStringLiteral("org.deepin.ds.test")); + EXPECT_TRUE(data.isValid()); + EXPECT_EQ(data.pluginId(), QStringLiteral("org.deepin.ds.test")); + EXPECT_TRUE(data.id().isEmpty()); // Id not set +} + +// Constructing from a QVariantMap copies the metadata verbatim. +TEST(DAppletData, ConstructFromVariantMap) +{ + QVariantMap map = makeMap({{"PluginId", "org.deepin.ds.test"}, {"Id", "test-instance"}}); + DAppletData data(map); + EXPECT_TRUE(data.isValid()); + EXPECT_EQ(data.pluginId(), QStringLiteral("org.deepin.ds.test")); + EXPECT_EQ(data.id(), QStringLiteral("test-instance")); +} + +// setId / id round-trips through the internal metadata. +TEST(DAppletData, SetAndGetId) +{ + DAppletData data(QStringLiteral("org.deepin.ds.test")); + data.setId(QStringLiteral("my-id")); + EXPECT_EQ(data.id(), QStringLiteral("my-id")); +} + +// value() returns stored keys and falls back to the provided default. +TEST(DAppletData, ValueAndDefault) +{ + QVariantMap map; + map["PluginId"] = QStringLiteral("org.deepin.ds.test"); + map["Custom"] = 42; + DAppletData data(map); + EXPECT_EQ(data.value("Custom").toInt(), 42); + EXPECT_EQ(data.value("Missing", QStringLiteral("def")).toString(), QStringLiteral("def")); +} + +// value() on invalid data always returns the default (short-circuit). +TEST(DAppletData, ValueOnInvalidReturnsDefault) +{ + DAppletData data; + EXPECT_EQ(data.value("Any", 7).toInt(), 7); + EXPECT_EQ(data.value("Any").toString(), QString()); +} + +// toMap() exposes the raw metadata map. +TEST(DAppletData, ToMap) +{ + QVariantMap map; + map["PluginId"] = QStringLiteral("org.deepin.ds.test"); + map["Id"] = QStringLiteral("inst"); + DAppletData data(map); + EXPECT_EQ(data.toMap(), map); +} + +// groupList on data without a Group key is empty. +TEST(DAppletData, GroupListEmptyByDefault) +{ + DAppletData data(QStringLiteral("org.deepin.ds.test")); + EXPECT_TRUE(data.groupList().isEmpty()); +} + +// groupList / setGroupList round-trips nested group metadata. +TEST(DAppletData, GroupListRoundTrip) +{ + DAppletData data(QStringLiteral("org.deepin.ds.test")); + + QVariantMap g1map = makeMap({{"PluginId", "org.deepin.ds.g1"}, {"Id", "g1inst"}}); + QVariantMap g2map = makeMap({{"PluginId", "org.deepin.ds.g2"}, {"Id", "g2inst"}}); + DAppletData g1(g1map); + DAppletData g2(g2map); + data.setGroupList({g1, g2}); + + const auto groups = data.groupList(); + ASSERT_EQ(groups.size(), 2); + EXPECT_EQ(groups[0].pluginId(), QStringLiteral("org.deepin.ds.g1")); + EXPECT_EQ(groups[0].id(), QStringLiteral("g1inst")); + EXPECT_EQ(groups[1].pluginId(), QStringLiteral("org.deepin.ds.g2")); + EXPECT_EQ(groups[1].id(), QStringLiteral("g2inst")); +} + +// setGroupList with an empty list clears the Group entry. +TEST(DAppletData, SetEmptyGroupList) +{ + DAppletData data(QStringLiteral("org.deepin.ds.test")); + data.setGroupList({DAppletData(QStringLiteral("org.deepin.ds.g1"))}); + ASSERT_EQ(data.groupList().size(), 1); + data.setGroupList({}); + EXPECT_TRUE(data.groupList().isEmpty()); +} + +// fromPluginMetaData copies the plugin id into a new DAppletData. +TEST(DAppletData, FromPluginMetaData) +{ + auto meta = DPluginMetaData::fromJsonString(R"({"Plugin":{"Id":"org.deepin.ds.frommeta"}})"); + ASSERT_TRUE(meta.isValid()); + DAppletData data = DAppletData::fromPluginMetaData(meta); + EXPECT_TRUE(data.isValid()); + EXPECT_EQ(data.pluginId(), meta.pluginId()); +} + +// fromPluginMetaData on invalid metadata still yields an invalid DAppletData. +TEST(DAppletData, FromInvalidPluginMetaData) +{ + DPluginMetaData invalid; + DAppletData data = DAppletData::fromPluginMetaData(invalid); + EXPECT_FALSE(data.isValid()); + EXPECT_TRUE(data.pluginId().isEmpty()); +} + +// Copy construction shares the implicitly-shared data. +TEST(DAppletData, CopyConstructor) +{ + DAppletData original(QStringLiteral("org.deepin.ds.test")); + original.setId(QStringLiteral("orig")); + DAppletData copy(original); + EXPECT_EQ(copy.pluginId(), original.pluginId()); + EXPECT_EQ(copy.id(), original.id()); +} + +// Copy assignment shares the implicitly-shared data. +TEST(DAppletData, CopyAssignment) +{ + DAppletData original(QStringLiteral("org.deepin.ds.test")); + original.setId(QStringLiteral("orig")); + DAppletData assigned; + assigned = original; + EXPECT_EQ(assigned.pluginId(), original.pluginId()); + EXPECT_EQ(assigned.id(), original.id()); +} + +// operator== compares id only (not pluginId). +TEST(DAppletData, EqualityById) +{ + DAppletData a(QStringLiteral("org.deepin.ds.a")); + a.setId(QStringLiteral("same-id")); + DAppletData b(QStringLiteral("org.deepin.ds.b")); // different pluginId + b.setId(QStringLiteral("same-id")); + DAppletData c(QStringLiteral("org.deepin.ds.a")); + c.setId(QStringLiteral("other-id")); + EXPECT_TRUE(a == b); + EXPECT_FALSE(a == c); +} + +// Destructor must not crash for default, valid and copied instances. +TEST(DAppletData, DestructorSafety) +{ + EXPECT_NO_THROW({ + DAppletData invalid; + DAppletData valid(QStringLiteral("org.deepin.ds.dtor")); + DAppletData copy(valid); + // all three go out of scope here. + }); +} diff --git a/tests/frame/appletitemmodeltests.cpp b/tests/frame/appletitemmodeltests.cpp new file mode 100644 index 000000000..4ce9be627 --- /dev/null +++ b/tests/frame/appletitemmodeltests.cpp @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include "appletitemmodel.h" + +using namespace ds; + +// An empty model reports zero rows and exposes the single "data" role. +TEST(DAppletItemModel, EmptyModel) +{ + DAppletItemModel model; + EXPECT_EQ(model.rowCount(QModelIndex()), 0); + EXPECT_EQ(model.rowCount(QModelIndex()), 0); + EXPECT_TRUE(model.rootObjects().isEmpty()); + + const auto roleNames = model.roleNames(); + ASSERT_TRUE(roleNames.contains(DAppletItemModel::Data)); + EXPECT_EQ(roleNames.value(DAppletItemModel::Data), QByteArrayLiteral("data")); +} + +// append() grows rowCount and emits rowsInserted with the right span. +TEST(DAppletItemModel, AppendGrowsRowCount) +{ + DAppletItemModel model; + QObject o1, o2; + + QSignalSpy insertSpy(&model, &DAppletItemModel::rowsInserted); + ASSERT_TRUE(insertSpy.isValid()); + + model.append(&o1); + ASSERT_EQ(insertSpy.count(), 1); + const auto args1 = insertSpy.takeFirst(); + EXPECT_EQ(args1.at(1).toInt(), 0); // first + EXPECT_EQ(args1.at(2).toInt(), 0); // last + EXPECT_EQ(model.rowCount(QModelIndex()), 1); + + model.append(&o2); + ASSERT_EQ(insertSpy.count(), 1); + const auto args2 = insertSpy.takeFirst(); + EXPECT_EQ(args2.at(1).toInt(), 1); // first + EXPECT_EQ(args2.at(2).toInt(), 1); // last + EXPECT_EQ(model.rowCount(QModelIndex()), 2); + + EXPECT_EQ(model.rootObjects().size(), 2); + EXPECT_EQ(model.rootObjects().first(), &o1); + EXPECT_EQ(model.rootObjects().last(), &o2); +} + +// data() returns the stored QObject* under the Data role. +TEST(DAppletItemModel, DataReturnsStoredObject) +{ + DAppletItemModel model; + QObject o1, o2; + model.append(&o1); + model.append(&o2); + + EXPECT_EQ(model.index(0).data(DAppletItemModel::Data).value(), &o1); + EXPECT_EQ(model.index(1).data(DAppletItemModel::Data).value(), &o2); +} + +// data() for an out-of-range row returns an empty QVariant (observable contract). +TEST(DAppletItemModel, DataOutOfRangeIsEmpty) +{ + DAppletItemModel model; + QObject o; + model.append(&o); + + const QModelIndex outOfRange = model.index(model.rowCount(QModelIndex()), 0); + EXPECT_FALSE(outOfRange.isValid()); + EXPECT_FALSE(outOfRange.data(DAppletItemModel::Data).isValid()); +} + +// data() with an unknown role returns an empty QVariant. +TEST(DAppletItemModel, DataUnknownRole) +{ + DAppletItemModel model; + QObject o; + model.append(&o); + EXPECT_FALSE(model.index(0).data(Qt::UserRole + 999).isValid()); +} + +// remove() shrinks rowCount and emits rowsRemoved with the right span. +TEST(DAppletItemModel, RemoveShrinksRowCount) +{ + DAppletItemModel model; + QObject o1, o2, o3; + model.append(&o1); + model.append(&o2); + model.append(&o3); + ASSERT_EQ(model.rowCount(QModelIndex()), 3); + + QSignalSpy removeSpy(&model, &DAppletItemModel::rowsRemoved); + ASSERT_TRUE(removeSpy.isValid()); + + model.remove(&o2); + ASSERT_EQ(removeSpy.count(), 1); + const auto args = removeSpy.takeFirst(); + EXPECT_EQ(args.at(1).toInt(), 1); // first + EXPECT_EQ(args.at(2).toInt(), 1); // last + EXPECT_EQ(model.rowCount(QModelIndex()), 2); + + const auto roots = model.rootObjects(); + ASSERT_EQ(roots.size(), 2); + EXPECT_EQ(roots.at(0), &o1); + EXPECT_EQ(roots.at(1), &o3); +} + +// remove() of an object not present is a no-op (no signal, no row change). +TEST(DAppletItemModel, RemoveMissingIsNoOp) +{ + DAppletItemModel model; + QObject o1, other; + model.append(&o1); + ASSERT_EQ(model.rowCount(QModelIndex()), 1); + + QSignalSpy removeSpy(&model, &DAppletItemModel::rowsRemoved); + model.remove(&other); + EXPECT_EQ(removeSpy.count(), 0); + EXPECT_EQ(model.rowCount(QModelIndex()), 1); + EXPECT_EQ(model.rootObjects().size(), 1); +} + +// Removing down to empty leaves rowCount 0 and an empty object list. +TEST(DAppletItemModel, RemoveUntilEmpty) +{ + DAppletItemModel model; + QObject o1, o2; + model.append(&o1); + model.append(&o2); + model.remove(&o1); + model.remove(&o2); + EXPECT_EQ(model.rowCount(QModelIndex()), 0); + EXPECT_TRUE(model.rootObjects().isEmpty()); +} + +// rootObjects() reflects the live internal list after mutations. +TEST(DAppletItemModel, RootObjectsReflectsMutations) +{ + DAppletItemModel model; + QObject o1, o2; + model.append(&o1); + model.append(&o2); + + ASSERT_EQ(model.rootObjects().size(), 2); + EXPECT_EQ(model.rootObjects().at(0), &o1); + EXPECT_EQ(model.rootObjects().at(1), &o2); + + model.remove(&o1); + ASSERT_EQ(model.rootObjects().size(), 1); + EXPECT_EQ(model.rootObjects().at(0), &o2); +} + +// QAbstractItemModelTester validates model invariants across mutations. +// If the model violates Qt model/view contracts, the tester asserts fatally. +TEST(DAppletItemModel, ModelTesterValidation) +{ + DAppletItemModel model; + auto tester = std::make_unique( + &model, QAbstractItemModelTester::FailureReportingMode::Fatal); + + QObject o1, o2, o3, o4; + model.append(&o1); + model.append(&o2); + model.append(&o3); + model.remove(&o2); + model.append(&o4); + model.remove(&o1); + model.remove(&o3); + model.remove(&o4); + + EXPECT_EQ(model.rowCount(QModelIndex()), 0); +} + +// ModelTester on an initially-empty model with append/remove interleaving. +TEST(DAppletItemModel, ModelTesterAppendRemoveInterleaved) +{ + DAppletItemModel model; + auto tester = std::make_unique( + &model, QAbstractItemModelTester::FailureReportingMode::Fatal); + + QObject items[4]; + model.append(&items[0]); + model.append(&items[1]); + model.remove(&items[0]); + model.append(&items[2]); + model.remove(&items[1]); + model.append(&items[3]); + model.remove(&items[2]); + model.remove(&items[3]); + EXPECT_EQ(model.rowCount(QModelIndex()), 0); +} diff --git a/tests/frame/kextracolumnsproxymodeltests.cpp b/tests/frame/kextracolumnsproxymodeltests.cpp new file mode 100644 index 000000000..46df287e5 --- /dev/null +++ b/tests/frame/kextracolumnsproxymodeltests.cpp @@ -0,0 +1,481 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +// Unit tests for KExtraColumnsProxyModel (frame/models/kextracolumnsproxymodel), +// a vendored KDE QIdentityProxyModel subclass that appends extra columns. +// +// KExtraColumnsProxyModel is abstract (extraColumnData() is pure virtual), so +// these tests drive it through a minimal concrete subclass defined below. The +// subclass overrides extraColumnData()/setExtraColumnData() with deterministic, +// controlled storage so the base-class branching logic is exercised without +// depending on ListToTableProxyModel's m_roles contract. +// +// Stack declaration order: the source QStandardItemModel is always declared +// BEFORE the proxy so that, on reverse stack destruction, the proxy (declared +// later) is destroyed first while the source is still alive — the same +// convention as tests/panels/dock/taskmanager. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "kextracolumnsproxymodel.h" + +// Concrete subclass under test: deterministic extra-column storage. +class TestExtraColumnsModel : public KExtraColumnsProxyModel +{ + Q_OBJECT +public: + explicit TestExtraColumnsModel(QObject *parent = nullptr) + : KExtraColumnsProxyModel(parent) {} + + QVariant extraColumnData(const QModelIndex &parent, int row, int extraColumn, + int role = Qt::DisplayRole) const override + { + Q_UNUSED(parent) + Q_UNUSED(row) + if (role != Qt::DisplayRole) + return QVariant(); + return m_extraData.value(extraColumn); + } + + bool setExtraColumnData(const QModelIndex &parent, int row, int extraColumn, + const QVariant &data, int role = Qt::EditRole) override + { + Q_UNUSED(parent) + Q_UNUSED(row) + Q_UNUSED(role) + m_extraData[extraColumn] = data; + extraColumnDataChanged(parent, row, extraColumn, {role}); + return true; + } + + QVariant extraValue(int extraCol) const { return m_extraData.value(extraCol); } + +private: + QHash m_extraData; +}; + +// Custom source model that exposes the (protected) layout-change signals so the +// proxy's _ec_sourceLayout{AboutToBeChanged,Changed} private slots fire. Used +// only by the LayoutChangeHandlers test below. +class TestLayoutSourceModel : public QStandardItemModel +{ + Q_OBJECT +public: + explicit TestLayoutSourceModel(QObject *parent = nullptr) : QStandardItemModel(parent) {} + void emitLayoutAboutToBeChanged(const QList &parents, + QAbstractItemModel::LayoutChangeHint hint = QAbstractItemModel::NoLayoutChangeHint) + { emit layoutAboutToBeChanged(parents, hint); } + void emitLayoutChanged(const QList &parents, + QAbstractItemModel::LayoutChangeHint hint = QAbstractItemModel::NoLayoutChangeHint) + { emit layoutChanged(parents, hint); } +}; + +namespace { + +// Build a 1-column, N-row source model whose DisplayRole text is "r". +QStandardItemModel *makeSourceModel(int rows, QObject *parent) +{ + auto *src = new QStandardItemModel(parent); + for (int r = 0; r < rows; ++r) + src->appendRow(new QStandardItem(QStringLiteral("r%1").arg(r))); + return src; +} + +} // namespace + +// appendColumn grows the extra-column count (and therefore columnCount). +TEST(KExtraColumnsProxyModel, AppendColumnGrowsColumnCount) +{ + QStandardItemModel src; + src.appendRow(new QStandardItem); // ensure source has 1 column + TestExtraColumnsModel proxy; + proxy.setSourceModel(&src); + EXPECT_EQ(proxy.columnCount(), 1); // source has 1 column, no extras yet + + proxy.appendColumn(QStringLiteral("extra1")); + proxy.appendColumn(QStringLiteral("extra2")); + EXPECT_EQ(proxy.columnCount(), 1 + 2); +} + +// setExtraColumnTitle / removeExtraColumn mutate the extra headers. +TEST(KExtraColumnsProxyModel, SetExtraColumnTitleAndRemove) +{ + QStandardItemModel src; + src.appendRow(new QStandardItem); // ensure source has 1 column + TestExtraColumnsModel proxy; + proxy.setSourceModel(&src); + proxy.appendColumn(QStringLiteral("extra1")); + proxy.appendColumn(QStringLiteral("extra2")); + + proxy.setExtraColumnTitle(0, QStringLiteral("renamed")); + EXPECT_EQ(proxy.headerData(1, Qt::Horizontal, Qt::DisplayRole).toString(), + QStringLiteral("renamed")); + + proxy.removeExtraColumn(0); + EXPECT_EQ(proxy.columnCount(), 1 + 1); + // The remaining extra column (previously "extra2") is now extra column 0. + EXPECT_EQ(proxy.headerData(1, Qt::Horizontal, Qt::DisplayRole).toString(), + QStringLiteral("extra2")); +} + +// data() on an extra column routes to extraColumnData; on a source column routes +// to the source model. Covers both branches of KExtraColumnsProxyModel::data. +TEST(KExtraColumnsProxyModel, DataRouting) +{ + QStandardItemModel src; + src.appendRow(new QStandardItem(QStringLiteral("sourceData"))); + TestExtraColumnsModel proxy; + proxy.setSourceModel(&src); + proxy.appendColumn(QStringLiteral("extra1")); + + // Source column 0 -> source data (extraCol < 0 branch). + EXPECT_EQ(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), + QStringLiteral("sourceData")); + + // Extra column 1 -> extraColumnData (extraCol >= 0 && headers non-empty). + EXPECT_EQ(proxy.data(proxy.index(0, 1), Qt::DisplayRole), QVariant()); + // Non-DisplayRole on extra column -> our override returns invalid. + EXPECT_FALSE(proxy.data(proxy.index(0, 1), Qt::EditRole).isValid()); +} + +// setData() on an extra column routes to setExtraColumnData (returns true and +// stores); on a source column routes to the source model. Covers both branches +// of KExtraColumnsProxyModel::setData and exercises setExtraColumnData. +TEST(KExtraColumnsProxyModel, SetDataRouting) +{ + QStandardItemModel src; + src.appendRow(new QStandardItem(QStringLiteral("src"))); + TestExtraColumnsModel proxy; + proxy.setSourceModel(&src); + proxy.appendColumn(QStringLiteral("extra1")); + + // Extra column -> setExtraColumnData (true) + storage + extraColumnDataChanged. + QSignalSpy changedSpy(&proxy, &QAbstractItemModel::dataChanged); + ASSERT_TRUE(changedSpy.isValid()); + EXPECT_TRUE(proxy.setData(proxy.index(0, 1), QStringLiteral("edited"))); + EXPECT_EQ(proxy.extraValue(0).toString(), QStringLiteral("edited")); + ASSERT_EQ(changedSpy.count(), 1); + + // Source column -> source model setData. + EXPECT_TRUE(proxy.setData(proxy.index(0, 0), QStringLiteral("srcEdited"))); + EXPECT_EQ(src.data(src.index(0, 0), Qt::DisplayRole).toString(), + QStringLiteral("srcEdited")); +} + +// flags(): extra columns are read-only Selectable|Enabled; source columns carry +// the source flags; with no source model, flags returns NoItemFlags. +TEST(KExtraColumnsProxyModel, FlagsRouting) +{ + QStandardItemModel src; + src.appendRow(new QStandardItem(QStringLiteral("src"))); + TestExtraColumnsModel proxy; + proxy.setSourceModel(&src); + proxy.appendColumn(QStringLiteral("extra1")); + + // Extra column -> readonly flags (extraCol >= 0 branch). + EXPECT_EQ(proxy.flags(proxy.index(0, 1)), + Qt::ItemIsSelectable | Qt::ItemIsEnabled); + + // Source column -> source flags (extraCol < 0 branch). + EXPECT_EQ(proxy.flags(proxy.index(0, 0)), src.flags(src.index(0, 0))); + + // No source model -> NoItemFlags (the `sourceModel() != nullptr` ternary + // false branch in flags()). + TestExtraColumnsModel noSourceProxy; + EXPECT_EQ(noSourceProxy.flags(QModelIndex()), Qt::NoItemFlags); +} + +// hasChildren(): column > 0 always false; column 0 defers to the source/base. +TEST(KExtraColumnsProxyModel, HasChildrenRouting) +{ + QStandardItemModel src; + src.appendRow(new QStandardItem(QStringLiteral("src"))); + TestExtraColumnsModel proxy; + proxy.setSourceModel(&src); + proxy.appendColumn(QStringLiteral("extra1")); + + // column > 0 -> false branch. + EXPECT_FALSE(proxy.hasChildren(proxy.index(0, 1))); + // root, column 0 -> base/source hasChildren. + EXPECT_TRUE(proxy.hasChildren(QModelIndex())); +} + +// headerData(): Horizontal+extra+DisplayRole -> extra header text; +// Horizontal+extra+non-DisplayRole -> invalid; Horizontal+source -> source; +// Vertical -> defers to source. Covers all branches of headerData. +TEST(KExtraColumnsProxyModel, HeaderDataRouting) +{ + QStandardItemModel src; + src.setHorizontalHeaderItem(0, new QStandardItem(QStringLiteral("srcHeader"))); + src.appendRow(new QStandardItem(QStringLiteral("src"))); + TestExtraColumnsModel proxy; + proxy.setSourceModel(&src); + proxy.appendColumn(QStringLiteral("extra1")); + + // Horizontal, extra column, DisplayRole -> extra header text. + EXPECT_EQ(proxy.headerData(1, Qt::Horizontal, Qt::DisplayRole).toString(), + QStringLiteral("extra1")); + // Horizontal, extra column, non-DisplayRole -> invalid. + EXPECT_FALSE(proxy.headerData(1, Qt::Horizontal, Qt::EditRole).isValid()); + // Horizontal, source column -> source headerData. + EXPECT_EQ(proxy.headerData(0, Qt::Horizontal, Qt::DisplayRole).toString(), + QStringLiteral("srcHeader")); + // Vertical -> defers to source headerData (orientation != Horizontal branch). + // QStandardItemModel's default vertical header for row 0 is "1"; just assert + // the proxy forwards whatever the source reports. + EXPECT_EQ(proxy.headerData(0, Qt::Vertical, Qt::DisplayRole), + src.headerData(0, Qt::Vertical, Qt::DisplayRole)); +} + +// mapToSource(): invalid proxy index -> invalid; extra column -> invalid; +// source column -> mapped to source. Covers all branches of mapToSource. +TEST(KExtraColumnsProxyModel, MapToSourceRouting) +{ + QStandardItemModel src; + src.appendRow(new QStandardItem(QStringLiteral("src"))); + TestExtraColumnsModel proxy; + proxy.setSourceModel(&src); + proxy.appendColumn(QStringLiteral("extra1")); + + // Invalid proxy index -> invalid (the `!proxyIndex.isValid()` branch). + EXPECT_FALSE(proxy.mapToSource(QModelIndex()).isValid()); + + // Extra column -> invalid (the `column >= columnCount` branch). + EXPECT_FALSE(proxy.mapToSource(proxy.index(0, 1)).isValid()); + + // Source column -> mapped to source. + QModelIndex mapped = proxy.mapToSource(proxy.index(0, 0)); + ASSERT_TRUE(mapped.isValid()); + EXPECT_EQ(mapped.row(), 0); + EXPECT_EQ(mapped.column(), 0); +} + +// buddy(): extra column returns the proxy index unchanged; source column defers +// to the base implementation. Covers both branches of buddy. +TEST(KExtraColumnsProxyModel, BuddyRouting) +{ + QStandardItemModel src; + src.appendRow(new QStandardItem(QStringLiteral("src"))); + TestExtraColumnsModel proxy; + proxy.setSourceModel(&src); + proxy.appendColumn(QStringLiteral("extra1")); + + QModelIndex extraIdx = proxy.index(0, 1); + EXPECT_EQ(proxy.buddy(extraIdx), extraIdx); // extra column branch + + QModelIndex srcIdx = proxy.index(0, 0); + EXPECT_EQ(proxy.buddy(srcIdx), srcIdx); // source column: buddy is self +} + +// sibling(): identical row+column returns the index itself; different returns a +// freshly created index. Covers both branches of sibling. +TEST(KExtraColumnsProxyModel, SiblingRouting) +{ + QStandardItemModel src; + src.appendRow(new QStandardItem(QStringLiteral("src"))); + TestExtraColumnsModel proxy; + proxy.setSourceModel(&src); + proxy.appendColumn(QStringLiteral("extra1")); + + QModelIndex idx = proxy.index(0, 1); + // Same row/column -> returns idx (the `row==idx.row() && column==idx.column()` branch). + EXPECT_EQ(proxy.sibling(0, 1, idx), idx); + // Different column -> newly created index (the else branch). + QModelIndex sib = proxy.sibling(0, 0, idx); + ASSERT_TRUE(sib.isValid()); + EXPECT_EQ(sib.row(), 0); + EXPECT_EQ(sib.column(), 0); +} + +// mapSelectionToSource(): with a source model, a selection spanning the extra +// columns is truncated to the source column range; without a source model an +// empty selection is returned. Covers both branches. +TEST(KExtraColumnsProxyModel, MapSelectionToSourceRouting) +{ + QStandardItemModel src; + src.appendRow(new QStandardItem(QStringLiteral("src"))); + TestExtraColumnsModel proxy; + proxy.setSourceModel(&src); + proxy.appendColumn(QStringLiteral("extra1")); + proxy.appendColumn(QStringLiteral("extra2")); + + // Selection spanning source col 0 through extra col 2 (proxy cols 0..2). + QItemSelection sel(proxy.index(0, 0), proxy.index(0, 2)); + QItemSelection mapped = proxy.mapSelectionToSource(sel); + ASSERT_EQ(mapped.size(), 1); + // bottomRight column (2) >= sourceColumnCount (1) -> truncated to col 0. + EXPECT_EQ(mapped.at(0).bottomRight().column(), 0); + EXPECT_EQ(mapped.at(0).topLeft().column(), 0); + + // No source model -> empty selection (the `!sourceModel()` branch). + TestExtraColumnsModel noSourceProxy; + QItemSelection emptySel; + EXPECT_TRUE(noSourceProxy.mapSelectionToSource(emptySel).isEmpty()); +} + +// extraColumnForProxyColumn / proxyColumnForExtraColumn: the mapping helpers +// behave with and without a source model. +TEST(KExtraColumnsProxyModel, ColumnMappingHelpers) +{ + QStandardItemModel src; + src.appendRow(new QStandardItem(QStringLiteral("src"))); + TestExtraColumnsModel proxy; + proxy.setSourceModel(&src); + proxy.appendColumn(QStringLiteral("extra1")); + + // With a source model: proxy column below source count -> -1. + EXPECT_EQ(proxy.extraColumnForProxyColumn(0), -1); + // proxy column at/above source count -> extra column index. + EXPECT_EQ(proxy.extraColumnForProxyColumn(1), 0); + EXPECT_EQ(proxy.proxyColumnForExtraColumn(0), 1); + + // Without a source model: always -1. + TestExtraColumnsModel noSourceProxy; + EXPECT_EQ(noSourceProxy.extraColumnForProxyColumn(0), -1); + EXPECT_EQ(noSourceProxy.extraColumnForProxyColumn(5), -1); +} + +// index()/parent(): extra-column indexes are created with the source column-0 +// internal pointer; parent() of an extra-column child resolves via a column-0 +// sibling. Covers the extra-column branches of index() and parent(). +TEST(KExtraColumnsProxyModel, IndexAndParentForExtraColumns) +{ + QStandardItemModel src; + QStandardItem *parent = new QStandardItem(QStringLiteral("parent")); + src.appendRow(parent); + // Give the parent a child row so a hierarchical index exists. + QStandardItem *child = new QStandardItem(QStringLiteral("child")); + parent->setChild(0, child); + TestExtraColumnsModel proxy; + proxy.setSourceModel(&src); + proxy.appendColumn(QStringLiteral("extra1")); + + // Top-level extra-column index is valid (extra-col >= 0 branch of index()). + QModelIndex extraTop = proxy.index(0, 1); + EXPECT_TRUE(extraTop.isValid()); + EXPECT_EQ(extraTop.column(), 1); + + // Top-level extra-column index parent is invalid (extra-col branch of parent()). + EXPECT_FALSE(proxy.parent(extraTop).isValid()); + + // Child extra-column index parent is the parent row (column 0). + QModelIndex childExtra = proxy.index(0, 1, proxy.index(0, 0)); + ASSERT_TRUE(childExtra.isValid()); + QModelIndex childParent = proxy.parent(childExtra); + ASSERT_TRUE(childParent.isValid()); + EXPECT_EQ(childParent.row(), 0); + EXPECT_EQ(childParent.column(), 0); +} + +// extraColumnDataChanged() emits dataChanged for the given extra column. +TEST(KExtraColumnsProxyModel, ExtraColumnDataChangedEmits) +{ + QStandardItemModel src; + src.appendRow(new QStandardItem(QStringLiteral("src"))); + TestExtraColumnsModel proxy; + proxy.setSourceModel(&src); + proxy.appendColumn(QStringLiteral("extra1")); + + QSignalSpy spy(&proxy, &QAbstractItemModel::dataChanged); + ASSERT_TRUE(spy.isValid()); + + proxy.extraColumnDataChanged(QModelIndex(), 0, 0, {Qt::DisplayRole}); + ASSERT_EQ(spy.count(), 1); + const auto args = spy.takeFirst(); + EXPECT_EQ(args.at(0).toModelIndex().column(), 1); // proxyColumnForExtraColumn(0) + EXPECT_EQ(args.at(1).toModelIndex().column(), 1); +} + +// setSourceModel twice: replacing the source exercises the disconnect-old / +// connect-new branches of setSourceModel. +TEST(KExtraColumnsProxyModel, ReplaceSourceModel) +{ + QStandardItemModel src1; + QStandardItemModel src2; + TestExtraColumnsModel proxy; + proxy.setSourceModel(&src1); + EXPECT_EQ(proxy.sourceModel(), &src1); + // Replacing triggers the `if (sourceModel())` disconnect branch and the + // `if (model)` connect branch. + proxy.setSourceModel(&src2); + EXPECT_EQ(proxy.sourceModel(), &src2); + // Clearing triggers the disconnect branch again, no connect (model == null). + proxy.setSourceModel(nullptr); + EXPECT_EQ(proxy.sourceModel(), nullptr); +} + +// QAbstractItemModelTester validates model invariants across extra-column +// operations on a flat source model. (Source is heap-allocated and parented to +// the proxy so it is destroyed with the proxy.) +TEST(KExtraColumnsProxyModel, ModelTesterFlatValidation) +{ + TestExtraColumnsModel proxy; + auto *src = makeSourceModel(3, &proxy); + proxy.appendColumn(QStringLiteral("extra1")); + proxy.appendColumn(QStringLiteral("extra2")); + proxy.setSourceModel(src); + + auto tester = std::make_unique( + &proxy, QAbstractItemModelTester::FailureReportingMode::Fatal); + + // Mutations that touch both source and extra columns. + proxy.setData(proxy.index(1, 0), QStringLiteral("edited")); + proxy.setData(proxy.index(1, 1), QStringLiteral("extraEdited")); + src->removeRow(0); + src->appendRow(new QStandardItem(QStringLiteral("r_new"))); + + EXPECT_GT(proxy.rowCount(), 0); +} + +// The _ec_sourceLayoutAboutToBeChanged / _ec_sourceLayoutChanged handlers fire +// when the source emits layoutAboutToBeChanged/layoutChanged. With persistent +// proxy indexes on both a source column and an extra column, the handlers' +// persistent-index loop runs and exercises both the `column < sourceColumnCount` +// and `column >= sourceColumnCount` branches. An invalid source parent hits the +// `!parent.isValid()` branch; a valid source parent hits the mapFromSource branch. +TEST(KExtraColumnsProxyModel, LayoutChangeHandlers) +{ + TestLayoutSourceModel src; + src.appendRow(new QStandardItem(QStringLiteral("r0"))); + src.appendRow(new QStandardItem(QStringLiteral("r1"))); + TestExtraColumnsModel proxy; + proxy.setSourceModel(&src); + proxy.appendColumn(QStringLiteral("extra1")); + + // Persistent proxy indexes: one on a source column, one on an extra column. + QPersistentModelIndex persSource(proxy.index(0, 0)); + QPersistentModelIndex persExtra(proxy.index(0, 1)); + ASSERT_TRUE(persSource.isValid()); + ASSERT_TRUE(persExtra.isValid()); + + // Cycle 1: an invalid source parent -> the `!parent.isValid()` branch. + QList invalidParents{QPersistentModelIndex()}; + src.emitLayoutAboutToBeChanged(invalidParents); + src.emitLayoutChanged(invalidParents); + + // Persistent indexes survive a well-formed (no-op) layout change. + EXPECT_TRUE(persSource.isValid()); + EXPECT_TRUE(persExtra.isValid()); + + // Cycle 2: a valid source parent -> the mapFromSource branch. + QList validParents{QPersistentModelIndex(src.index(0, 0))}; + src.emitLayoutAboutToBeChanged(validParents); + src.emitLayoutChanged(validParents); + + EXPECT_TRUE(persSource.isValid()); + EXPECT_TRUE(persExtra.isValid()); +} + +#include "kextracolumnsproxymodeltests.moc" diff --git a/tests/frame/listtotableproxymodeltests.cpp b/tests/frame/listtotableproxymodeltests.cpp new file mode 100644 index 000000000..469c0a9d4 --- /dev/null +++ b/tests/frame/listtotableproxymodeltests.cpp @@ -0,0 +1,239 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +// Unit tests for ListToTableProxyModel (frame/models/listtotableproxymodel), +// the concrete KExtraColumnsProxyModel subclass that turns a list model's roles +// into table columns. +// +// NOTE: private-member access (m_roles / m_sourceColumn) uses a scoped +// `#define private public` block around the model header below, NOT a global +// -D compile definition (the latter breaks gtest/libstdc++). In production +// `roles` is only ever set from QML (TrayContainer.qml), and QList has +// no Q_DECLARE_METATYPE in this repo, so a C++ setProperty("roles", ...) is +// unreliable; direct member access is the robust white-box alternative and +// avoids the m_roles[extraColumn] out-of-bounds UB when m_roles is empty. +// If upstream ListToTableProxyModel::m_roles changes (type/semantics), the +// direct-write sites here must be updated in sync. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Scoped visibility: expose private members of ListToTableProxyModel ONLY +// while including its header, then restore access. This avoids the global +// -Dprivate=public that breaks gtest/libstdc++ internal headers. +#define private public +#define protected public +#include "listtotableproxymodel.h" +#undef private +#undef protected + +namespace { +constexpr int kNameRole = Qt::UserRole + 1; +constexpr int kValueRole = Qt::UserRole + 2; +constexpr int kListRole = Qt::UserRole + 3; +} // namespace + +// Helper: create a 1-column source model with the given role names, no rows. +QStandardItemModel *makeEmptySourceWithRoles(QObject *parent) +{ + auto *src = new QStandardItemModel(parent); + src->setItemRoleNames({{kNameRole, "name"}, + {kValueRole, "value"}, + {kListRole, "list"}}); + return src; +} + +// Setting m_roles and emitting rolesChanged drives the appendColumn() lambda: +// one extra column is appended per role, named after the source role name. +TEST(ListToTableProxyModel, RolesChangedAppendsColumns) +{ + QStandardItemModel src; + src.setItemRoleNames({{kNameRole, "name"}, {kValueRole, "value"}}); + src.appendRow(new QStandardItem); + + ListToTableProxyModel proxy; + proxy.setSourceModel(&src); + + EXPECT_EQ(proxy.columnCount(), 1); // only the source column, no extras yet + + // White-box: populate m_roles and fire rolesChanged (the production path is + // QML setting the `roles` property, which triggers the same signal). + proxy.m_roles = QList{kNameRole, kValueRole}; + proxy.rolesChanged(proxy.m_roles); + + // Two extra columns appended -> columnCount = 1 (source) + 2. + EXPECT_EQ(proxy.columnCount(), 1 + 2); + // Extra columns are titled after the source role names. + EXPECT_EQ(proxy.headerData(1, Qt::Horizontal, Qt::DisplayRole).toString(), + QStringLiteral("name")); + EXPECT_EQ(proxy.headerData(2, Qt::Horizontal, Qt::DisplayRole).toString(), + QStringLiteral("value")); +} + +// extraColumnData returns the source value at (row, sourceColumn) for the +// role stored in m_roles[extraColumn] — the normal "valid value" return path. +TEST(ListToTableProxyModel, ExtraColumnDataReturnsSourceValue) +{ + QStandardItemModel src; + src.setItemRoleNames({{kNameRole, "name"}, {kValueRole, "value"}}); + QStandardItem *item = new QStandardItem; + item->setData(QStringLiteral("n0"), kNameRole); + item->setData(QStringLiteral("v0"), kValueRole); + src.appendRow(item); + + ListToTableProxyModel proxy; + proxy.setSourceModel(&src); + proxy.m_roles = QList{kNameRole, kValueRole}; + proxy.rolesChanged(proxy.m_roles); + + // Extra column 0 (proxy col 1) -> m_roles[0] = name role -> "n0". + EXPECT_EQ(proxy.data(proxy.index(0, 1), Qt::DisplayRole).toString(), + QStringLiteral("n0")); + // Extra column 1 (proxy col 2) -> m_roles[1] = value role -> "v0". + EXPECT_EQ(proxy.data(proxy.index(0, 2), Qt::DisplayRole).toString(), + QStringLiteral("v0")); +} + +// extraColumnData returns the "" placeholder when the source has no +// data for the requested role (the `!result.isValid()` branch). +TEST(ListToTableProxyModel, ExtraColumnDataInvalidReturnsPlaceholder) +{ + QStandardItemModel src; + src.setItemRoleNames({{kNameRole, "name"}, {kValueRole, "value"}}); + QStandardItem *item = new QStandardItem; + item->setData(QStringLiteral("n0"), kNameRole); + // NOTE: kValueRole is intentionally NOT set -> data() returns an invalid + // QVariant, exercising the "" placeholder return. + src.appendRow(item); + + ListToTableProxyModel proxy; + proxy.setSourceModel(&src); + proxy.m_roles = QList{kNameRole, kValueRole}; + proxy.rolesChanged(proxy.m_roles); + + EXPECT_EQ(proxy.data(proxy.index(0, 2), Qt::DisplayRole).toString(), + QStringLiteral("")); +} + +// extraColumnData joins a QVariantList result with ',' (the +// `result.userType() == QMetaType::QVariantList` branch). NOTE: must store a +// QVariantList (userType==9), not a QStringList (userType==11) — the latter +// does NOT match the branch and returns an empty string. +TEST(ListToTableProxyModel, ExtraColumnDataVariantListJoined) +{ + QStandardItemModel src; + src.setItemRoleNames({{kListRole, "list"}}); + QStandardItem *item = new QStandardItem; + item->setData(QVariantList{QStringLiteral("a"), QStringLiteral("b")}, kListRole); + src.appendRow(item); + + ListToTableProxyModel proxy; + proxy.setSourceModel(&src); + proxy.m_roles = QList{kListRole}; + proxy.rolesChanged(proxy.m_roles); + + EXPECT_EQ(proxy.data(proxy.index(0, 1), Qt::DisplayRole).toString(), + QStringLiteral("a,b")); +} + +// data() on a source column routes to the source model (extraCol < 0 branch). +TEST(ListToTableProxyModel, SourceColumnDataRoutesToSource) +{ + QStandardItemModel src; + src.setItemRoleNames({{kNameRole, "name"}}); + QStandardItem *item = new QStandardItem(QStringLiteral("displayText")); + item->setData(QStringLiteral("n0"), kNameRole); + src.appendRow(item); + + ListToTableProxyModel proxy; + proxy.setSourceModel(&src); + proxy.m_roles = QList{kNameRole}; + proxy.rolesChanged(proxy.m_roles); + + // Source column 0 -> source DisplayRole data. + EXPECT_EQ(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), + QStringLiteral("displayText")); +} + +// sourceModelChanged re-titles the extra columns from the new source model's +// role names (the sourceModelChanged lambda / setExtraColumnTitle path). +TEST(ListToTableProxyModel, SourceModelChangedRetitlesColumns) +{ + // Declare the source first so the proxy (declared later) is destroyed + // first on reverse stack destruction while the source is still alive. + QStandardItemModel src; + src.setItemRoleNames({{kNameRole, "name"}}); + src.appendRow(new QStandardItem); + + // Set roles BEFORE plugging a source model: appendColumn() then uses the + // numeric fallback (no source roleNames available). + ListToTableProxyModel proxy; + proxy.m_roles = QList{kNameRole}; + proxy.rolesChanged(proxy.m_roles); + + // Plugging the source fires sourceModelChanged, which re-titles the extra + // column from the source's role name. + proxy.setSourceModel(&src); + EXPECT_EQ(proxy.headerData(1, Qt::Horizontal, Qt::DisplayRole).toString(), + QStringLiteral("name")); +} + +// Changing source data makes the proxy emit dataChanged, which ListToTable +// turns into a model reset (the dataChanged lambda: beginResetModel/endResetModel). +TEST(ListToTableProxyModel, DataChangedTriggersReset) +{ + QStandardItemModel src; + src.setItemRoleNames({{kNameRole, "name"}}); + QStandardItem *item = new QStandardItem; + item->setData(QStringLiteral("n0"), kNameRole); + src.appendRow(item); + + ListToTableProxyModel proxy; + proxy.setSourceModel(&src); + proxy.m_roles = QList{kNameRole}; + proxy.rolesChanged(proxy.m_roles); + + QSignalSpy aboutResetSpy(&proxy, &QAbstractItemModel::modelAboutToBeReset); + QSignalSpy resetSpy(&proxy, &QAbstractItemModel::modelReset); + ASSERT_TRUE(aboutResetSpy.isValid()); + ASSERT_TRUE(resetSpy.isValid()); + + // Source data change -> proxy dataChanged (forwarded) -> reset lambda. + src.setData(src.index(0, 0), QStringLiteral("n0_edited"), kNameRole); + + EXPECT_GE(aboutResetSpy.count(), 1); + EXPECT_GE(resetSpy.count(), 1); + // The edited value is still readable through the extra column after reset. + EXPECT_EQ(proxy.data(proxy.index(0, 1), Qt::DisplayRole).toString(), + QStringLiteral("n0_edited")); +} + +// sourceColumn selects which source column extraColumnData reads from. +TEST(ListToTableProxyModel, SourceColumnSelectsReadColumn) +{ + QStandardItemModel src; + src.setItemRoleNames({{kNameRole, "name"}}); + // Two explicit items so both source columns exist and are settable. + src.appendRow({new QStandardItem, new QStandardItem}); + src.setData(src.index(0, 0), QStringLiteral("col0val"), kNameRole); + src.setData(src.index(0, 1), QStringLiteral("col1val"), kNameRole); + + ListToTableProxyModel proxy; + proxy.setSourceModel(&src); + proxy.m_roles = QList{kNameRole}; + proxy.m_sourceColumn = 1; // read from source column 1 + proxy.rolesChanged(proxy.m_roles); + + EXPECT_EQ(proxy.data(proxy.index(0, 2), Qt::DisplayRole).toString(), + QStringLiteral("col1val")); +} diff --git a/tests/frame/pluginfactorytests.cpp b/tests/frame/pluginfactorytests.cpp new file mode 100644 index 000000000..d9d462953 --- /dev/null +++ b/tests/frame/pluginfactorytests.cpp @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +// Unit tests for DAppletFactory (frame/pluginfactory), the plugin registration +// helper. registerInstance()/create() key off metaObject()->className() into a +// file-scope static QMap, so each test uses a uniquely-named Q_OBJECT factory +// subclass to avoid cross-test collision in that global map. The registered +// CreateAppletFunction returns nullptr (plus a side-effect flag) so no DApplet +// needs to be instantiated and applet.cpp is not pulled in. + +#include + +#include +#include + +#include "pluginfactory.h" + +using namespace ds; + +// --- Unique Q_OBJECT factory subclasses (one per test that registers) --------- +class InvokeFactory : public DAppletFactory +{ + Q_OBJECT +public: + explicit InvokeFactory(QObject *parent = nullptr) : DAppletFactory(parent) {} +}; + +class DuplicateFactory : public DAppletFactory +{ + Q_OBJECT +public: + explicit DuplicateFactory(QObject *parent = nullptr) : DAppletFactory(parent) {} +}; + +// registerInstance() inserts the function under the factory's className, and a +// subsequent create() invokes it. Covers the insert branch of registerInstance +// and the "found -> invoke" branch of create. +TEST(DAppletFactory, RegisterAndCreateInvokesFunction) +{ + InvokeFactory factory; + + bool invoked = false; + QObject *capturedParent = reinterpret_cast(0xDEADBEEF); // sentinel + factory.registerInstance([&invoked, &capturedParent](QObject *parent) -> DApplet * { + invoked = true; + capturedParent = parent; + return nullptr; // no real DApplet needed to exercise the dispatch + }); + + // create() should look up the registered function and call it. + QObject *sentinelParent = reinterpret_cast(0xCAFEBABE); + DApplet *result = factory.create(sentinelParent); + + EXPECT_EQ(result, nullptr); // our function returns nullptr + EXPECT_TRUE(invoked); // the function was actually dispatched + EXPECT_EQ(capturedParent, sentinelParent); // parent forwarded +} + +// create() on a factory that never registered returns nullptr. Covers the +// "not found -> nullptr" branch of create. Uses the base DAppletFactory (whose +// className "ds::DAppletFactory" is never registered by any test here). +TEST(DAppletFactory, CreateUnregisteredReturnsNull) +{ + DAppletFactory factory; + EXPECT_EQ(factory.create(), nullptr); +} + +// A second registerInstance() with the same className is ignored (the +// "already registered" branch); create() keeps using the first function. +TEST(DAppletFactory, DuplicateRegistrationIsIgnored) +{ + DuplicateFactory factory; + + bool firstCalled = false; + bool secondCalled = false; + + factory.registerInstance([&firstCalled](QObject *) -> DApplet * { + firstCalled = true; + return nullptr; + }); + // Second registration under the same className must be a no-op. + factory.registerInstance([&secondCalled](QObject *) -> DApplet * { + secondCalled = true; + return nullptr; + }); + + EXPECT_EQ(factory.create(), nullptr); + EXPECT_TRUE(firstCalled); // the first function is the one dispatched + EXPECT_FALSE(secondCalled); // the duplicate was ignored +} + +// registerApplet is a stateless template helper (new T(parent)); it is not +// exercised with a real DApplet here because that would pull applet.cpp into +// the build. The three tests above cover all branches of registerInstance() +// (insert / duplicate-ignore) and create() (found-invoke / not-found-null). + +#include "pluginfactorytests.moc" diff --git a/tests/frame/pluginmetadatatests.cpp b/tests/frame/pluginmetadatatests.cpp new file mode 100644 index 000000000..4a297710c --- /dev/null +++ b/tests/frame/pluginmetadatatests.cpp @@ -0,0 +1,225 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "pluginmetadata.h" + +using namespace ds; + +// Default-constructed metadata is invalid and reports empty identifiers. +TEST(DPluginMetaData, DefaultIsInvalid) +{ + DPluginMetaData meta; + EXPECT_FALSE(meta.isValid()); + EXPECT_TRUE(meta.pluginId().isEmpty()); + EXPECT_TRUE(meta.pluginDir().isEmpty()); + EXPECT_TRUE(meta.url().isEmpty()); +} + +// fromJsonString with a well-formed Plugin.Id populates the metadata. +TEST(DPluginMetaData, FromJsonStringValid) +{ + const QByteArray json = R"({"Plugin":{"Id":"org.deepin.ds.test","Url":"main.qml"}})"; + auto meta = DPluginMetaData::fromJsonString(json); + ASSERT_TRUE(meta.isValid()); + EXPECT_EQ(meta.pluginId(), QStringLiteral("org.deepin.ds.test")); +} + +// fromJsonString without an Id yields an invalid metadata. +TEST(DPluginMetaData, FromJsonStringMissingId) +{ + const QByteArray json = R"({"Plugin":{"Url":"main.qml"}})"; + auto meta = DPluginMetaData::fromJsonString(json); + EXPECT_FALSE(meta.isValid()); + EXPECT_TRUE(meta.pluginId().isEmpty()); +} + +// fromJsonString with malformed JSON returns invalid metadata and does not throw. +TEST(DPluginMetaData, FromJsonStringMalformed) +{ + const QByteArray json = R"(not a json {{{)"; + EXPECT_NO_THROW({ + auto meta = DPluginMetaData::fromJsonString(json); + EXPECT_FALSE(meta.isValid()); + EXPECT_TRUE(meta.pluginId().isEmpty()); + }); +} + +// value() returns stored fields and falls back to the provided default. +TEST(DPluginMetaData, ValueAndDefault) +{ + const QByteArray json = R"({"Plugin":{"Id":"org.test.foo","Url":"bar.qml","Version":"1.2"}})"; + auto meta = DPluginMetaData::fromJsonString(json); + ASSERT_TRUE(meta.isValid()); + EXPECT_EQ(meta.value("Url").toString(), QStringLiteral("bar.qml")); + EXPECT_EQ(meta.value("Version").toString(), QStringLiteral("1.2")); + EXPECT_EQ(meta.value("Missing", QStringLiteral("fallback")).toString(), + QStringLiteral("fallback")); +} + +// value() on invalid metadata always returns the default (short-circuit). +TEST(DPluginMetaData, ValueOnInvalidReturnsDefault) +{ + DPluginMetaData meta; + EXPECT_EQ(meta.value("Any", 42).toInt(), 42); + EXPECT_EQ(meta.value("Any").toString(), QString()); +} + +// rootPluginMetaData is a stable singleton identifying the root plugin. +TEST(DPluginMetaData, RootPluginMetaData) +{ + auto root = DPluginMetaData::rootPluginMetaData(); + ASSERT_TRUE(root.isValid()); + EXPECT_EQ(root.pluginId(), QStringLiteral("org.deepin.ds.root")); +} + +// isRootPlugin only matches the canonical root plugin id. +TEST(DPluginMetaData, IsRootPlugin) +{ + EXPECT_TRUE(DPluginMetaData::isRootPlugin(QStringLiteral("org.deepin.ds.root"))); + EXPECT_FALSE(DPluginMetaData::isRootPlugin(QStringLiteral("org.deepin.ds.other"))); +} + +// url() resolves Url against the plugin directory; absent Url -> empty. +TEST(DPluginMetaData, UrlResolvesAgainstPluginDir) +{ + QTemporaryDir dir(QDir::tempPath() + "/ddestest-XXXXXX"); + ASSERT_TRUE(dir.isValid()); + const QString filePath = dir.path() + "/plugin.json"; + const QString urlRel = QStringLiteral("main.qml"); + { + QFile f(filePath); + ASSERT_TRUE(f.open(QIODevice::WriteOnly)); + f.write(R"({"Plugin":{"Id":"org.test.url","Url":")" + urlRel.toUtf8() + R"("}})"); + f.close(); + } + + auto meta = DPluginMetaData::fromJsonFile(filePath); + ASSERT_TRUE(meta.isValid()); + EXPECT_EQ(meta.pluginId(), QStringLiteral("org.test.url")); + EXPECT_EQ(meta.pluginDir(), QFileInfo(filePath).absoluteDir().path()); + EXPECT_EQ(meta.url(), QDir(meta.pluginDir()).absoluteFilePath(urlRel)); +} + +// url() is empty when the Url field is absent even on otherwise valid metadata. +TEST(DPluginMetaData, UrlEmptyWhenAbsent) +{ + const QByteArray json = R"({"Plugin":{"Id":"org.test.nourl"}})"; + auto meta = DPluginMetaData::fromJsonString(json); + ASSERT_TRUE(meta.isValid()); + EXPECT_TRUE(meta.url().isEmpty()); +} + +// fromJsonFile with a missing path returns invalid metadata (logs a warning). +TEST(DPluginMetaData, FromJsonFileMissing) +{ + auto meta = DPluginMetaData::fromJsonFile(QStringLiteral("/nonexistent/path/to/plugin.json")); + EXPECT_FALSE(meta.isValid()); + EXPECT_TRUE(meta.pluginId().isEmpty()); + EXPECT_TRUE(meta.pluginDir().isEmpty()); +} + +// fromJsonFile with a file whose content has no Plugin.Id: open() succeeds but +// fromJsonString() returns invalid metadata, so the `if (!result.isValid())` +// early-return branch in fromJsonFile is taken (no pluginDir is set). +TEST(DPluginMetaData, FromJsonFileInvalidContent) +{ + QTemporaryFile tmp; + ASSERT_TRUE(tmp.open()); + // Valid JSON object but the Plugin object carries no Id -> fromJsonString + // yields invalid metadata, exercising fromJsonFile's invalid-result branch. + tmp.write(R"({"Plugin":{"Url":"main.qml"}})"); + tmp.close(); + + auto meta = DPluginMetaData::fromJsonFile(tmp.fileName()); + EXPECT_FALSE(meta.isValid()); + EXPECT_TRUE(meta.pluginId().isEmpty()); + // pluginDir is only assigned after the validity check passes, so it stays + // empty when the invalid-result branch is taken. + EXPECT_TRUE(meta.pluginDir().isEmpty()); +} + +// fromJsonFile with a real file behaves like fromJsonString plus pluginDir. +TEST(DPluginMetaData, FromJsonFileRoundTrip) +{ + QTemporaryFile tmp; + ASSERT_TRUE(tmp.open()); + tmp.write(R"({"Plugin":{"Id":"org.test.file"}})"); + tmp.close(); + + auto meta = DPluginMetaData::fromJsonFile(tmp.fileName()); + ASSERT_TRUE(meta.isValid()); + EXPECT_EQ(meta.pluginId(), QStringLiteral("org.test.file")); + EXPECT_FALSE(meta.pluginDir().isEmpty()); +} + +// Copy construction shares the underlying implicitly-shared data. +TEST(DPluginMetaData, CopyConstructor) +{ + auto meta = DPluginMetaData::fromJsonString(R"({"Plugin":{"Id":"org.test.copy"}})"); + ASSERT_TRUE(meta.isValid()); + DPluginMetaData copy(meta); + EXPECT_TRUE(copy.isValid()); + EXPECT_EQ(copy.pluginId(), meta.pluginId()); +} + +// Copy assignment shares the underlying implicitly-shared data. +TEST(DPluginMetaData, CopyAssignment) +{ + auto meta = DPluginMetaData::fromJsonString(R"({"Plugin":{"Id":"org.test.assign"}})"); + DPluginMetaData other; + other = meta; + EXPECT_TRUE(other.isValid()); + EXPECT_EQ(other.pluginId(), meta.pluginId()); +} + +// Move construction transfers ownership of the shared data. +TEST(DPluginMetaData, MoveConstructor) +{ + auto meta = DPluginMetaData::fromJsonString(R"({"Plugin":{"Id":"org.test.move"}})"); + ASSERT_TRUE(meta.isValid()); + DPluginMetaData moved(std::move(meta)); + EXPECT_TRUE(moved.isValid()); + EXPECT_EQ(moved.pluginId(), QStringLiteral("org.test.move")); +} + +// Move assignment swaps the underlying shared data pointer. +TEST(DPluginMetaData, MoveAssignment) +{ + auto meta = DPluginMetaData::fromJsonString(R"({"Plugin":{"Id":"org.test.moveassign"}})"); + DPluginMetaData other; + other = std::move(meta); + EXPECT_TRUE(other.isValid()); + EXPECT_EQ(other.pluginId(), QStringLiteral("org.test.moveassign")); +} + +// operator== compares pluginId only (other fields are irrelevant). +TEST(DPluginMetaData, EqualityByPluginId) +{ + auto a = DPluginMetaData::fromJsonString(R"({"Plugin":{"Id":"org.test.eq"}})"); + auto b = DPluginMetaData::fromJsonString(R"({"Plugin":{"Id":"org.test.eq","Url":"x.qml"}})"); + auto c = DPluginMetaData::fromJsonString(R"({"Plugin":{"Id":"org.test.neq"}})"); + EXPECT_TRUE(a == b); + EXPECT_FALSE(a == c); +} + +// Destructor must not crash for valid, invalid, moved-from instances. +TEST(DPluginMetaData, DestructorSafety) +{ + EXPECT_NO_THROW({ + DPluginMetaData invalid; + DPluginMetaData valid = DPluginMetaData::fromJsonString(R"({"Plugin":{"Id":"org.test.dtor"}})"); + DPluginMetaData moved = std::move(valid); + // invalid, valid (moved-from), moved all go out of scope here. + }); +} From d6538e2428a714fc8e42144f8ff54444112e2561 Mon Sep 17 00:00:00 2001 From: zhaofangxun Date: Wed, 19 Aug 2026 17:20:42 +0800 Subject: [PATCH 2/2] test(frame): add GTest for 4 more core classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add 26 GTest unit tests for 4 more frame/ core classes 2. Cover dstypes, applet, appletproxy, dsutility (Batch N+1) 3. Extend OBJECT lib with 4 sources + link Dtk6::Core and Qt6::Gui 4. Add 4 test executables types/applet/appletproxy/utility tests 5. Force-include qguiapplication.h for dsutility.cpp workaround 6. Brings frame/ coverage to 100 tests across 10 core classes Influence: 1. Run ctest -R '^frame_' to execute all 100 unit tests 2. Build requires DTK6 Core + Qt6 Gui (dsutility uses QGuiApplication) 3. utility_tests runs offscreen QGuiApplication via global environment test(frame): 为4个核心类新增GTest单元测试 1. 为 4 个 frame/ 核心类新增 26 个 GTest 单元测试 2. 覆盖 dstypes、applet、appletproxy、dsutility(Batch N+1) 3. OBJECT 库追加 4 源并链接 Dtk6::Core 与 Qt6::Gui 4. 新增 4 个测试可执行 types/applet/appletproxy/utility tests 5. 为 dsutility.cpp 强制包含 qguiapplication.h 规避编译 6. frame/ 覆盖增至 100 测试,覆盖 10 个核心类 Influence: 1. 通过 ctest -R '^frame_' 运行全部 100 个单元测试 2. 构建需 DTK6 Core 与 Qt6 Gui(dsutility 使用 QGuiApplication) 3. utility_tests 经全局测试环境运行 offscreen QGuiApplication --- tests/frame/CMakeLists.txt | 63 ++++++++++++++ tests/frame/appletproxytests.cpp | 112 ++++++++++++++++++++++++ tests/frame/applettests.cpp | 145 +++++++++++++++++++++++++++++++ tests/frame/typestests.cpp | 40 +++++++++ tests/frame/utilitytests.cpp | 121 ++++++++++++++++++++++++++ 5 files changed, 481 insertions(+) create mode 100644 tests/frame/appletproxytests.cpp create mode 100644 tests/frame/applettests.cpp create mode 100644 tests/frame/typestests.cpp create mode 100644 tests/frame/utilitytests.cpp diff --git a/tests/frame/CMakeLists.txt b/tests/frame/CMakeLists.txt index 730606052..df472abc0 100644 --- a/tests/frame/CMakeLists.txt +++ b/tests/frame/CMakeLists.txt @@ -38,7 +38,10 @@ option(FRAME_BUILD_COVERAGE "Enable gcov coverage instrumentation for dde-shell # ---- OBJECT library: all frame/ sources under test, compiled once ------------ # DS_LIB mirrors frame/CMakeLists.txt so the DS_SHARE export macro is correct # while compiling these sources into the test objects. +# Sources are grouped by batch. BUILD_WITH_X11 is intentionally NOT defined +# for this OBJECT lib so that dsutility.cpp uses the base (non-X11) Utility class. add_library(frame_test_objects OBJECT + # Batch 1 (PR #1703): pluginmetadata, appletdata, appletitemmodel, models, pluginfactory ${CMAKE_SOURCE_DIR}/frame/pluginmetadata.h ${CMAKE_SOURCE_DIR}/frame/pluginmetadata.cpp ${CMAKE_SOURCE_DIR}/frame/appletdata.h @@ -51,17 +54,71 @@ add_library(frame_test_objects OBJECT ${CMAKE_SOURCE_DIR}/frame/models/listtotableproxymodel.cpp ${CMAKE_SOURCE_DIR}/frame/pluginfactory.h ${CMAKE_SOURCE_DIR}/frame/pluginfactory.cpp + + # Batch N+1: dstypes, applet, appletproxy, dsutility + ${CMAKE_SOURCE_DIR}/frame/dstypes.h + ${CMAKE_SOURCE_DIR}/frame/dstypes.cpp + ${CMAKE_SOURCE_DIR}/frame/applet.h + ${CMAKE_SOURCE_DIR}/frame/applet.cpp + ${CMAKE_SOURCE_DIR}/frame/appletproxy.h + ${CMAKE_SOURCE_DIR}/frame/appletproxy.cpp + ${CMAKE_SOURCE_DIR}/frame/dsutility.h + ${CMAKE_SOURCE_DIR}/frame/dsutility.cpp ) target_compile_definitions(frame_test_objects PRIVATE DS_LIB) target_include_directories(frame_test_objects PUBLIC ${CMAKE_SOURCE_DIR}/frame ${CMAKE_SOURCE_DIR}/frame/models ) +# Dtk::Core is needed by applet.cpp/appletproxy.cpp/dsutility.cpp (DObject/DObjectPrivate). +# Qt::Gui is needed by dsutility.cpp (QGuiApplication/QWindow). +# Both are already found by the top-level CMakeLists.txt. target_link_libraries(frame_test_objects PUBLIC Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Qml + Qt${QT_VERSION_MAJOR}::Gui + Dtk${DTK_VERSION_MAJOR}::Core ) +# --- B1 workaround (test-side, no src change) ------------------------------- +# dsutility.cpp uses QGuiApplication::platformName() / qGuiApp->allWindows() but +# does NOT directly #include . In production, BUILD_WITH_X11=ON +# (default) pulls in utility_x11_p.h which transitively includes QGuiApplication. +# This test OBJECT lib intentionally does NOT define BUILD_WITH_X11 (to avoid XCB +# cascade), which cuts that transitive include. As a test-side workaround we +# force-include into every TU of the OBJECT lib so dsutility.cpp +# compiles without touching src. NB: this is a workaround for a src robustness +# gap (dsutility.cpp should directly include ); a separate src +# defect issue should fix it, after which this block can be removed. +# +# Use find_path to resolve the absolute header path (avoid hardcoding), then pass +# -include and the path as TWO separate list elements (CMake list separator) — +# writing "-include" would be misparsed ("<" as redirection by make/shell). +find_path(QT6_GUIAPP_INCLUDE_DIR qguiapplication.h + PATHS ${Qt${QT_VERSION_MAJOR}Gui_INCLUDE_DIRS} + NO_DEFAULT_PATH +) +if(QT6_GUIAPP_INCLUDE_DIR) + target_compile_options(frame_test_objects PRIVATE + "-include" "${QT6_GUIAPP_INCLUDE_DIR}/qguiapplication.h") +else() + # Fallback: Qt6 may expose headers under a QtGui/ subdirectory or via the + # imported target's INTERFACE_INCLUDE_DIRECTORIES. Resolve via the target. + get_target_property(_qt6gui_incdirs Qt${QT_VERSION_MAJOR}::Gui + INTERFACE_INCLUDE_DIRECTORIES) + find_path(QT6_GUIAPP_INCLUDE_DIR qguiapplication.h + PATHS ${_qt6gui_incdirs} + NO_DEFAULT_PATH + ) + if(QT6_GUIAPP_INCLUDE_DIR) + target_compile_options(frame_test_objects PRIVATE + "-include" "${QT6_GUIAPP_INCLUDE_DIR}/qguiapplication.h") + else() + message(WARNING "qguiapplication.h not found in Qt${QT_VERSION_MAJOR}::Gui include dirs; " + "dsutility.cpp may fail to compile. Set QT6_GUIAPP_INCLUDE_DIR manually.") + endif() +endif() + # ---- Helper: declare a frame test executable with the standard link set ------- set(FRAME_TEST_TARGETS "") function(frame_add_test NAME) @@ -92,6 +149,12 @@ frame_add_test(proxymodel_tests ) frame_add_test(pluginfactory_tests pluginfactorytests.cpp) +# Batch N+1: dstypes, applet, appletproxy, dsutility +frame_add_test(types_tests typestests.cpp) +frame_add_test(applet_tests applettests.cpp) +frame_add_test(appletproxy_tests appletproxytests.cpp) +frame_add_test(utility_tests utilitytests.cpp) + # Visibility: private-member access for ListToTableProxyModel::m_roles / # m_sourceColumn is done via a scoped `#define private public` block in # listtotableproxymodeltests.cpp itself (around the model header only), NOT diff --git a/tests/frame/appletproxytests.cpp b/tests/frame/appletproxytests.cpp new file mode 100644 index 000000000..746751ab4 --- /dev/null +++ b/tests/frame/appletproxytests.cpp @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +// Unit tests for DAppletProxy and DAppletMetaProxy (frame/appletproxy). +// +// DAppletProxy's constructor is protected, so it cannot be directly +// instantiated. DAppletMetaProxy (declared in the private header +// private/appletproxy_p.h) has a public constructor and is the concrete class +// we test. Its metaObject()/qt_metacast()/qt_metacall() overrides have +// interesting branching: when the wrapped `meta` QObject is null vs non-null. + +#include + +#include +#include +#include +#include + +// Private header that declares DAppletMetaProxy. +#include "private/appletproxy_p.h" + +using namespace ds; + +// A minimal Q_OBJECT subclass so its staticMetaObject differs from +// QObject's, letting us distinguish the meta-null vs meta-non-null branches. +class TestMetaObject : public QObject +{ + Q_OBJECT +public: + explicit TestMetaObject(QObject *parent = nullptr) : QObject(parent) {} +}; + +// --- DAppletMetaProxy::metaObject() --- + +// With a non-null meta, metaObject() returns the meta's metaObject. +TEST(DAppletMetaProxy, MetaObjectWithMeta) +{ + TestMetaObject meta; + DAppletMetaProxy proxy(&meta, nullptr); + EXPECT_EQ(proxy.metaObject(), meta.metaObject()); + EXPECT_NE(proxy.metaObject(), &QObject::staticMetaObject); +} + +// With a null meta, metaObject() returns the base (DAppletProxy/QObject) staticMetaObject. +TEST(DAppletMetaProxy, MetaObjectWithoutMeta) +{ + DAppletMetaProxy proxy(nullptr, nullptr); + EXPECT_EQ(proxy.metaObject(), &QObject::staticMetaObject); +} + +// --- DAppletMetaProxy::qt_metacast() --- + +// With a non-null meta, qt_metacast returns the meta pointer regardless of clname. +TEST(DAppletMetaProxy, MetaCastWithMeta) +{ + TestMetaObject meta; + DAppletMetaProxy proxy(&meta, nullptr); + void *result = proxy.qt_metacast("QObject"); + EXPECT_EQ(result, &meta); +} + +// Without meta and a null clname, qt_metacast returns nullptr. +TEST(DAppletMetaProxy, MetaCastWithoutMetaNullName) +{ + DAppletMetaProxy proxy(nullptr, nullptr); + EXPECT_EQ(proxy.qt_metacast(nullptr), nullptr); +} + +// Without meta and a valid clname, qt_metacast delegates to QObject's implementation. +TEST(DAppletMetaProxy, MetaCastWithoutMetaValidName) +{ + DAppletMetaProxy proxy(nullptr, nullptr); + void *result = proxy.qt_metacast("QObject"); + // QObject::qt_metacast("QObject") returns `this` if the object is a QObject. + EXPECT_EQ(result, &proxy); +} + +// --- DAppletMetaProxy::qt_metacall() --- + +// Without meta, qt_metacall delegates directly to the base implementation. +TEST(DAppletMetaProxy, MetaCallWithoutMeta) +{ + DAppletMetaProxy proxy(nullptr, nullptr); + // id=-1 → invalid → base returns -1. + int result = proxy.qt_metacall(QMetaObject::WriteProperty, -1, nullptr); + EXPECT_EQ(result, -1); +} + +// With meta, qt_metacall calls meta's qt_metacall; if it returns < 0, falls through. +TEST(DAppletMetaProxy, MetaCallWithMetaFallthrough) +{ + TestMetaObject meta; + DAppletMetaProxy proxy(&meta, nullptr); + // id=-1 → meta's qt_metacall returns -1 (< 0) → falls through to base → -1. + int result = proxy.qt_metacall(QMetaObject::WriteProperty, -1, nullptr); + EXPECT_EQ(result, -1); +} + +// Destructor is safe. +TEST(DAppletMetaProxy, DestructorSafety) +{ + EXPECT_NO_THROW({ + DAppletMetaProxy proxy(nullptr, nullptr); + }); + EXPECT_NO_THROW({ + TestMetaObject meta; + DAppletMetaProxy proxy(&meta, nullptr); + }); +} + +#include "appletproxytests.moc" diff --git a/tests/frame/applettests.cpp b/tests/frame/applettests.cpp new file mode 100644 index 000000000..26ad7e8e7 --- /dev/null +++ b/tests/frame/applettests.cpp @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +// Unit tests for DApplet (frame/applet), the base plugin-instance class. +// Tests the public API: id, pluginId, rootObject/setRootObject (+ signal), +// parentApplet, appletData/setAppletData, pluginMetaData, load, init. +// createProxyMeta is protected — tested via a minimal test subclass. + +#include + +#include +#include +#include +#include + +#include "applet.h" +#include "appletdata.h" +#include "pluginmetadata.h" + +using namespace ds; + +// Test subclass to expose the protected createProxyMeta(). +class TestApplet : public DApplet +{ +public: + explicit TestApplet(QObject *parent = nullptr) : DApplet(parent) {} + using DApplet::createProxyMeta; // lift protected → public for testing +}; + +// Default-constructed applet has empty id/pluginId and no rootObject. +TEST(DApplet, DefaultState) +{ + DApplet applet; + EXPECT_TRUE(applet.id().isEmpty()); + EXPECT_TRUE(applet.pluginId().isEmpty()); + EXPECT_EQ(applet.rootObject(), nullptr); + EXPECT_FALSE(applet.pluginMetaData().isValid()); + EXPECT_EQ(applet.parentApplet(), nullptr); + EXPECT_FALSE(applet.appletData().isValid()); +} + +// setAppletData / appletData round-trip; id() reflects the data. +TEST(DApplet, SetGetAppletData) +{ + DApplet applet; + DAppletData data(QStringLiteral("org.test.plugin")); + data.setId(QStringLiteral("instance-1")); + applet.setAppletData(data); + + EXPECT_EQ(applet.appletData().pluginId(), QStringLiteral("org.test.plugin")); + EXPECT_EQ(applet.appletData().id(), QStringLiteral("instance-1")); + EXPECT_EQ(applet.id(), QStringLiteral("instance-1")); +} + +// setRootObject with a new object emits rootObjectChanged. +TEST(DApplet, SetRootObjectEmitsSignal) +{ + DApplet applet; + QSignalSpy spy(&applet, &DApplet::rootObjectChanged); + ASSERT_TRUE(spy.isValid()); + + auto *obj = new QObject(); + applet.setRootObject(obj); + EXPECT_EQ(applet.rootObject(), obj); + EXPECT_EQ(spy.count(), 1); +} + +// setRootObject with the same object does NOT emit the signal. +TEST(DApplet, SetRootObjectSameNoSignal) +{ + DApplet applet; + auto *obj = new QObject(); + applet.setRootObject(obj); + + QSignalSpy spy(&applet, &DApplet::rootObjectChanged); + applet.setRootObject(obj); // same — no signal + EXPECT_EQ(spy.count(), 0); +} + +// setRootObject to nullptr emits the signal and clears rootObject. +TEST(DApplet, SetRootObjectNullEmitsSignal) +{ + DApplet applet; + auto *obj = new QObject(); + applet.setRootObject(obj); + + QSignalSpy spy(&applet, &DApplet::rootObjectChanged); + applet.setRootObject(nullptr); + EXPECT_EQ(applet.rootObject(), nullptr); + EXPECT_EQ(spy.count(), 1); + delete obj; // clean up; DAppletPrivate destructor would deleteLater it +} + +// parentApplet() returns the parent cast to DApplet, or nullptr if not a DApplet. +TEST(DApplet, ParentApplet) +{ + // No parent → null + { + DApplet applet; + EXPECT_EQ(applet.parentApplet(), nullptr); + } + + // Parent is a DApplet → returns it + { + DApplet parent; + DApplet child(&parent); + EXPECT_EQ(child.parentApplet(), &parent); + } + + // Parent is a plain QObject (not DApplet) → nullptr + { + QObject plainParent; + DApplet child(&plainParent); + EXPECT_EQ(child.parentApplet(), nullptr); + } +} + +// load() and init() return true by default. +TEST(DApplet, LoadInitReturnTrue) +{ + DApplet applet; + EXPECT_TRUE(applet.load()); + EXPECT_TRUE(applet.init()); +} + +// createProxyMeta() returns `this` (the default implementation). +TEST(DApplet, CreateProxyMetaReturnsThis) +{ + TestApplet applet; + EXPECT_EQ(applet.createProxyMeta(), &applet); +} + +// Destructor is safe with and without a rootObject set. +// DAppletPrivate destructor calls m_rootObject->deleteLater(). +TEST(DApplet, DestructorSafety) +{ + EXPECT_NO_THROW({ + // Without rootObject + DApplet a1; + // With rootObject (will be deleteLater'd by DAppletPrivate destructor) + DApplet a2; + a2.setRootObject(new QObject()); + }); +} diff --git a/tests/frame/typestests.cpp b/tests/frame/typestests.cpp new file mode 100644 index 000000000..e5058bf86 --- /dev/null +++ b/tests/frame/typestests.cpp @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +// Unit tests for Types (frame/dstypes), a minimal QObject wrapper used as a +// QML type registration anchor. Only a constructor — no branches. + +#include + +#include + +#include "dstypes.h" + +using namespace ds; + +// Default-constructed Types is a valid QObject with no parent. +TEST(Types, DefaultConstruct) +{ + Types t; + EXPECT_TRUE(t.metaObject()->inherits(&QObject::staticMetaObject)); + EXPECT_EQ(t.parent(), nullptr); +} + +// Parent is wired through QObject's constructor. +TEST(Types, ConstructWithParent) +{ + QObject parent; + auto *t = new Types(&parent); + EXPECT_EQ(t->parent(), &parent); + EXPECT_EQ(parent.children().count(), 1); + delete t; // removing from parent manually; parent still owns it via deleteLater-safe pattern +} + +// Destructor is safe — no crash, no leak when stack-allocated. +TEST(Types, DestructorSafety) +{ + EXPECT_NO_THROW({ + Types t; + }); +} diff --git a/tests/frame/utilitytests.cpp b/tests/frame/utilitytests.cpp new file mode 100644 index 000000000..a3227721f --- /dev/null +++ b/tests/frame/utilitytests.cpp @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +// Unit tests for Utility (frame/dsutility), the base (non-X11) utility class. +// +// Utility's constructor is protected; the static instance() factory creates +// a base Utility (no BUILD_WITH_X11 in the test OBJECT lib). These tests +// exercise instance(), allChildrenWindows(), grabKeyboard(), grabMouse(). +// +// A QGuiApplication is required (QGuiApplication::platformName(), +// qGuiApp->allWindows()). It is provided via a GTest global environment that +// sets QT_QPA_PLATFORM=offscreen and creates QGuiApplication before any test +// runs. This approach is compatible with gtest_discover_tests (each test is a +// separate process) and GTest::Main (no custom main needed). + +#include + +#include +#include +#include +#include + +#include "dsutility.h" + +using namespace ds; + +// ---- Global test environment: creates QGuiApplication (offscreen) ---------- +class QtGuiEnvironment : public ::testing::Environment +{ +public: + void SetUp() override + { + qputenv("QT_QPA_PLATFORM", "offscreen"); + static int argc = 1; + static char arg0[] = "utilitytests"; + static char *argv[] = {arg0, nullptr}; + m_app = new QGuiApplication(argc, argv); + } + void TearDown() override + { + delete m_app; + m_app = nullptr; + } +private: + QGuiApplication *m_app = nullptr; +}; + +// Static init: register the environment before main() runs. +::testing::Environment *const kQtEnv = + ::testing::AddGlobalTestEnvironment(new QtGuiEnvironment); + +// ---- Tests ------------------------------------------------------------------ + +// instance() returns a non-null Utility and is stable across calls. +TEST(Utility, InstanceReturnsNonNull) +{ + auto *u1 = Utility::instance(); + ASSERT_NE(u1, nullptr); + auto *u2 = Utility::instance(); + EXPECT_EQ(u1, u2); // singleton — same pointer +} + +// grabKeyboard / grabMouse are no-ops in the base class → return false. +TEST(Utility, GrabKeyboardMouseReturnFalse) +{ + auto *u = Utility::instance(); + EXPECT_FALSE(u->grabKeyboard(nullptr, true)); + EXPECT_FALSE(u->grabMouse(nullptr, true)); + EXPECT_FALSE(u->grabKeyboard(nullptr, false)); + EXPECT_FALSE(u->grabMouse(nullptr, false)); +} + +// allChildrenWindows with no windows returns an empty list. +TEST(Utility, AllChildrenWindowsEmpty) +{ + auto *u = Utility::instance(); + QWindow target; + // No other windows have target as transient parent. + auto result = u->allChildrenWindows(&target); + EXPECT_TRUE(result.isEmpty()); +} + +// allChildrenWindows finds direct children (windows whose transientParent == target). +TEST(Utility, AllChildrenWindowsDirectChild) +{ + auto *u = Utility::instance(); + QWindow target; + target.setObjectName(QStringLiteral("target")); + QWindow child; + child.setTransientParent(&target); + child.setObjectName(QStringLiteral("child")); + + auto result = u->allChildrenWindows(&target); + ASSERT_EQ(result.size(), 1); + EXPECT_EQ(result.first(), &child); +} + +// allChildrenWindows does NOT include the target itself. +TEST(Utility, AllChildrenWindowsExcludesTarget) +{ + auto *u = Utility::instance(); + QWindow target; + auto result = u->allChildrenWindows(&target); + // The target window is present in qGuiApp->allWindows() but its + // transientParent is null, so the inner while loop ends without matching. + EXPECT_FALSE(result.contains(&target)); +} + +// allChildrenWindows excludes unrelated windows. +TEST(Utility, AllChildrenWindowsExcludesUnrelated) +{ + auto *u = Utility::instance(); + QWindow target; + QWindow unrelated; + unrelated.setObjectName(QStringLiteral("unrelated")); + // unrelated has no transient parent → not a child of target + + auto result = u->allChildrenWindows(&target); + EXPECT_FALSE(result.contains(&unrelated)); +}