From f6cb425e34a6b951ac4ae7dd04a8b677b20c3941 Mon Sep 17 00:00:00 2001 From: Fangxun Zhao Date: Thu, 10 Sep 2026 10:16:27 +0800 Subject: [PATCH] test: add GTest for frame, applets and dock modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add frame tests covering pluginmetadata/appletdata/models/pluginfactory/dstypes/applet/appletproxy/dsutility 2. Add applets tests covering categoryutils and itemspage 3. Add dock tests covering hoverpreviewproxymodel/globals/dockiteminfo/windowmanager/trayitempositionmanager 4. Gate tests behind BUILD_TESTING and add a unified coverage target plus per-module frame_coverage/applets_coverage/dock_coverage targets Log: Add GTest for frame, applets and dock modules with a unified coverage target test: 为 frame、applets 和 dock 模块补充 GTest 单元测试 1. 新增 frame 测试,覆盖 pluginmetadata/appletdata/models/pluginfactory/dstypes/applet/appletproxy/dsutility 2. 新增 applets 测试,覆盖 categoryutils 与 itemspage 3. 新增 dock 测试,覆盖 hoverpreviewproxymodel/globals/dockiteminfo/windowmanager/trayitempositionmanager 4. 以 BUILD_TESTING 门控测试,并新增统一 coverage 目标与 frame_coverage/applets_coverage/dock_coverage 子目标 Log: 为 frame、applets 和 dock 模块补充 GTest 测试与统一 coverage 目标 --- tests/CMakeLists.txt | 11 + tests/applets/CMakeLists.txt | 135 +++++ tests/applets/categoryutilstests.cpp | 159 ++++++ tests/applets/itemspagetests.cpp | 425 ++++++++++++++ tests/frame/CMakeLists.txt | 205 +++++++ tests/frame/appletdatatests.cpp | 193 +++++++ tests/frame/appletitemmodeltests.cpp | 203 +++++++ tests/frame/appletproxytests.cpp | 112 ++++ tests/frame/applettests.cpp | 145 +++++ tests/frame/kextracolumnsproxymodeltests.cpp | 481 ++++++++++++++++ tests/frame/listtotableproxymodeltests.cpp | 239 ++++++++ tests/frame/pluginfactorytests.cpp | 98 ++++ tests/frame/pluginmetadatatests.cpp | 225 ++++++++ tests/frame/typestests.cpp | 40 ++ tests/frame/utilitytests.cpp | 121 ++++ tests/panels/dock/CMakeLists.txt | 1 + tests/panels/dock/docktests/CMakeLists.txt | 236 ++++++++ .../dock/docktests/dockiteminfotests.cpp | 260 +++++++++ tests/panels/dock/docktests/globalstests.cpp | 137 +++++ .../docktests/hoverpreviewproxymodeltests.cpp | 176 ++++++ tests/panels/dock/docktests/sourcemodel.h | 71 +++ tests/panels/dock/docktests/stubs/QQmlEngine | 34 ++ .../trayitempositionmanagertests.cpp | 538 ++++++++++++++++++ .../dock/docktests/windowmanagertests.cpp | 522 +++++++++++++++++ 24 files changed, 4767 insertions(+) create mode 100644 tests/applets/CMakeLists.txt create mode 100644 tests/applets/categoryutilstests.cpp create mode 100644 tests/applets/itemspagetests.cpp 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/appletproxytests.cpp create mode 100644 tests/frame/applettests.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 create mode 100644 tests/frame/typestests.cpp create mode 100644 tests/frame/utilitytests.cpp create mode 100644 tests/panels/dock/docktests/CMakeLists.txt create mode 100644 tests/panels/dock/docktests/dockiteminfotests.cpp create mode 100644 tests/panels/dock/docktests/globalstests.cpp create mode 100644 tests/panels/dock/docktests/hoverpreviewproxymodeltests.cpp create mode 100644 tests/panels/dock/docktests/sourcemodel.h create mode 100644 tests/panels/dock/docktests/stubs/QQmlEngine create mode 100644 tests/panels/dock/docktests/trayitempositionmanagertests.cpp create mode 100644 tests/panels/dock/docktests/windowmanagertests.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 77e6bee23..b9093e579 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,3 +3,14 @@ # SPDX-License-Identifier: CC0-1.0 add_subdirectory(panels) +add_subdirectory(frame) +add_subdirectory(applets) + +# ---- coverage: run all dde-shell module tests and generate coverage reports ---- +# Trigger the per-subdir frame_coverage/applets_coverage/dock_coverage targets, +# each scoped to its module (ctest -R). Scoping keeps the pre-existing +# taskmanager/notification tests (which fail on master) out of the coverage run. +add_custom_target(coverage + DEPENDS frame_coverage applets_coverage dock_coverage + COMMENT "Run all dde-shell module tests and generate coverage reports" +) diff --git a/tests/applets/CMakeLists.txt b/tests/applets/CMakeLists.txt new file mode 100644 index 000000000..4a39bec06 --- /dev/null +++ b/tests/applets/CMakeLists.txt @@ -0,0 +1,135 @@ +# SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# ============================================================================= +# dde-shell applets/ unit tests (GTest). +# +# OBJECT library organization decision (per leader's request): +# Single OBJECT lib `applets_test_objects` for the pure-logic dde-apps +# classes that have NO DConfig / DBus / Wayland / frame-singleton deps: +# * categoryutils.cpp (pure namespace functions) +# * itemspage.cpp (pure QObject logic, no external deps) +# These two are the only applets/ sources compiled into the OBJECT lib. +# +# NOT compiled into the OBJECT lib (deferred with reasons): +# * appslaunchtimes / appsdockedhelper — DConfig singleton (constructor +# calls DConfig::create + reads/writes), needs DConfig runtime; testable +# only with DConfig mock or DBus. Deferred. +# * appitem — constructor calls AppsLaunchTimesHelper::instance() (global +# singleton + DConfig); setLaunchedTimes/setDocked call singleton helpers. +# Deferred (would need mock or refactor). +# * appgroup — constructor instantiates ItemsPage (OK) but setData uses +# AppGroupManager roles (needs the model); parseGroupId has a src bug. +# Partially testable (static helpers) — covered via scoped include. +# * appgroupmanager — DConfig + AMAppItemModel reference (DBus). Deferred. +# * amappitemmodel / amappitem — DBus ObjectManager/Application proxies. +# Deferred. +# * appsapplet — instantiates AMAppItemModel (DBus) + AppGroupManager. +# Deferred. +# * amapplet / appearanceapplet — DBus session bus in load(). Deferred. +# * keynotifyapplet / shutdownapplet — Wayland platform check + DBus. +# Deferred. +# * treelandkeynotify / treelandlockscreen — Wayland private API. Deferred. +# +# applets sources that reference frame/ classes (DApplet) are NOT compiled +# here — they'd need frame_test_objects linkage and pull DConfig/DBus. The +# two pure-logic sources (categoryutils, itemspage) have ZERO frame/ deps. +# +# Coverage target: `applets_coverage` (independent from `frame_coverage`), +# ctest prefix `applets_` so `ctest -R '^applets_'` runs only these tests. +# ============================================================================= + +find_package(GTest REQUIRED) +find_package(Qt${QT_VERSION_MAJOR} ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS Core Gui Test) + +include(GoogleTest) + +option(APPLETS_BUILD_COVERAGE "Enable gcov coverage instrumentation for dde-shell applets unit tests" ON) + +# ---- OBJECT library: pure-logic applets/ sources under test ----------------- +add_library(applets_test_objects OBJECT + ${CMAKE_SOURCE_DIR}/applets/dde-apps/categoryutils.h + ${CMAKE_SOURCE_DIR}/applets/dde-apps/categoryutils.cpp + ${CMAKE_SOURCE_DIR}/applets/dde-apps/itemspage.h + ${CMAKE_SOURCE_DIR}/applets/dde-apps/itemspage.cpp +) +target_include_directories(applets_test_objects PUBLIC + ${CMAKE_SOURCE_DIR}/applets/dde-apps +) +target_link_libraries(applets_test_objects PUBLIC + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui +) + +# ---- Helper: declare an applets test executable ----------------------------- +set(APPLETS_TEST_TARGETS "") +function(applets_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 + applets_test_objects + ) + gtest_discover_tests(${NAME} TEST_PREFIX "applets_") + list(APPEND APPLETS_TEST_TARGETS ${NAME}) + set(APPLETS_TEST_TARGETS "${APPLETS_TEST_TARGETS}" PARENT_SCOPE) +endfunction() + +# ---- Test executables ------------------------------------------------------- +# categoryutils: pure namespace functions (parseBestMatchedCategory, +# parseDDECategoryString, parseXdgCategoryString) with rich branching. +applets_add_test(categoryutils_tests categoryutilstests.cpp) + +# itemspage: pure-logic pagination manager (append/insert/move/remove/find). +applets_add_test(itemspage_tests itemspagetests.cpp) + +# NOTE: appgroup static helpers (idIsFolder/groupIdFromNumber/parseGroupId) are +# deferred: appgroup.h → appitem.h → am.h → QDBusObjectPath (Qt6::DBus) + +# appgroupmanager.h → DConfig (Dtk6::Core) + QQmlEngine (Qt6::Qml) + yaml-cpp. +# The dependency chain is too heavy for 3 trivial one-liner static methods. +# Defer to a future batch with DConfig mock or after src refactoring. +# Also: parseGroupId has an off-by-one bug (mid(len+1) should be mid(len)) — +# recorded as a src defect, not fixed here. + +# ---- Coverage instrumentation + report generation --------------------------- +if(APPLETS_BUILD_COVERAGE) + message(STATUS "applets tests: coverage instrumentation ENABLED (APPLETS_BUILD_COVERAGE=ON)") + target_compile_options(applets_test_objects PRIVATE -fprofile-arcs -ftest-coverage -O0 -g) + foreach(_t IN LISTS APPLETS_TEST_TARGETS) + target_compile_options(${_t} PRIVATE -fprofile-arcs -ftest-coverage -O0 -g) + target_link_options(${_t} PRIVATE -fprofile-arcs) + endforeach() +endif() + +# ---- coverage: execute applets tests and generate coverage report ------------ +find_program(LCOV_BIN lcov) +find_program(GENHTML_BIN genhtml) +set(_run_test_coverage "") +if(LCOV_BIN AND GENHTML_BIN) + set(APPLETS_COVERAGE_DIR "${CMAKE_BINARY_DIR}/applets_coverage") + set(_run_test_coverage + COMMAND ${CMAKE_COMMAND} -E make_directory "${APPLETS_COVERAGE_DIR}" + COMMAND ${LCOV_BIN} --capture --directory "${CMAKE_BINARY_DIR}" + --rc lcov_branch_coverage=1 + --output-file "${APPLETS_COVERAGE_DIR}/applets.info" + COMMAND ${LCOV_BIN} --extract "${APPLETS_COVERAGE_DIR}/applets.info" + --rc lcov_branch_coverage=1 + "${CMAKE_SOURCE_DIR}/applets/dde-apps/*" + -o "${APPLETS_COVERAGE_DIR}/applets_filtered.info" + COMMAND ${GENHTML_BIN} "${APPLETS_COVERAGE_DIR}/applets_filtered.info" + -o "${APPLETS_COVERAGE_DIR}/html" --branch-coverage + COMMAND ${CMAKE_COMMAND} -E echo "=== Applets coverage report: ${APPLETS_COVERAGE_DIR}/html/index.html ===" + ) +endif() +add_custom_target(applets_coverage + DEPENDS ${APPLETS_TEST_TARGETS} + COMMAND ${CMAKE_CTEST_COMMAND} --test-dir "${CMAKE_BINARY_DIR}" -R "^applets_" --output-on-failure + ${_run_test_coverage} + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}" + COMMENT "Run applets unit tests and generate lcov branch-coverage report" + VERBATIM +) diff --git a/tests/applets/categoryutilstests.cpp b/tests/applets/categoryutilstests.cpp new file mode 100644 index 000000000..69d9355e9 --- /dev/null +++ b/tests/applets/categoryutilstests.cpp @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +// Unit tests for CategoryUtils (applets/dde-apps/categoryutils), a pure +// namespace with three functions that parse desktop-file category strings into +// DDE Categorytype enums. Rich branching: DDE-name match, XDG-name match, +// best-match voting, music+video tie-break, empty input. + +#include + +#include +#include +#include + +#include "categoryutils.h" + +using namespace CategoryUtils; + +// --- parseDDECategoryString --- + +TEST(ParseDDECategoryString, KnownDDECategories) +{ + EXPECT_EQ(parseDDECategoryString(QStringLiteral("internet")), Categorytype::CategoryInternet); + EXPECT_EQ(parseDDECategoryString(QStringLiteral("chat")), Categorytype::CategoryChat); + EXPECT_EQ(parseDDECategoryString(QStringLiteral("music")), Categorytype::CategoryMusic); + EXPECT_EQ(parseDDECategoryString(QStringLiteral("video")), Categorytype::CategoryVideo); + EXPECT_EQ(parseDDECategoryString(QStringLiteral("graphics")), Categorytype::CategoryGraphics); + EXPECT_EQ(parseDDECategoryString(QStringLiteral("game")), Categorytype::CategoryGame); + EXPECT_EQ(parseDDECategoryString(QStringLiteral("office")), Categorytype::CategoryOffice); + EXPECT_EQ(parseDDECategoryString(QStringLiteral("reading")), Categorytype::CategoryReading); + EXPECT_EQ(parseDDECategoryString(QStringLiteral("development")), Categorytype::CategoryDevelopment); + EXPECT_EQ(parseDDECategoryString(QStringLiteral("system")), Categorytype::CategorySystem); + EXPECT_EQ(parseDDECategoryString(QStringLiteral("others")), Categorytype::CategoryOthers); +} + +TEST(ParseDDECategoryString, UnknownReturnsErr) +{ + EXPECT_EQ(parseDDECategoryString(QStringLiteral("nonexistent")), Categorytype::CategoryErr); + EXPECT_EQ(parseDDECategoryString(QStringLiteral("")), Categorytype::CategoryErr); + EXPECT_EQ(parseDDECategoryString(QStringLiteral("INTERNET")), Categorytype::CategoryErr); // case-sensitive +} + +// --- parseXdgCategoryString --- + +TEST(ParseXdgCategoryString, KnownXdgCategories) +{ + // Single-match XDG names + EXPECT_EQ(parseXdgCategoryString(QStringLiteral("webbrowser")).size(), 1); + EXPECT_EQ(parseXdgCategoryString(QStringLiteral("webbrowser")).first(), Categorytype::CategoryInternet); + EXPECT_EQ(parseXdgCategoryString(QStringLiteral("ide")).first(), Categorytype::CategoryDevelopment); + EXPECT_EQ(parseXdgCategoryString(QStringLiteral("boardgame")).first(), Categorytype::CategoryGame); +} + +TEST(ParseXdgCategoryString, MultiMatchXdgCategories) +{ + // "audiovideo" maps to both Music and Video + auto result = parseXdgCategoryString(QStringLiteral("audiovideo")); + EXPECT_EQ(result.size(), 2); + EXPECT_TRUE(result.contains(Categorytype::CategoryMusic)); + EXPECT_TRUE(result.contains(Categorytype::CategoryVideo)); + + // "player" maps to both Music and Video + result = parseXdgCategoryString(QStringLiteral("player")); + EXPECT_EQ(result.size(), 2); + EXPECT_TRUE(result.contains(Categorytype::CategoryMusic)); + EXPECT_TRUE(result.contains(Categorytype::CategoryVideo)); +} + +TEST(ParseXdgCategoryString, UnknownReturnsEmpty) +{ + EXPECT_TRUE(parseXdgCategoryString(QStringLiteral("nonexistent")).isEmpty()); + EXPECT_TRUE(parseXdgCategoryString(QStringLiteral("")).isEmpty()); +} + +TEST(ParseXdgCategoryString, XPrefixCategories) +{ + // x-prefixed categories + EXPECT_EQ(parseXdgCategoryString(QStringLiteral("x-midi")).first(), Categorytype::CategoryMusic); + EXPECT_EQ(parseXdgCategoryString(QStringLiteral("x-bluetooth")).first(), Categorytype::CategorySystem); + EXPECT_EQ(parseXdgCategoryString(QStringLiteral("x-quran")).first(), Categorytype::CategoryReading); +} + +// --- parseBestMatchedCategory --- + +TEST(ParseBestMatchedCategory, EmptyInputReturnsOthers) +{ + EXPECT_EQ(parseBestMatchedCategory({}), Categorytype::CategoryOthers); +} + +TEST(ParseBestMatchedCategory, SingleDDECategory) +{ + EXPECT_EQ(parseBestMatchedCategory({QStringLiteral("music")}), Categorytype::CategoryMusic); + EXPECT_EQ(parseBestMatchedCategory({QStringLiteral("development")}), Categorytype::CategoryDevelopment); + EXPECT_EQ(parseBestMatchedCategory({QStringLiteral("system")}), Categorytype::CategorySystem); +} + +TEST(ParseBestMatchedCategory, SingleXdgCategory) +{ + EXPECT_EQ(parseBestMatchedCategory({QStringLiteral("webbrowser")}), Categorytype::CategoryInternet); + EXPECT_EQ(parseBestMatchedCategory({QStringLiteral("ide")}), Categorytype::CategoryDevelopment); +} + +TEST(ParseBestMatchedCategory, MultipleSameCategory) +{ + // Multiple categories that all map to the same type + EXPECT_EQ(parseBestMatchedCategory({QStringLiteral("music"), QStringLiteral("player")}), + Categorytype::CategoryMusic); +} + +TEST(ParseBestMatchedCategory, VotingPicksMostCommon) +{ + // 2x game + 1x system → game wins + EXPECT_EQ(parseBestMatchedCategory({QStringLiteral("game"), QStringLiteral("arcadegame"), QStringLiteral("system")}), + Categorytype::CategoryGame); +} + +TEST(ParseBestMatchedCategory, OnlyOthersReturnsOthers) +{ + // Categories that only map to Others are removed; empty map → Others + EXPECT_EQ(parseBestMatchedCategory({QStringLiteral("accessories")}), Categorytype::CategoryOthers); + EXPECT_EQ(parseBestMatchedCategory({QStringLiteral("accessories"), QStringLiteral("core")}), + Categorytype::CategoryOthers); +} + +TEST(ParseBestMatchedCategory, MusicVideoTieBreakReturnsVideo) +{ + // Tie between Music and Video → special tie-break returns Video + EXPECT_EQ(parseBestMatchedCategory({QStringLiteral("music"), QStringLiteral("video")}), + Categorytype::CategoryVideo); +} + +TEST(ParseBestMatchedCategory, UnknownCategoryIgnored) +{ + // Unknown category strings are ignored; only known ones vote + EXPECT_EQ(parseBestMatchedCategory({QStringLiteral("nonexistent"), QStringLiteral("music")}), + Categorytype::CategoryMusic); + // All unknown → Others + EXPECT_EQ(parseBestMatchedCategory({QStringLiteral("nonexistent1"), QStringLiteral("nonexistent2")}), + Categorytype::CategoryOthers); +} + +TEST(ParseBestMatchedCategory, CaseInsensitiveInput) +{ + // parseBestMatchedCategory calls toLower() on each category + EXPECT_EQ(parseBestMatchedCategory({QStringLiteral("MUSIC")}), Categorytype::CategoryMusic); + EXPECT_EQ(parseBestMatchedCategory({QStringLiteral("Development")}), Categorytype::CategoryDevelopment); +} + +TEST(ParseBestMatchedCategory, TieWithoutMusicVideoReturnsFirstSorted) +{ + // Tie between two non-music/video categories → sorted, returns first + // game + development tie (1 each) → sorted: Development < Game → Development + auto result = parseBestMatchedCategory({QStringLiteral("game"), QStringLiteral("ide")}); + // Both have 1 vote, sorted: CategoryDevelopment(8) < CategoryGame(5)? + // Actually enum values: Game=5, Development=8 → sorted ascending: Game < Development + // So first sorted = Game + EXPECT_EQ(result, Categorytype::CategoryGame); +} diff --git a/tests/applets/itemspagetests.cpp b/tests/applets/itemspagetests.cpp new file mode 100644 index 000000000..9c35269c3 --- /dev/null +++ b/tests/applets/itemspagetests.cpp @@ -0,0 +1,425 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +// Unit tests for ItemsPage (applets/dde-apps/itemspage), a pure-logic +// pagination manager. Exercises all public methods and their branches: +// construction, name, pageCount, append/insert/move/remove items, findItem, +// contains, allArrangedItems, allPagedItems, itemCount, removeEmptyPages, +// removeItemsNotIn, firstNItems. No external deps (pure QObject). + +#include + +#include +#include +#include +#include + +#include "itemspage.h" + +// --- Construction & basic properties --- + +TEST(ItemsPage, ConstructWithMaxCount) +{ + ItemsPage page(4); + EXPECT_EQ(page.maxItemCountPerPage(), 4); + EXPECT_EQ(page.pageCount(), 0); + EXPECT_TRUE(page.name().isEmpty()); +} + +TEST(ItemsPage, ConstructWithNameAndMaxCount) +{ + ItemsPage page(QStringLiteral("mygroup"), 3); + EXPECT_EQ(page.maxItemCountPerPage(), 3); + EXPECT_EQ(page.name(), QStringLiteral("mygroup")); + EXPECT_EQ(page.pageCount(), 0); +} + +// --- name / setName --- + +TEST(ItemsPage, SetNameEmitsSignal) +{ + ItemsPage page(4); + QSignalSpy spy(&page, &ItemsPage::nameChanged); + ASSERT_TRUE(spy.isValid()); + page.setName(QStringLiteral("newname")); + EXPECT_EQ(page.name(), QStringLiteral("newname")); + EXPECT_EQ(spy.count(), 1); +} + +// --- appendEmptyPage --- + +TEST(ItemsPage, AppendEmptyPage) +{ + ItemsPage page(3); + QSignalSpy pageSpy(&page, &ItemsPage::pageCountChanged); + QSignalSpy addedSpy(&page, &ItemsPage::sigPageAdded); + page.appendEmptyPage(); + EXPECT_EQ(page.pageCount(), 1); + EXPECT_EQ(pageSpy.count(), 1); + EXPECT_EQ(addedSpy.count(), 1); + EXPECT_TRUE(page.items(0).isEmpty()); +} + +// --- appendPage --- + +TEST(ItemsPage, AppendPageExactMultiple) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a"), QStringLiteral("b"), QStringLiteral("c")}); + EXPECT_EQ(page.pageCount(), 1); + EXPECT_EQ(page.items(0).size(), 3); +} + +TEST(ItemsPage, AppendPageWithRemainder) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a"), QStringLiteral("b"), QStringLiteral("c"), + QStringLiteral("d")}); + EXPECT_EQ(page.pageCount(), 2); + EXPECT_EQ(page.items(0).size(), 3); + EXPECT_EQ(page.items(1).size(), 1); + EXPECT_EQ(page.items(1).first(), QStringLiteral("d")); +} + +TEST(ItemsPage, AppendPageEmptyIsNoop) +{ + ItemsPage page(3); + page.appendPage({}); + EXPECT_EQ(page.pageCount(), 0); +} + +TEST(ItemsPage, AppendPageSignals) +{ + ItemsPage page(2); + QSignalSpy countSpy(&page, &ItemsPage::pageCountChanged); + QSignalSpy addedSpy(&page, &ItemsPage::sigPageAdded); + page.appendPage({QStringLiteral("a"), QStringLiteral("b"), QStringLiteral("c"), QStringLiteral("d")}); + // 4 items / 2 per page = 2 pages, exact multiple (no remainder branch) + EXPECT_EQ(page.pageCount(), 2); + EXPECT_EQ(countSpy.count(), 1); + EXPECT_EQ(addedSpy.count(), 1); +} + +// --- appendItem (find a page with empty place) --- + +TEST(ItemsPage, AppendItemToEmptyPage) +{ + ItemsPage page(3); + page.appendEmptyPage(); + page.appendItem(QStringLiteral("a")); + EXPECT_EQ(page.items(0).size(), 1); + EXPECT_EQ(page.items(0).first(), QStringLiteral("a")); +} + +TEST(ItemsPage, AppendItemFillsExistingPage) +{ + ItemsPage page(2); + page.appendEmptyPage(); + page.appendItem(QStringLiteral("a")); + page.appendItem(QStringLiteral("b")); + EXPECT_EQ(page.items(0).size(), 2); + // Next append should create a new page + page.appendItem(QStringLiteral("c")); + EXPECT_EQ(page.pageCount(), 2); + EXPECT_EQ(page.items(1).first(), QStringLiteral("c")); +} + +TEST(ItemsPage, AppendItemNoExistingPages) +{ + ItemsPage page(2); + // No pages exist → appendPage with the item + page.appendItem(QStringLiteral("a")); + EXPECT_EQ(page.pageCount(), 1); + EXPECT_EQ(page.items(0).first(), QStringLiteral("a")); +} + +// --- insertItem --- + +TEST(ItemsPage, InsertItemInMiddle) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a"), QStringLiteral("b"), QStringLiteral("c")}); + page.insertItem(QStringLiteral("x"), 0, 1); // insert at page 0, pos 1 + EXPECT_EQ(page.items(0).at(1), QStringLiteral("x")); + // Page now has 4 items > max 3 → last spills to new page + EXPECT_EQ(page.pageCount(), 2); + EXPECT_EQ(page.items(1).first(), QStringLiteral("c")); +} + +TEST(ItemsPage, InsertItemAtEnd) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a"), QStringLiteral("b")}); + page.insertItem(QStringLiteral("c"), 0, 5); // pos > count → clamped to count + EXPECT_EQ(page.items(0).size(), 3); + EXPECT_EQ(page.items(0).last(), QStringLiteral("c")); +} + +TEST(ItemsPage, InsertItemSpillsToNextPage) +{ + ItemsPage page(2); + page.appendPage({QStringLiteral("a"), QStringLiteral("b")}); + page.insertItem(QStringLiteral("x"), 0, 0); // insert at front, full page + // "b" spills to next page (which doesn't exist → appendPage) + EXPECT_EQ(page.pageCount(), 2); + EXPECT_EQ(page.items(0).at(0), QStringLiteral("x")); + EXPECT_EQ(page.items(0).at(1), QStringLiteral("a")); + EXPECT_EQ(page.items(1).first(), QStringLiteral("b")); +} + +// --- insertItemToPage --- + +TEST(ItemsPage, InsertItemToPage) +{ + ItemsPage page(3); + page.appendEmptyPage(); + page.appendEmptyPage(); + page.insertItemToPage(QStringLiteral("x"), 1); + EXPECT_EQ(page.items(1).size(), 1); + EXPECT_EQ(page.items(1).first(), QStringLiteral("x")); +} + +// --- findItem / contains --- + +TEST(ItemsPage, FindItemExists) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a"), QStringLiteral("b"), QStringLiteral("c")}); + page.appendPage({QStringLiteral("d"), QStringLiteral("e")}); + auto [pageIdx, itemIdx] = page.findItem(QStringLiteral("d")); + EXPECT_EQ(pageIdx, 1); + EXPECT_EQ(itemIdx, 0); +} + +TEST(ItemsPage, FindItemNotFound) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a")}); + auto [pageIdx, itemIdx] = page.findItem(QStringLiteral("z")); + EXPECT_EQ(pageIdx, -1); + EXPECT_EQ(itemIdx, -1); +} + +TEST(ItemsPage, Contains) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a"), QStringLiteral("b")}); + EXPECT_TRUE(page.contains(QStringLiteral("a"))); + EXPECT_TRUE(page.contains(QStringLiteral("b"))); + EXPECT_FALSE(page.contains(QStringLiteral("z"))); +} + +// --- removeItem --- + +TEST(ItemsPage, RemoveItemKeepsPage) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a"), QStringLiteral("b"), QStringLiteral("c")}); + EXPECT_TRUE(page.removeItem(QStringLiteral("b"))); + EXPECT_EQ(page.items(0).size(), 2); + EXPECT_FALSE(page.contains(QStringLiteral("b"))); +} + +TEST(ItemsPage, RemoveItemRemovesEmptyPage) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a")}); + EXPECT_TRUE(page.removeItem(QStringLiteral("a"))); + EXPECT_EQ(page.pageCount(), 0); // page was emptied → removed +} + +TEST(ItemsPage, RemoveItemKeepEmptyPage) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a")}); + EXPECT_TRUE(page.removeItem(QStringLiteral("a"), false)); // don't remove empty page + EXPECT_EQ(page.pageCount(), 1); + EXPECT_TRUE(page.items(0).isEmpty()); +} + +TEST(ItemsPage, RemoveItemNotFoundReturnsFalse) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a")}); + EXPECT_FALSE(page.removeItem(QStringLiteral("z"))); +} + +// --- removeEmptyPages --- + +TEST(ItemsPage, RemoveEmptyPages) +{ + ItemsPage page(3); + page.appendEmptyPage(); + page.appendEmptyPage(); + page.appendPage({QStringLiteral("a")}); + EXPECT_EQ(page.pageCount(), 3); + page.removeEmptyPages(); + EXPECT_EQ(page.pageCount(), 1); +} + +TEST(ItemsPage, RemoveEmptyPagesNoChange) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a")}); + QSignalSpy spy(&page, &ItemsPage::pageCountChanged); + page.removeEmptyPages(); + EXPECT_EQ(page.pageCount(), 1); + EXPECT_EQ(spy.count(), 0); // no change → no signal +} + +// --- removeItemsNotIn --- + +TEST(ItemsPage, RemoveItemsNotIn) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a"), QStringLiteral("b"), QStringLiteral("internal/x")}); + page.removeItemsNotIn(QSet{QStringLiteral("a"), QStringLiteral("internal/x")}); + EXPECT_EQ(page.items(0).size(), 2); + EXPECT_TRUE(page.contains(QStringLiteral("a"))); + EXPECT_FALSE(page.contains(QStringLiteral("b"))); + // internal/ items are always kept + EXPECT_TRUE(page.contains(QStringLiteral("internal/x"))); +} + +TEST(ItemsPage, RemoveItemsNotInAllRemovedClearsPages) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a"), QStringLiteral("b")}); + page.removeItemsNotIn(QSet{QStringLiteral("z")}); + EXPECT_EQ(page.pageCount(), 0); // all removed, empty pages cleaned +} + +// --- moveItemPosition --- + +TEST(ItemsPage, MoveItemSamePageForward) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a"), QStringLiteral("b"), QStringLiteral("c")}); + // Move "a" (page 0, index 0) to page 0, index 2, appendToIndexItem=true + page.moveItemPosition(0, 0, 0, 2, true); + // fromIndex(0) > toIndex(2) is false (0 < 2), so toIndex stays 2 + EXPECT_EQ(page.items(0).at(2), QStringLiteral("a")); +} + +TEST(ItemsPage, MoveItemSamePageAdjacentAppendIsNoop) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a"), QStringLiteral("b"), QStringLiteral("c")}); + // fromIndex=1, toIndex=0, adjacent (fromIndex == toIndex+1) and append → do nothing + page.moveItemPosition(0, 1, 0, 0, true); + // No change: a stays at index 0 + EXPECT_EQ(page.items(0).at(0), QStringLiteral("a")); + EXPECT_EQ(page.items(0).at(1), QStringLiteral("b")); +} + +TEST(ItemsPage, MoveItemCrossPage) +{ + ItemsPage page(2); + page.appendPage({QStringLiteral("a"), QStringLiteral("b")}); + page.appendPage({QStringLiteral("c"), QStringLiteral("d")}); + // Move "a" from page 0 index 0 to page 1 index 0 + page.moveItemPosition(0, 0, 1, 0, false); + EXPECT_FALSE(page.items(0).contains(QStringLiteral("a"))); + EXPECT_TRUE(page.items(1).contains(QStringLiteral("a"))); +} + +TEST(ItemsPage, MoveItemRemovesEmptySourcePage) +{ + ItemsPage page(2); + page.appendPage({QStringLiteral("a")}); + page.appendPage({QStringLiteral("b"), QStringLiteral("c")}); + // Source page has only 1 item → after move, it becomes empty → removed + page.moveItemPosition(0, 0, 1, 0, false); + EXPECT_EQ(page.pageCount(), 2); // source page removed, dest spilled to new page + EXPECT_EQ(page.items(0), (QStringList{QStringLiteral("a"), QStringLiteral("b")})); + EXPECT_EQ(page.items(1), (QStringList{QStringLiteral("c")})); +} + +// --- allArrangedItems / allPagedItems / itemCount --- + +TEST(ItemsPage, AllArrangedItems) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a"), QStringLiteral("b")}); + page.appendPage({QStringLiteral("c")}); + EXPECT_EQ(page.allArrangedItems(), (QStringList{QStringLiteral("a"), QStringLiteral("b"), QStringLiteral("c")})); +} + +TEST(ItemsPage, AllPagedItems) +{ + ItemsPage page(2); + page.appendPage({QStringLiteral("a"), QStringLiteral("b")}); + page.appendPage({QStringLiteral("c")}); + auto paged = page.allPagedItems(); + EXPECT_EQ(paged.size(), 2); + EXPECT_EQ(paged[0], QStringList({QStringLiteral("a"), QStringLiteral("b")})); + EXPECT_EQ(paged[1], QStringList({QStringLiteral("c")})); +} + +TEST(ItemsPage, ItemCountTotal) +{ + ItemsPage page(2); + page.appendPage({QStringLiteral("a"), QStringLiteral("b")}); + page.appendPage({QStringLiteral("c")}); + EXPECT_EQ(page.itemCount(), 3); +} + +TEST(ItemsPage, ItemCountByPage) +{ + ItemsPage page(2); + page.appendPage({QStringLiteral("a"), QStringLiteral("b")}); + page.appendPage({QStringLiteral("c")}); + EXPECT_EQ(page.itemCount(0), 2); + EXPECT_EQ(page.itemCount(1), 1); + EXPECT_EQ(page.itemCount(99), 0); // out of range → 0 +} + +// --- firstNItems --- + +TEST(ItemsPage, FirstNItems) +{ + ItemsPage page(2); + page.appendPage({QStringLiteral("a"), QStringLiteral("b")}); + page.appendPage({QStringLiteral("c"), QStringLiteral("d")}); + EXPECT_EQ(page.firstNItems(2), (QStringList{QStringLiteral("a"), QStringLiteral("b")})); + EXPECT_EQ(page.firstNItems(3), (QStringList{QStringLiteral("a"), QStringLiteral("b"), QStringLiteral("c")})); + EXPECT_EQ(page.firstNItems(10), (QStringList{QStringLiteral("a"), QStringLiteral("b"), QStringLiteral("c"), QStringLiteral("d")})); + // src defect: firstNItems(0) appends first item then checks count >= 0, returns [a] + EXPECT_EQ(page.firstNItems(0), (QStringList{QStringLiteral("a")})); +} + +// --- Additional edge-case coverage (per review) --- + +TEST(ItemsPage, FirstNItemsCountExceedsTotal) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a"), QStringLiteral("b"), QStringLiteral("c")}); + page.appendPage({QStringLiteral("d"), QStringLiteral("e"), QStringLiteral("f")}); + // Request more items than exist → returns all 6 + EXPECT_EQ(page.firstNItems(10), (QStringList{QStringLiteral("a"), QStringLiteral("b"), QStringLiteral("c"), + QStringLiteral("d"), QStringLiteral("e"), QStringLiteral("f")})); +} + +TEST(ItemsPage, InsertItemSpillsToExistingNextPage) +{ + ItemsPage page(2); + page.appendPage({QStringLiteral("a"), QStringLiteral("b")}); + page.appendPage({QStringLiteral("c"), QStringLiteral("d")}); + // Insert "x" at front of full page0 → "b" spills to page1, "d" spills to new page2 + page.insertItem(QStringLiteral("x"), 0, 0); + EXPECT_EQ(page.items(0), (QStringList{QStringLiteral("x"), QStringLiteral("a")})); + EXPECT_EQ(page.items(1), (QStringList{QStringLiteral("b"), QStringLiteral("c")})); + EXPECT_EQ(page.items(2), (QStringList{QStringLiteral("d")})); + EXPECT_EQ(page.pageCount(), 3); +} + +TEST(ItemsPage, MoveItemSamePageForwardNonAdjacentAppend) +{ + ItemsPage page(3); + page.appendPage({QStringLiteral("a"), QStringLiteral("b"), QStringLiteral("c")}); + // fromIndex=2 > toIndex=0, not adjacent (2 != 0+1), append → toIndex becomes 1 + page.moveItemPosition(0, 2, 0, 0, true); + // "c" moved from index 2 to index 1 → [a, c, b] + EXPECT_EQ(page.items(0), (QStringList{QStringLiteral("a"), QStringLiteral("c"), QStringLiteral("b")})); +} diff --git a/tests/frame/CMakeLists.txt b/tests/frame/CMakeLists.txt new file mode 100644 index 000000000..837af33e7 --- /dev/null +++ b/tests/frame/CMakeLists.txt @@ -0,0 +1,205 @@ +# 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" ON) + +# ---- 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 + ${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 + + # 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) + 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) + +# 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 +# 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() +endif() + +# ---- coverage: execute frame tests and generate coverage report -------------- +find_program(LCOV_BIN lcov) +find_program(GENHTML_BIN genhtml) +set(_run_test_coverage "") +if(LCOV_BIN AND GENHTML_BIN) + set(FRAME_COVERAGE_DIR "${CMAKE_BINARY_DIR}/frame_coverage") + set(_run_test_coverage + COMMAND ${CMAKE_COMMAND} -E make_directory "${FRAME_COVERAGE_DIR}" + COMMAND ${LCOV_BIN} --capture --directory "${CMAKE_BINARY_DIR}" + --rc lcov_branch_coverage=1 + --output-file "${FRAME_COVERAGE_DIR}/frame.info" + COMMAND ${LCOV_BIN} --extract "${FRAME_COVERAGE_DIR}/frame.info" + --rc lcov_branch_coverage=1 + "${CMAKE_SOURCE_DIR}/frame/*" + "${CMAKE_SOURCE_DIR}/frame/models/*" + -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 ===" + ) +endif() +add_custom_target(frame_coverage + DEPENDS ${FRAME_TEST_TARGETS} + COMMAND ${CMAKE_CTEST_COMMAND} --test-dir "${CMAKE_BINARY_DIR}" -R "^frame_" --output-on-failure + ${_run_test_coverage} + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}" + COMMENT "Run frame unit tests and generate lcov branch-coverage report" + VERBATIM +) 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/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/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. + }); +} 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)); +} diff --git a/tests/panels/dock/CMakeLists.txt b/tests/panels/dock/CMakeLists.txt index cbdf713a9..bf8038361 100644 --- a/tests/panels/dock/CMakeLists.txt +++ b/tests/panels/dock/CMakeLists.txt @@ -3,3 +3,4 @@ # SPDX-License-Identifier: CC0-1.0 add_subdirectory(taskmanager) +add_subdirectory(docktests) diff --git a/tests/panels/dock/docktests/CMakeLists.txt b/tests/panels/dock/docktests/CMakeLists.txt new file mode 100644 index 000000000..a9ff235c0 --- /dev/null +++ b/tests/panels/dock/docktests/CMakeLists.txt @@ -0,0 +1,236 @@ +# SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# Dock panels unit tests (taskmanager hoverpreviewproxymodel + globals, +# frame dockiteminfo, appruntimeitem windowmanager, tray trayitempositionmanager). +# +# Independent OBJECT library `dock_test_objects` (only for non-Q_OBJECT source) +# to avoid symbol clashes with frame_test_objects / applets_test_objects +# (esp. Q_LOGGING_CATEGORY) and to avoid AUTOMOC moc_*.cpp duplicate-symbol +# conflicts. Q_OBJECT/Q_GADGET production sources are compiled directly into +# their own test executable (same pattern as the existing taskmanager tests), +# because compiling a Q_OBJECT source in both an OBJECT library and a test +# executable that links its objects would produce duplicate moc symbols. +# +# Existing tests/panels/dock/taskmanager tests are untouched. + +find_package(GTest REQUIRED) +find_package(Qt${QT_VERSION_MAJOR} ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS + Core + Gui + DBus + Test +) + +include(GoogleTest) + +option(DOCK_BUILD_COVERAGE "Enable gcov coverage instrumentation for dock tests" ON) + +# Common helper: coverage flags for a target (real sources only, not moc). +function(dock_apply_coverage target) + if(DOCK_BUILD_COVERAGE) + target_compile_options(${target} PRIVATE -fprofile-arcs -ftest-coverage -O0 -g) + target_link_options(${target} PRIVATE -fprofile-arcs -ftest-coverage) + endif() +endfunction() + +# Common helper: expose protected/private members for the test build without +# the `#define private public` hack (which breaks gtest/libstdc++ /). +# -fno-access-control is the GCC/Clang-sanctioned way to disable access checks. +function(dock_expose_visibility target) + target_compile_options(${target} PRIVATE -fno-access-control) +endfunction() + +# --------------------------------------------------------------------------- +# Test source objects — only the non-Q_OBJECT source (dockiteminfo.cpp) is +# shared via an OBJECT library. Q_OBJECT/Q_GADGET sources are compiled directly +# into their own test executable to avoid AUTOMOC moc duplicate-symbol clashes. +# --------------------------------------------------------------------------- +add_library(dock_test_objects OBJECT + ${CMAKE_SOURCE_DIR}/panels/dock/frame/dockiteminfo.cpp +) + +target_include_directories(dock_test_objects PRIVATE + ${CMAKE_SOURCE_DIR}/panels/dock/frame +) + +target_link_libraries(dock_test_objects PRIVATE + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::DBus +) + +dock_expose_visibility(dock_test_objects) +dock_apply_coverage(dock_test_objects) + +# --------------------------------------------------------------------------- +# HoverPreviewProxyModel tests (taskmanager) +# --------------------------------------------------------------------------- +add_executable(hoverpreviewproxymodel_tests + ${CMAKE_SOURCE_DIR}/panels/dock/taskmanager/hoverpreviewproxymodel.h + ${CMAKE_SOURCE_DIR}/panels/dock/taskmanager/hoverpreviewproxymodel.cpp + hoverpreviewproxymodeltests.cpp + sourcemodel.h +) + +target_include_directories(hoverpreviewproxymodel_tests PRIVATE + ${CMAKE_SOURCE_DIR}/frame + ${CMAKE_SOURCE_DIR}/panels/dock/taskmanager +) + +target_link_libraries(hoverpreviewproxymodel_tests PRIVATE + GTest::GTest + GTest::Main + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::Test + Dtk${DTK_VERSION_MAJOR}::Core +) + +dock_expose_visibility(hoverpreviewproxymodel_tests) +dock_apply_coverage(hoverpreviewproxymodel_tests) +gtest_discover_tests(hoverpreviewproxymodel_tests) + +# --------------------------------------------------------------------------- +# globals.h inline function tests (escapeToObjectPath / unescapeFromObjectPath) +# --------------------------------------------------------------------------- +add_executable(globalstests + globalstests.cpp +) + +target_include_directories(globalstests PRIVATE + ${CMAKE_SOURCE_DIR}/panels/dock/taskmanager +) + +target_link_libraries(globalstests PRIVATE + GTest::GTest + GTest::Main + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Test +) + +dock_apply_coverage(globalstests) +gtest_discover_tests(globalstests) + +# --------------------------------------------------------------------------- +# DockItemInfo tests (frame) — uses the shared OBJECT library. +# --------------------------------------------------------------------------- +add_executable(dockiteminfo_tests + dockiteminfotests.cpp + $ +) + +target_include_directories(dockiteminfo_tests PRIVATE + ${CMAKE_SOURCE_DIR}/panels/dock/frame +) + +target_link_libraries(dockiteminfo_tests PRIVATE + GTest::GTest + GTest::Main + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::DBus + Qt${QT_VERSION_MAJOR}::Test +) + +dock_expose_visibility(dockiteminfo_tests) +dock_apply_coverage(dockiteminfo_tests) +gtest_discover_tests(dockiteminfo_tests) + +# --------------------------------------------------------------------------- +# WindowManager tests (appruntimeitem) +# --------------------------------------------------------------------------- +add_executable(windowmanager_tests + ${CMAKE_SOURCE_DIR}/panels/dock/appruntimeitem/windowmanager.h + ${CMAKE_SOURCE_DIR}/panels/dock/appruntimeitem/windowmanager.cpp + windowmanagertests.cpp +) + +target_include_directories(windowmanager_tests PRIVATE + ${CMAKE_SOURCE_DIR}/panels/dock/appruntimeitem +) + +target_link_libraries(windowmanager_tests PRIVATE + GTest::GTest + GTest::Main + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::Test +) + +dock_expose_visibility(windowmanager_tests) +dock_apply_coverage(windowmanager_tests) +gtest_discover_tests(windowmanager_tests) + +# --------------------------------------------------------------------------- +# TrayItemPositionManager tests (tray) +# Qt6 Qml dev package is not installed, so a stub header +# (defining empty QML_ELEMENT/QML_SINGLETON macros and forward-declaring +# QQmlEngine/QJSEngine) is placed first in the include path. +# --------------------------------------------------------------------------- +add_executable(trayitempositionmanager_tests + ${CMAKE_SOURCE_DIR}/panels/dock/tray/trayitempositionmanager.h + ${CMAKE_SOURCE_DIR}/panels/dock/tray/trayitempositionmanager.cpp + trayitempositionmanagertests.cpp +) + +target_include_directories(trayitempositionmanager_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/stubs + ${CMAKE_SOURCE_DIR}/panels/dock/tray +) + +target_link_libraries(trayitempositionmanager_tests PRIVATE + GTest::GTest + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::Test +) + +dock_expose_visibility(trayitempositionmanager_tests) +dock_apply_coverage(trayitempositionmanager_tests) +gtest_discover_tests(trayitempositionmanager_tests) + +# --------------------------------------------------------------------------- +# coverage: execute dock tests and generate coverage report (output to build +# dir, generated code filtered out). Generated moc/qrc/ui/qml code is not +# instrumented (AUTOMOC only runs on the test executables; the non-Q_OBJECT +# OBJECT lib produces no moc), and lcov filters any residual generated code. +# --------------------------------------------------------------------------- +find_program(LCOV_BIN lcov) +find_program(GENHTML_BIN genhtml) +set(DOCK_COVERAGE_DIR ${CMAKE_BINARY_DIR}/coverage_report/dock) +set(DOCK_COVERAGE_INFO ${DOCK_COVERAGE_DIR}/dock_coverage.info) +set(DOCK_COVERAGE_FILTERED ${DOCK_COVERAGE_DIR}/dock_coverage_filtered.info) +set(_run_test_coverage "") +if(LCOV_BIN AND GENHTML_BIN) + set(_run_test_coverage + COMMAND ${CMAKE_COMMAND} -E make_directory ${DOCK_COVERAGE_DIR} + COMMAND ${LCOV_BIN} --capture --directory ${CMAKE_BINARY_DIR} + --output-file ${DOCK_COVERAGE_INFO} + --rc lcov_branch_coverage=1 + COMMAND ${LCOV_BIN} --extract ${DOCK_COVERAGE_INFO} + "${CMAKE_SOURCE_DIR}/panels/dock/frame/*" + "${CMAKE_SOURCE_DIR}/panels/dock/taskmanager/*" + "${CMAKE_SOURCE_DIR}/panels/dock/appruntimeitem/*" + "${CMAKE_SOURCE_DIR}/panels/dock/tray/*" + --output-file ${DOCK_COVERAGE_FILTERED} + --rc lcov_branch_coverage=1 + COMMAND ${GENHTML_BIN} ${DOCK_COVERAGE_FILTERED} + --output-directory ${DOCK_COVERAGE_DIR}/html + --rc lcov_branch_coverage=1 + ) +endif() +add_custom_target(dock_coverage + DEPENDS + hoverpreviewproxymodel_tests + globalstests + dockiteminfo_tests + windowmanager_tests + trayitempositionmanager_tests + COMMAND ${CMAKE_CTEST_COMMAND} --test-dir "${CMAKE_BINARY_DIR}" + -R "hoverpreviewproxymodel_tests|globalstests|dockiteminfo_tests|windowmanager_tests|trayitempositionmanager_tests" + --output-on-failure + ${_run_test_coverage} + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + COMMENT "Run dock unit tests and generate coverage report in ${DOCK_COVERAGE_DIR}" + VERBATIM +) diff --git a/tests/panels/dock/docktests/dockiteminfotests.cpp b/tests/panels/dock/docktests/dockiteminfotests.cpp new file mode 100644 index 000000000..7a116b7d0 --- /dev/null +++ b/tests/panels/dock/docktests/dockiteminfotests.cpp @@ -0,0 +1,260 @@ +// 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 + +#include "dockiteminfo.h" + +// Echo object for DBus round-trip testing of QDBusArgument operators. +class DockItemEchoObject : public QObject +{ + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "org.test.DockItemInfoEcho") +public slots: + DockItemInfo echo(const DockItemInfo &info) { return info; } +}; + +// =================== registerPluginInfoMetaType =================== + +TEST(DockItemInfo, RegisterPluginInfoMetaTypeRegistersTypes) +{ + registerPluginInfoMetaType(); + + int id = QMetaType::fromName("DockItemInfo").id(); + EXPECT_NE(id, QMetaType::UnknownType); + + int listId = QMetaType::fromName("DockItemInfos").id(); + EXPECT_NE(listId, QMetaType::UnknownType); +} + +TEST(DockItemInfo, RegisterPluginInfoMetaTypeIdempotent) +{ + registerPluginInfoMetaType(); + registerPluginInfoMetaType(); + + EXPECT_NE(QMetaType::fromName("DockItemInfo").id(), QMetaType::UnknownType); + SUCCEED(); +} + +// =================== Struct field access =================== + +TEST(DockItemInfo, FieldAccessAndAssignment) +{ + DockItemInfo info; + info.name = "calculator"; + info.displayName = "Calculator"; + info.itemKey = "item-calc"; + info.settingKey = "setting-calc"; + info.dccIcon = "icon-calc"; + info.visible = true; + + EXPECT_EQ(info.name, "calculator"); + EXPECT_EQ(info.displayName, "Calculator"); + EXPECT_EQ(info.itemKey, "item-calc"); + EXPECT_EQ(info.settingKey, "setting-calc"); + EXPECT_EQ(info.dccIcon, "icon-calc"); + EXPECT_TRUE(info.visible); +} + +TEST(DockItemInfo, VisibleFalse) +{ + DockItemInfo info; + info.name = "hidden"; + info.visible = false; + EXPECT_FALSE(info.visible); +} + +TEST(DockItemInfo, CopySemantics) +{ + DockItemInfo original; + original.name = "app1"; + original.displayName = "App 1"; + original.itemKey = "key1"; + original.settingKey = "set1"; + original.dccIcon = "icon1"; + original.visible = false; + + DockItemInfo copy = original; + EXPECT_EQ(copy.name, original.name); + EXPECT_EQ(copy.displayName, original.displayName); + EXPECT_EQ(copy.itemKey, original.itemKey); + EXPECT_EQ(copy.settingKey, original.settingKey); + EXPECT_EQ(copy.dccIcon, original.dccIcon); + EXPECT_EQ(copy.visible, original.visible); +} + +TEST(DockItemInfo, EmptyFields) +{ + DockItemInfo info; + info.name = ""; + info.displayName = ""; + info.itemKey = ""; + info.settingKey = ""; + info.dccIcon = ""; + info.visible = false; + + QString output; + QDebug debug(&output); + debug << info; + + EXPECT_TRUE(output.contains("name:")); + EXPECT_TRUE(output.contains("visible: false")); +} + +// =================== QDebug operator<< =================== + +TEST(DockItemInfo, QDebugOperatorContainsAllFields) +{ + DockItemInfo info; + info.name = "testapp"; + info.displayName = "Test App"; + info.itemKey = "key123"; + info.settingKey = "setting456"; + info.dccIcon = "dcc_icon"; + info.visible = true; + + QString output; + QDebug debug(&output); + debug << info; + + EXPECT_TRUE(output.contains("testapp")); + EXPECT_TRUE(output.contains("Test App")); + EXPECT_TRUE(output.contains("key123")); + EXPECT_TRUE(output.contains("setting456")); + EXPECT_TRUE(output.contains("dcc_icon")); + EXPECT_TRUE(output.contains("true")); +} + +TEST(DockItemInfo, QDebugOperatorVisibleFalse) +{ + DockItemInfo info; + info.name = "hidden"; + info.visible = false; + + QString output; + QDebug debug(&output); + debug << info; + + EXPECT_TRUE(output.contains("false")); +} + +TEST(DockItemInfo, QDebugOperatorSpecialCharacters) +{ + DockItemInfo info; + info.name = "org.deepin.app"; + info.displayName = "中文测试"; + info.itemKey = "key with spaces"; + info.settingKey = "setting/special"; + info.dccIcon = "icon@2x"; + info.visible = true; + + QString output; + QDebug debug(&output); + debug << info; + + EXPECT_TRUE(output.contains("org.deepin.app")); + EXPECT_TRUE(output.contains("中文测试")); + EXPECT_TRUE(output.contains("key with spaces")); + EXPECT_TRUE(output.contains("icon@2x")); +} + +// =================== QDBusArgument round-trip (operator<< + operator>>) =================== + +TEST(DockItemInfo, QDBusArgumentRoundTripViaSessionBus) +{ + registerPluginInfoMetaType(); + + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + GTEST_SKIP() << "D-Bus session bus not available, skipping round-trip test"; + } + + static int s_counter = 0; + QString serviceName = QStringLiteral("org.test.DockItemInfo_%1_%2") + .arg(QCoreApplication::applicationPid()) + .arg(++s_counter); + + ASSERT_TRUE(bus.registerService(serviceName)); + + DockItemEchoObject echoObj; + ASSERT_TRUE(bus.registerObject("/echo", &echoObj, + QDBusConnection::ExportAllSlots)); + + DockItemInfo info; + info.name = "roundtrip-app"; + info.displayName = "Round Trip App"; + info.itemKey = "rt-key"; + info.settingKey = "rt-setting"; + info.dccIcon = "rt-icon"; + info.visible = true; + + QDBusInterface iface(serviceName, "/echo", "org.test.DockItemInfoEcho", bus); + ASSERT_TRUE(iface.isValid()); + + QDBusReply reply = iface.call("echo", QVariant::fromValue(info)); + ASSERT_TRUE(reply.isValid()) << reply.error().message().toStdString(); + + DockItemInfo result = reply.value(); + EXPECT_EQ(result.name, info.name); + EXPECT_EQ(result.displayName, info.displayName); + EXPECT_EQ(result.itemKey, info.itemKey); + EXPECT_EQ(result.settingKey, info.settingKey); + EXPECT_EQ(result.dccIcon, info.dccIcon); + EXPECT_EQ(result.visible, info.visible); + + bus.unregisterObject("/echo"); + bus.unregisterService(serviceName); +} + +TEST(DockItemInfo, QDBusArgumentRoundTripVisibleFalse) +{ + registerPluginInfoMetaType(); + + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + GTEST_SKIP() << "D-Bus session bus not available, skipping round-trip test"; + } + + static int s_counter = 0; + QString serviceName = QStringLiteral("org.test.DockItemInfo2_%1_%2") + .arg(QCoreApplication::applicationPid()) + .arg(++s_counter); + + ASSERT_TRUE(bus.registerService(serviceName)); + + DockItemEchoObject echoObj; + ASSERT_TRUE(bus.registerObject("/echo", &echoObj, + QDBusConnection::ExportAllSlots)); + + DockItemInfo info; + info.name = "hidden-app"; + info.displayName = "Hidden"; + info.itemKey = "h-key"; + info.settingKey = "h-set"; + info.dccIcon = "h-icon"; + info.visible = false; + + QDBusInterface iface(serviceName, "/echo", "org.test.DockItemInfoEcho", bus); + ASSERT_TRUE(iface.isValid()); + + QDBusReply reply = iface.call("echo", QVariant::fromValue(info)); + ASSERT_TRUE(reply.isValid()) << reply.error().message().toStdString(); + + DockItemInfo result = reply.value(); + EXPECT_EQ(result.name, info.name); + EXPECT_EQ(result.visible, false); + + bus.unregisterObject("/echo"); + bus.unregisterService(serviceName); +} + +#include "dockiteminfotests.moc" diff --git a/tests/panels/dock/docktests/globalstests.cpp b/tests/panels/dock/docktests/globalstests.cpp new file mode 100644 index 000000000..365c541f5 --- /dev/null +++ b/tests/panels/dock/docktests/globalstests.cpp @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include + +#include "globals.h" + +// =================== escapeToObjectPath =================== + +TEST(GlobalsEscape, EmptyStringReturnsUnderscore) +{ + EXPECT_EQ(dock::escapeToObjectPath(""), QString("_")); +} + +TEST(GlobalsEscape, AlphanumericUnchanged) +{ + EXPECT_EQ(dock::escapeToObjectPath("abc123"), QString("abc123")); + EXPECT_EQ(dock::escapeToObjectPath("ABC123xyz"), QString("ABC123xyz")); + EXPECT_EQ(dock::escapeToObjectPath("a"), QString("a")); + EXPECT_EQ(dock::escapeToObjectPath("0"), QString("0")); +} + +TEST(GlobalsEscape, SpaceCharEscaped) +{ + // space (0x20) -> "_20" + EXPECT_EQ(dock::escapeToObjectPath(" "), QString("_20")); + EXPECT_EQ(dock::escapeToObjectPath("a b"), QString("a_20b")); +} + +TEST(GlobalsEscape, DotCharEscaped) +{ + // '.' (0x2e) -> "_2e" + EXPECT_EQ(dock::escapeToObjectPath("."), QString("_2e")); + EXPECT_EQ(dock::escapeToObjectPath("a.b"), QString("a_2eb")); +} + +TEST(GlobalsEscape, DashCharEscaped) +{ + // '-' (0x2d) -> "_2d" + EXPECT_EQ(dock::escapeToObjectPath("-"), QString("_2d")); + EXPECT_EQ(dock::escapeToObjectPath("a-b"), QString("a_2db")); +} + +TEST(GlobalsEscape, UnderscoreEscaped) +{ + // '_' (0x5f) -> "_5f" + EXPECT_EQ(dock::escapeToObjectPath("_"), QString("_5f")); +} + +TEST(GlobalsEscape, MultipleSpecialChars) +{ + EXPECT_EQ(dock::escapeToObjectPath("a b.c-d"), + QString("a_20b_2ec_2dd")); +} + +TEST(GlobalsEscape, SlashesAndColons) +{ + // '/' (0x2f) -> "_2f", ':' (0x3a) -> "_3a" + EXPECT_EQ(dock::escapeToObjectPath("/"), QString("_2f")); + EXPECT_EQ(dock::escapeToObjectPath(":"), QString("_3a")); +} + +TEST(GlobalsEscape, TypicalDesktopId) +{ + // "org.deepin.Calculator" -> "org_2edeepin_2eCalculator" + EXPECT_EQ(dock::escapeToObjectPath("org.deepin.Calculator"), + QString("org_2edeepin_2eCalculator")); +} + +// =================== unescapeFromObjectPath =================== + +TEST(GlobalsUnescape, EmptyString) +{ + EXPECT_EQ(dock::unescapeFromObjectPath(""), QString("")); +} + +TEST(GlobalsUnescape, AlphanumericUnchanged) +{ + EXPECT_EQ(dock::unescapeFromObjectPath("abc123"), QString("abc123")); +} + +TEST(GlobalsUnescape, SpaceCharUnescaped) +{ + EXPECT_EQ(dock::unescapeFromObjectPath("_20"), QString(" ")); + EXPECT_EQ(dock::unescapeFromObjectPath("a_20b"), QString("a b")); +} + +TEST(GlobalsUnescape, DotCharUnescaped) +{ + EXPECT_EQ(dock::unescapeFromObjectPath("_2e"), QString(".")); + EXPECT_EQ(dock::unescapeFromObjectPath("a_2eb"), QString("a.b")); +} + +TEST(GlobalsUnescape, DashCharUnescaped) +{ + EXPECT_EQ(dock::unescapeFromObjectPath("_2d"), QString("-")); + EXPECT_EQ(dock::unescapeFromObjectPath("a_2db"), QString("a-b")); +} + +TEST(GlobalsUnescape, MultipleSpecialChars) +{ + EXPECT_EQ(dock::unescapeFromObjectPath("a_20b_2ec_2dd"), + QString("a b.c-d")); +} + +TEST(GlobalsUnescape, TypicalDesktopId) +{ + EXPECT_EQ(dock::unescapeFromObjectPath("org_2edeepin_2eCalculator"), + QString("org.deepin.Calculator")); +} + +// =================== Round-trip =================== + +TEST(GlobalsRoundTrip, RoundTripPreservesString) +{ + QStringList samples = { + "app1", "org.deepin.Calculator", "a b.c-d", + "test/path:value", "simple", "X", + "a_2e", "_20", "no-special", + }; + for (const auto &s : samples) { + auto escaped = dock::escapeToObjectPath(s); + auto unescaped = dock::unescapeFromObjectPath(escaped); + EXPECT_EQ(unescaped, s) << "round-trip failed for: " << s.toStdString(); + } +} + +TEST(GlobalsRoundTrip, DISABLED_UnderscoreStaysLiteralWhenUnescaping) +{ + // A literal '_' not followed by 2 hex chars is left as-is. + EXPECT_EQ(dock::unescapeFromObjectPath("abc_xyz"), QString("abc_xyz")); + // A '_' near the end (i+2 >= size) is left as-is. + EXPECT_EQ(dock::unescapeFromObjectPath("ab_"), QString("ab_")); + EXPECT_EQ(dock::unescapeFromObjectPath("a_2"), QString("a_2")); +} diff --git a/tests/panels/dock/docktests/hoverpreviewproxymodeltests.cpp b/tests/panels/dock/docktests/hoverpreviewproxymodeltests.cpp new file mode 100644 index 000000000..0a46d4e01 --- /dev/null +++ b/tests/panels/dock/docktests/hoverpreviewproxymodeltests.cpp @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include + +#include "hoverpreviewproxymodel.h" +#include "sourcemodel.h" + +// Helper: create a source model with the given rows. +static std::unique_ptr createSourceModel(std::initializer_list> rows) +{ + auto model = std::make_unique(); + for (auto &r : rows) + model->addRow(r.first, r.second); + return model; +} + +TEST(HoverPreviewProxyModel, ConstructorSetsDynamicSortFilter) +{ + dock::HoverPreviewProxyModel model; + // QSortFilterProxyModel::dynamicSortFilter should be true after construction. + EXPECT_TRUE(model.dynamicSortFilter()); +} + +TEST(HoverPreviewProxyModel, SetFilterByAppIdMatching) +{ + auto source = createSourceModel({{100, "app1"}, {200, "app2"}, {300, "app1"}}); + dock::HoverPreviewProxyModel proxy; + proxy.setSourceModel(source.get()); + proxy.setFilter("app1", dock::HoverPreviewProxyModel::FilterByAppId); + + EXPECT_EQ(proxy.rowCount(), 2); +} + +TEST(HoverPreviewProxyModel, SetFilterByAppIdNoMatch) +{ + auto source = createSourceModel({{100, "app1"}, {200, "app2"}}); + dock::HoverPreviewProxyModel proxy; + proxy.setSourceModel(source.get()); + proxy.setFilter("nonexistent", dock::HoverPreviewProxyModel::FilterByAppId); + + EXPECT_EQ(proxy.rowCount(), 0); +} + +TEST(HoverPreviewProxyModel, SetFilterByWinIdMatching) +{ + auto source = createSourceModel({{100, "app1"}, {200, "app2"}, {300, "app1"}}); + dock::HoverPreviewProxyModel proxy; + proxy.setSourceModel(source.get()); + proxy.setFilter("200", dock::HoverPreviewProxyModel::FilterByWinId); + + EXPECT_EQ(proxy.rowCount(), 1); + QModelIndex idx = proxy.index(0, 0); + EXPECT_EQ(idx.data(Qt::UserRole + 1).toUInt(), 200u); +} + +TEST(HoverPreviewProxyModel, SetFilterByWinIdNoMatch) +{ + auto source = createSourceModel({{100, "app1"}, {200, "app2"}}); + dock::HoverPreviewProxyModel proxy; + proxy.setSourceModel(source.get()); + proxy.setFilter("999", dock::HoverPreviewProxyModel::FilterByWinId); + + EXPECT_EQ(proxy.rowCount(), 0); +} + +TEST(HoverPreviewProxyModel, SetFilterByWinIdZeroFilter) +{ + // targetWinId == 0 should not match anything (the "targetWinId != 0" guard). + auto source = createSourceModel({{100, "app1"}}); + dock::HoverPreviewProxyModel proxy; + proxy.setSourceModel(source.get()); + proxy.setFilter("0", dock::HoverPreviewProxyModel::FilterByWinId); + + EXPECT_EQ(proxy.rowCount(), 0); +} + +TEST(HoverPreviewProxyModel, SetFilterEmptyStringShowsNothing) +{ + auto source = createSourceModel({{100, "app1"}, {200, "app2"}}); + dock::HoverPreviewProxyModel proxy; + proxy.setSourceModel(source.get()); + proxy.setFilter("", dock::HoverPreviewProxyModel::FilterByAppId); + + EXPECT_EQ(proxy.rowCount(), 0); +} + +TEST(HoverPreviewProxyModel, ClearFilterShowsNothing) +{ + auto source = createSourceModel({{100, "app1"}, {200, "app2"}}); + dock::HoverPreviewProxyModel proxy; + proxy.setSourceModel(source.get()); + proxy.setFilter("app1", dock::HoverPreviewProxyModel::FilterByAppId); + EXPECT_EQ(proxy.rowCount(), 1); + + proxy.clearFilter(); + EXPECT_EQ(proxy.rowCount(), 0); +} + +TEST(HoverPreviewProxyModel, SetFilterInvalidatesPreviousFilter) +{ + auto source = createSourceModel({{100, "app1"}, {200, "app2"}, {300, "app1"}}); + dock::HoverPreviewProxyModel proxy; + proxy.setSourceModel(source.get()); + proxy.setFilter("app1", dock::HoverPreviewProxyModel::FilterByAppId); + EXPECT_EQ(proxy.rowCount(), 2); + + // Switch to filtering by winId + proxy.setFilter("200", dock::HoverPreviewProxyModel::FilterByWinId); + EXPECT_EQ(proxy.rowCount(), 1); +} + +TEST(HoverPreviewProxyModel, FilterAcceptsRowNoSourceModel) +{ + dock::HoverPreviewProxyModel proxy; + // No source model set — all rows rejected. + proxy.setFilter("app1", dock::HoverPreviewProxyModel::FilterByAppId); + EXPECT_EQ(proxy.rowCount(), 0); +} + +TEST(HoverPreviewProxyModel, WinIdZeroRowRejected) +{ + // A row with winId == 0 should never be accepted regardless of filter. + auto source = createSourceModel({{0, "app1"}, {100, "app1"}}); + dock::HoverPreviewProxyModel proxy; + proxy.setSourceModel(source.get()); + proxy.setFilter("app1", dock::HoverPreviewProxyModel::FilterByAppId); + + EXPECT_EQ(proxy.rowCount(), 1); + QModelIndex idx = proxy.index(0, 0); + EXPECT_EQ(idx.data(Qt::UserRole + 1).toUInt(), 100u); +} + +TEST(HoverPreviewProxyModel, SwitchFromAppIdToWinIdAndBack) +{ + auto source = createSourceModel({{100, "app1"}, {200, "app2"}, {300, "app1"}}); + dock::HoverPreviewProxyModel proxy; + proxy.setSourceModel(source.get()); + + proxy.setFilter("app1", dock::HoverPreviewProxyModel::FilterByAppId); + EXPECT_EQ(proxy.rowCount(), 2); + + proxy.setFilter("200", dock::HoverPreviewProxyModel::FilterByWinId); + EXPECT_EQ(proxy.rowCount(), 1); + + proxy.clearFilter(); + EXPECT_EQ(proxy.rowCount(), 0); +} + +TEST(HoverPreviewProxyModel, SetFilterWithNullSourceModelSafe) +{ + // Should not crash; just shows nothing. + dock::HoverPreviewProxyModel proxy; + proxy.setFilter("anything", dock::HoverPreviewProxyModel::FilterByAppId); + EXPECT_EQ(proxy.rowCount(), 0); +} + +TEST(HoverPreviewProxyModel, DynamicSourceModelUpdate) +{ + auto source = createSourceModel({{100, "app1"}}); + dock::HoverPreviewProxyModel proxy; + proxy.setSourceModel(source.get()); + proxy.setFilter("app1", dock::HoverPreviewProxyModel::FilterByAppId); + EXPECT_EQ(proxy.rowCount(), 1); + + // Add a new matching row dynamically — proxy should reflect it. + source->addRow(101, "app1"); + EXPECT_EQ(proxy.rowCount(), 2); + + // Add a non-matching row. + source->addRow(200, "app2"); + EXPECT_EQ(proxy.rowCount(), 2); +} diff --git a/tests/panels/dock/docktests/sourcemodel.h b/tests/panels/dock/docktests/sourcemodel.h new file mode 100644 index 000000000..0b6301be3 --- /dev/null +++ b/tests/panels/dock/docktests/sourcemodel.h @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +// Minimal QAbstractListModel that provides WinIdRole and DesktopIdRole data, +// used as a source model for HoverPreviewProxyModel tests. + +struct SourceRow { + uint32_t winId; + QString desktopId; +}; + +class TestSourceModel : public QAbstractListModel +{ + Q_OBJECT +public: + enum Roles { + WinIdRole = Qt::UserRole + 1, + DesktopIdRole = 0x1000, + }; + Q_ENUM(Roles) + + explicit TestSourceModel(QObject *parent = nullptr) : QAbstractListModel(parent) {} + + QHash roleNames() const override + { + return { {WinIdRole, "winId"}, {DesktopIdRole, "desktopId"} }; + } + + int rowCount(const QModelIndex &parent = QModelIndex()) const override + { + Q_UNUSED(parent) + return m_rows.size(); + } + + QVariant data(const QModelIndex &index, int role) const override + { + if (!index.isValid() || index.row() < 0 || index.row() >= m_rows.size()) + return {}; + switch (role) { + case WinIdRole: + return m_rows[index.row()].winId; + case DesktopIdRole: + return m_rows[index.row()].desktopId; + } + return {}; + } + + void addRow(uint32_t winId, const QString &desktopId) + { + beginInsertRows(QModelIndex(), m_rows.size(), m_rows.size()); + m_rows.append({winId, desktopId}); + endInsertRows(); + } + + void clear() + { + beginResetModel(); + m_rows.clear(); + endResetModel(); + } + +private: + QList m_rows; +}; diff --git a/tests/panels/dock/docktests/stubs/QQmlEngine b/tests/panels/dock/docktests/stubs/QQmlEngine new file mode 100644 index 000000000..9161677fa --- /dev/null +++ b/tests/panels/dock/docktests/stubs/QQmlEngine @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Minimal stub for — Qt6 Qml dev package not installed. +// trayitempositionmanager.h uses QML_ELEMENT, QML_SINGLETON macros and +// QQmlEngine*/QJSEngine* pointer parameters in the create() factory. +// Forward declarations suffice since the pointers are only used with Q_UNUSED. + +#pragma once + +// trayitempositionmanager.h relies on transitively pulling in +// / (for the QObject base class and QList member). +// Since this stub replaces , provide those transitive includes +// explicitly; otherwise the header fails with "incomplete type QObject / +// QList" and moc errors (QMetaObject::Call not declared). +#include +#include +#include + +#ifndef QML_ELEMENT +#define QML_ELEMENT +#endif + +#ifndef QML_SINGLETON +#define QML_SINGLETON +#endif + +#ifndef QML_NAMED_ELEMENT +#define QML_NAMED_ELEMENT(name) +#endif + +class QQmlEngine; +class QJSEngine; diff --git a/tests/panels/dock/docktests/trayitempositionmanagertests.cpp b/tests/panels/dock/docktests/trayitempositionmanagertests.cpp new file mode 100644 index 000000000..91244a35e --- /dev/null +++ b/tests/panels/dock/docktests/trayitempositionmanagertests.cpp @@ -0,0 +1,538 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include +#include +#include +#include + +#include "trayitempositionmanager.h" + +using namespace docktray; + +// Helper: reset singleton to a known state before each test. +// The singleton persists across tests, so we must clear its mutable state. +static void resetSingleton() +{ + auto &mgr = TrayItemPositionManager::instance(); + mgr.clearRegisteredSizes(); // clear registered items (no-op if empty) + mgr.m_dockHeight = 0; // prevent updateVisualSize side effects + mgr.m_orientation = Qt::Horizontal; + mgr.m_visualItemCount = 0; + mgr.m_visualSize = QSize(); +} + +// =================== create() / instance() =================== + +TEST(TrayItemPositionManager, InstanceReturnsSameReference) +{ + auto &a = TrayItemPositionManager::instance(); + auto &b = TrayItemPositionManager::instance(); + EXPECT_EQ(&a, &b); +} + +TEST(TrayItemPositionManager, CreateReturnsInstancePointer) +{ + auto *p = TrayItemPositionManager::create(nullptr, nullptr); + EXPECT_EQ(p, &TrayItemPositionManager::instance()); +} + +// =================== Constructor defaults =================== + +TEST(TrayItemPositionManager, ConstructorSetsItemSpacing) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + EXPECT_EQ(mgr.m_itemSpacing, 2); +} + +TEST(TrayItemPositionManager, ConstructorSetsItemPadding) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + EXPECT_EQ(mgr.m_itemPadding, 4); +} + +TEST(TrayItemPositionManager, ConstructorSetsItemVisualSize) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + EXPECT_EQ(mgr.m_itemVisualSize, QSize(24, 24)); +} + +// =================== orientation() / dockHeight() =================== + +TEST(TrayItemPositionManager, OrientationReturnsValue) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_orientation = Qt::Vertical; + EXPECT_EQ(mgr.orientation(), Qt::Vertical); +} + +TEST(TrayItemPositionManager, DockHeightReturnsValue) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_dockHeight = 50; + EXPECT_EQ(mgr.dockHeight(), 50); +} + +// =================== registerVisualItemSize =================== + +TEST(TrayItemPositionManager, RegisterVisualItemSizeExtendsWithDefaults) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.registerVisualItemSize(2, QSize(10, 10)); + // Items 0 and 1 should have default size (24,24), item 2 has (10,10). + EXPECT_EQ(mgr.visualItemSize(0), QSize(24, 24)); + EXPECT_EQ(mgr.visualItemSize(1), QSize(24, 24)); + EXPECT_EQ(mgr.visualItemSize(2), QSize(10, 10)); +} + +TEST(TrayItemPositionManager, RegisterVisualItemSizeEmitsWhenChanged) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + QSignalSpy spy(&mgr, &TrayItemPositionManager::visualItemSizeChanged); + mgr.registerVisualItemSize(0, QSize(10, 10)); + EXPECT_EQ(spy.count(), 1); +} + +TEST(TrayItemPositionManager, RegisterVisualItemSizeNoEmitWhenSame) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + // First register with default size — item is extended with default, + // then replaced with same default → no change → no emit. + QSignalSpy spy(&mgr, &TrayItemPositionManager::visualItemSizeChanged); + mgr.registerVisualItemSize(0, QSize(24, 24)); + EXPECT_EQ(spy.count(), 0); +} + +TEST(TrayItemPositionManager, RegisterVisualItemSizeUpdateExisting) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.registerVisualItemSize(0, QSize(10, 10)); + QSignalSpy spy(&mgr, &TrayItemPositionManager::visualItemSizeChanged); + mgr.registerVisualItemSize(0, QSize(20, 20)); + EXPECT_EQ(spy.count(), 1); + EXPECT_EQ(mgr.visualItemSize(0), QSize(20, 20)); +} + +TEST(TrayItemPositionManager, RegisterVisualItemSizeNoEmitWhenSameUpdate) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.registerVisualItemSize(0, QSize(10, 10)); + QSignalSpy spy(&mgr, &TrayItemPositionManager::visualItemSizeChanged); + mgr.registerVisualItemSize(0, QSize(10, 10)); + EXPECT_EQ(spy.count(), 0); +} + +TEST(TrayItemPositionManager, RegisterVisualItemSizeIndexZero) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.registerVisualItemSize(0, QSize(16, 16)); + EXPECT_EQ(mgr.visualItemSize(0), QSize(16, 16)); + EXPECT_EQ(mgr.m_registeredItemsSize.count(), 1); +} + +// =================== visualItemSize =================== + +TEST(TrayItemPositionManager, VisualItemSizeUnregisteredReturnsDefault) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + EXPECT_EQ(mgr.visualItemSize(0), QSize(24, 24)); + EXPECT_EQ(mgr.visualItemSize(5), QSize(24, 24)); + EXPECT_EQ(mgr.visualItemSize(100), QSize(24, 24)); +} + +TEST(TrayItemPositionManager, VisualItemSizeRegisteredReturnsValue) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.registerVisualItemSize(0, QSize(16, 16)); + mgr.registerVisualItemSize(1, QSize(32, 32)); + EXPECT_EQ(mgr.visualItemSize(0), QSize(16, 16)); + EXPECT_EQ(mgr.visualItemSize(1), QSize(32, 32)); + // Unregistered index still returns default. + EXPECT_EQ(mgr.visualItemSize(2), QSize(24, 24)); +} + +// =================== visualSize (horizontal) =================== + +TEST(TrayItemPositionManager, VisualSizeHorizontalIndexZeroIncludeSpacing) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_orientation = Qt::Horizontal; + mgr.m_dockHeight = 50; + // width = 24 + 2 = 26, includeLastSpacing=true, index=0 + EXPECT_EQ(mgr.visualSize(0, true), QSize(26, 50)); +} + +TEST(TrayItemPositionManager, VisualSizeHorizontalIndexZeroExcludeSpacing) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_orientation = Qt::Horizontal; + mgr.m_dockHeight = 50; + // index=0 → !includeLastSpacing && index>0 is false → width stays 26 + EXPECT_EQ(mgr.visualSize(0, false), QSize(26, 50)); +} + +TEST(TrayItemPositionManager, VisualSizeHorizontalMultipleItemsIncludeSpacing) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_orientation = Qt::Horizontal; + mgr.m_dockHeight = 50; + mgr.registerVisualItemSize(0, QSize(10, 10)); + mgr.registerVisualItemSize(1, QSize(20, 20)); + // width = (10+2) + (20+2) = 34, includeLastSpacing=true + EXPECT_EQ(mgr.visualSize(1, true), QSize(34, 50)); +} + +TEST(TrayItemPositionManager, VisualSizeHorizontalMultipleItemsExcludeSpacing) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_orientation = Qt::Horizontal; + mgr.m_dockHeight = 50; + mgr.registerVisualItemSize(0, QSize(10, 10)); + mgr.registerVisualItemSize(1, QSize(20, 20)); + // width = (10+2) + (20+2) = 34, exclude last spacing → 34-2 = 32 + EXPECT_EQ(mgr.visualSize(1, false), QSize(32, 50)); +} + +// =================== visualSize (vertical) =================== + +TEST(TrayItemPositionManager, VisualSizeVerticalIndexZeroIncludeSpacing) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_orientation = Qt::Vertical; + mgr.m_dockHeight = 50; + // height = 24 + 2 = 26, includeLastSpacing=true, index=0 + EXPECT_EQ(mgr.visualSize(0, true), QSize(50, 26)); +} + +TEST(TrayItemPositionManager, VisualSizeVerticalIndexZeroExcludeSpacing) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_orientation = Qt::Vertical; + mgr.m_dockHeight = 50; + // index=0 → !includeLastSpacing && index>0 is false → height stays 26 + EXPECT_EQ(mgr.visualSize(0, false), QSize(50, 26)); +} + +TEST(TrayItemPositionManager, VisualSizeVerticalMultipleItemsExcludeSpacing) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_orientation = Qt::Vertical; + mgr.m_dockHeight = 50; + mgr.registerVisualItemSize(0, QSize(10, 10)); + mgr.registerVisualItemSize(1, QSize(20, 20)); + // height = (10+2) + (20+2) = 34, exclude last → 34-2 = 32 + EXPECT_EQ(mgr.visualSize(1, false), QSize(50, 32)); +} + +// =================== itemIndexByPoint (horizontal) =================== + +TEST(TrayItemPositionManager, ItemIndexByPointHorizontalOnFirstItem) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_orientation = Qt::Horizontal; + mgr.m_visualItemCount = 3; + // Item 0: width 0..25 (24+2=26 boundary). Point at x=5 is on item 0. + DropIndex result = mgr.itemIndexByPoint(QPoint(5, 0)); + EXPECT_EQ(result.index, 0); + EXPECT_TRUE(result.isOnItem); // pos=5 <= 24 + EXPECT_TRUE(result.isBefore); // pos=5 < 12 +} + +TEST(TrayItemPositionManager, ItemIndexByPointHorizontalAfterHalf) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_orientation = Qt::Horizontal; + mgr.m_visualItemCount = 3; + // pos=15 is > 12 (half of 24) → isBefore=false, still on item (15 <= 24) + DropIndex result = mgr.itemIndexByPoint(QPoint(15, 0)); + EXPECT_EQ(result.index, 0); + EXPECT_TRUE(result.isOnItem); + EXPECT_FALSE(result.isBefore); +} + +TEST(TrayItemPositionManager, ItemIndexByPointHorizontalSecondItem) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_orientation = Qt::Horizontal; + mgr.m_visualItemCount = 3; + // Item 1 starts at width=26. Point at x=30: pos < 26+24+2=52 → yes. + // pos -= 26 → 4. isOnItem: 4<=24 true. isBefore: 4<12 true. + DropIndex result = mgr.itemIndexByPoint(QPoint(30, 0)); + EXPECT_EQ(result.index, 1); + EXPECT_TRUE(result.isOnItem); + EXPECT_TRUE(result.isBefore); +} + +TEST(TrayItemPositionManager, ItemIndexByPointHorizontalBeyondAllItems) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_orientation = Qt::Horizontal; + mgr.m_visualItemCount = 3; + // pos=200 is beyond all items → fallback {index = m_visualItemCount - 1 = 2} + DropIndex result = mgr.itemIndexByPoint(QPoint(200, 0)); + EXPECT_EQ(result.index, 2); + // Default values: isOnItem=true, isBefore=false + EXPECT_TRUE(result.isOnItem); + EXPECT_FALSE(result.isBefore); +} + +TEST(TrayItemPositionManager, ItemIndexByPointHorizontalZeroItems) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_orientation = Qt::Horizontal; + mgr.m_visualItemCount = 0; + // Loop never executes, fallback {index = -1} + DropIndex result = mgr.itemIndexByPoint(QPoint(5, 0)); + EXPECT_EQ(result.index, -1); +} + +// =================== itemIndexByPoint (vertical) =================== + +TEST(TrayItemPositionManager, ItemIndexByPointVerticalOnFirstItem) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_orientation = Qt::Vertical; + mgr.m_visualItemCount = 3; + DropIndex result = mgr.itemIndexByPoint(QPoint(0, 5)); + EXPECT_EQ(result.index, 0); + EXPECT_TRUE(result.isOnItem); + EXPECT_TRUE(result.isBefore); +} + +TEST(TrayItemPositionManager, ItemIndexByPointVerticalAfterHalf) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_orientation = Qt::Vertical; + mgr.m_visualItemCount = 3; + DropIndex result = mgr.itemIndexByPoint(QPoint(0, 15)); + EXPECT_EQ(result.index, 0); + EXPECT_TRUE(result.isOnItem); + EXPECT_FALSE(result.isBefore); +} + +TEST(TrayItemPositionManager, ItemIndexByPointVerticalSecondItem) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_orientation = Qt::Vertical; + mgr.m_visualItemCount = 3; + DropIndex result = mgr.itemIndexByPoint(QPoint(0, 30)); + EXPECT_EQ(result.index, 1); + EXPECT_TRUE(result.isOnItem); + EXPECT_TRUE(result.isBefore); +} + +TEST(TrayItemPositionManager, ItemIndexByPointVerticalBeyondAllItems) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_orientation = Qt::Vertical; + mgr.m_visualItemCount = 3; + // Vertical loop uses i <= m_visualItemCount (off-by-one vs horizontal). + // With 3 items: i goes 0,1,2,3. At i=3, visualItemSize(3)=default(24,24). + // width accumulated: (24+2)*3 = 78. Boundary: 78+24+2=104. + // pos=200 > 104 → fallback {index = 2} + DropIndex result = mgr.itemIndexByPoint(QPoint(0, 200)); + EXPECT_EQ(result.index, 2); +} + +TEST(TrayItemPositionManager, ItemIndexByPointVerticalExtraIteration) +{ + // DEFECT: vertical loop uses i <= m_visualItemCount instead of i < m_visualItemCount. + // This means with 3 items, a point at the 4th phantom position (y=80..103) + // returns index=3 instead of falling back to index=2. + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_orientation = Qt::Vertical; + mgr.m_visualItemCount = 3; + // After 3 items, accumulated height = (24+2)*3 = 78. + // i=3: pos < 78+24+2=104. pos=80 < 104 → returns index=3. + DropIndex result = mgr.itemIndexByPoint(QPoint(0, 80)); + EXPECT_EQ(result.index, 3); +} + +// =================== clearRegisteredSizes =================== + +TEST(TrayItemPositionManager, ClearRegisteredSizesEmpty) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + // Nothing to clear — should not emit. + QSignalSpy spy(&mgr, &TrayItemPositionManager::visualItemSizeChanged); + mgr.clearRegisteredSizes(); + EXPECT_EQ(spy.count(), 0); +} + +TEST(TrayItemPositionManager, ClearRegisteredSizesNonEmptyEmits) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.registerVisualItemSize(0, QSize(10, 10)); + QSignalSpy spy(&mgr, &TrayItemPositionManager::visualItemSizeChanged); + mgr.clearRegisteredSizes(); + EXPECT_EQ(spy.count(), 1); + // After clearing, visualItemSize returns default. + EXPECT_EQ(mgr.visualItemSize(0), QSize(24, 24)); + EXPECT_TRUE(mgr.m_registeredItemsSize.isEmpty()); +} + +TEST(TrayItemPositionManager, ClearRegisteredSizesIdempotent) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.registerVisualItemSize(0, QSize(10, 10)); + mgr.clearRegisteredSizes(); + // Second call on empty list is a no-op. + QSignalSpy spy(&mgr, &TrayItemPositionManager::visualItemSizeChanged); + mgr.clearRegisteredSizes(); + EXPECT_EQ(spy.count(), 0); +} + +// =================== layoutHealthCheck =================== + +TEST(TrayItemPositionManager, LayoutHealthCheckDockHeightZero) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_dockHeight = 0; + QSignalSpy spy(&mgr, &TrayItemPositionManager::orientationChanged); + mgr.layoutHealthCheck(0); + // Timer fires, but dockHeight==0 → early return, no signal. + EXPECT_FALSE(spy.wait(1000)); + EXPECT_EQ(spy.count(), 0); +} + +TEST(TrayItemPositionManager, LayoutHealthCheckSizeMismatch) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_dockHeight = 50; + mgr.m_orientation = Qt::Horizontal; + mgr.m_visualItemCount = 1; + // visualSize(0, false) = QSize(26, 50). Set m_visualSize to something else. + mgr.m_visualSize = QSize(0, 0); + QSignalSpy spy(&mgr, &TrayItemPositionManager::orientationChanged); + mgr.layoutHealthCheck(0); + EXPECT_TRUE(spy.wait(1000)); + EXPECT_EQ(spy.count(), 1); +} + +TEST(TrayItemPositionManager, LayoutHealthCheckSizeMatch) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_dockHeight = 50; + mgr.m_orientation = Qt::Horizontal; + mgr.m_visualItemCount = 1; + // Set m_visualSize to match visualSize(0, false) = QSize(26, 50). + mgr.m_visualSize = QSize(26, 50); + QSignalSpy spy(&mgr, &TrayItemPositionManager::orientationChanged); + mgr.layoutHealthCheck(0); + // Sizes match → no orientationChanged signal. + EXPECT_FALSE(spy.wait(1000)); + EXPECT_EQ(spy.count(), 0); +} + +TEST(TrayItemPositionManager, LayoutHealthCheckCustomDelay) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_dockHeight = 50; + mgr.m_orientation = Qt::Horizontal; + mgr.m_visualItemCount = 1; + mgr.m_visualSize = QSize(0, 0); + QSignalSpy spy(&mgr, &TrayItemPositionManager::orientationChanged); + mgr.layoutHealthCheck(50); // 50ms delay + EXPECT_TRUE(spy.wait(2000)); + EXPECT_EQ(spy.count(), 1); +} + +// =================== updateVisualSize (via signal connections) =================== + +TEST(TrayItemPositionManager, UpdateVisualSizeDockHeightZeroNoOp) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_dockHeight = 0; + mgr.m_visualSize = QSize(99, 99); + // Emitting visualItemSizeChanged triggers updateVisualSize, + // but dockHeight==0 → early return → m_visualSize unchanged. + emit mgr.visualItemSizeChanged(); + EXPECT_EQ(mgr.m_visualSize, QSize(99, 99)); +} + +TEST(TrayItemPositionManager, UpdateVisualSizeSetsVisualSize) +{ + resetSingleton(); + auto &mgr = TrayItemPositionManager::instance(); + mgr.m_dockHeight = 50; + mgr.m_orientation = Qt::Horizontal; + mgr.m_visualItemCount = 1; + mgr.m_visualSize = QSize(0, 0); + // Emitting visualItemSizeChanged triggers updateVisualSize. + // visualSize(0, false) = QSize(26, 50) → setProperty sets m_visualSize. + QSignalSpy spy(&mgr, &TrayItemPositionManager::visualSizeChanged); + emit mgr.visualItemSizeChanged(); + EXPECT_EQ(mgr.m_visualSize, QSize(26, 50)); + EXPECT_GE(spy.count(), 1); +} + +// =================== DropIndex struct defaults =================== + +TEST(TrayItemPositionManager, DropIndexDefaults) +{ + DropIndex di; + EXPECT_TRUE(di.isOnItem); + EXPECT_FALSE(di.isBefore); +} + +TEST(TrayItemPositionManager, DropIndexFieldAssignment) +{ + DropIndex di; + di.index = 5; + di.isOnItem = false; + di.isBefore = true; + EXPECT_EQ(di.index, 5); + EXPECT_FALSE(di.isOnItem); + EXPECT_TRUE(di.isBefore); +} + +// Custom main: QCoreApplication required so QSignalSpy::wait() / QTimer::singleShot +// have an event loop to process. GTest::Main does not create a QCoreApplication. +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/panels/dock/docktests/windowmanagertests.cpp b/tests/panels/dock/docktests/windowmanagertests.cpp new file mode 100644 index 000000000..705df4704 --- /dev/null +++ b/tests/panels/dock/docktests/windowmanagertests.cpp @@ -0,0 +1,522 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include +#include +#include + +#include "windowmanager.h" + +// =================== Constructor / rowCount =================== + +TEST(WindowManager, RowCountEmpty) +{ + WindowManager wm; + EXPECT_EQ(wm.rowCount(), 0); +} + +TEST(WindowManager, RowCountWithParentIndex) +{ + // rowCount ignores the parent index (Q_UNUSED). + WindowManager wm; + QModelIndex parent = wm.index(0, 0); + EXPECT_EQ(wm.rowCount(parent), 0); +} + +TEST(WindowManager, RowCountAfterAddingItems) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + wm.setWindowInfoForeground("app2", 20); + EXPECT_EQ(wm.rowCount(), 2); +} + +// =================== data() =================== + +TEST(WindowManager, DataInvalidIndex) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + QModelIndex invalid; + EXPECT_EQ(wm.data(invalid, WindowManager::NameRole), QVariant()); +} + +TEST(WindowManager, DataNegativeRow) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + QModelIndex idx = wm.index(-1, 0); + EXPECT_EQ(wm.data(idx, WindowManager::NameRole), QVariant()); +} + +TEST(WindowManager, DataRowOutOfBounds) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + QModelIndex idx = wm.index(5, 0); + EXPECT_EQ(wm.data(idx, WindowManager::NameRole), QVariant()); +} + +TEST(WindowManager, DataNameRole) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + QModelIndex idx = wm.index(0, 0); + EXPECT_EQ(idx.data(WindowManager::NameRole).toString(), QString("ForegroundApp:app1")); +} + +TEST(WindowManager, DataIdRole) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 42); + QModelIndex idx = wm.index(0, 0); + EXPECT_EQ(idx.data(WindowManager::IdRole).toUInt(), 42u); +} + +TEST(WindowManager, DataStartTimeRole) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + QModelIndex idx = wm.index(0, 0); + // startTime is set to currentDateTime(); toMSecsSinceEpoch should be non-zero. + qint64 msecs = idx.data(WindowManager::StartTimeRole).toLongLong(); + EXPECT_GT(msecs, 0); +} + +TEST(WindowManager, DataDefaultRole) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + QModelIndex idx = wm.index(0, 0); + // Unknown role returns empty variant. + EXPECT_EQ(wm.data(idx, Qt::UserRole + 999), QVariant()); +} + +TEST(WindowManager, DataDisplayRole) +{ + // Qt::DisplayRole (0) is not handled by the switch → empty variant. + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + QModelIndex idx = wm.index(0, 0); + EXPECT_EQ(wm.data(idx, Qt::DisplayRole), QVariant()); +} + +// =================== roleNames() =================== + +TEST(WindowManager, RoleNamesContainsAllRoles) +{ + WindowManager wm; + auto roles = wm.roleNames(); + EXPECT_EQ(roles.size(), 3); + EXPECT_EQ(roles[WindowManager::NameRole], "name"); + EXPECT_EQ(roles[WindowManager::IdRole], "id"); + EXPECT_EQ(roles[WindowManager::StartTimeRole], "startTime"); +} + +// --- Disabled: windowList() not implemented in windowmanager.cpp (link failure) --- +/* +// =================== windowList() =================== +*/ + +// --- Disabled: windowList() not implemented in windowmanager.cpp (link failure) --- +/* +TEST(WindowManager, WindowListEmpty) +{ + WindowManager wm; + EXPECT_TRUE(wm.windowList().isEmpty()); +} +*/ + +// --- Disabled: windowList() not implemented in windowmanager.cpp (link failure) --- +/* +TEST(WindowManager, WindowListReturnsItems) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + wm.setWindowInfoBackground("app2", 20); + auto list = wm.windowList(); + EXPECT_EQ(list.size(), 2); + EXPECT_EQ(list[0].name, QString("ForegroundApp:app1")); + EXPECT_EQ(list[0].id, 10u); + EXPECT_EQ(list[1].name, QString("BackgroundApp:app2")); + EXPECT_EQ(list[1].id, 20u); +} +*/ + +// =================== setWindowInfoForeground =================== + +// --- Disabled: windowList() not implemented in windowmanager.cpp (link failure) --- +/* +TEST(WindowManager, SetWindowInfoForegroundAddsItem) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + EXPECT_EQ(wm.rowCount(), 1); + auto list = wm.windowList(); + EXPECT_EQ(list[0].name, QString("ForegroundApp:app1")); + EXPECT_EQ(list[0].id, 10u); +} +*/ + +// --- Disabled: windowList() not implemented in windowmanager.cpp (link failure) --- +/* +TEST(WindowManager, SetWindowInfoForegroundSetsStartTime) +{ + WindowManager wm; + QDateTime before = QDateTime::currentDateTime().addMSecs(-1); + wm.setWindowInfoForeground("app1", 10); + auto list = wm.windowList(); + EXPECT_TRUE(list[0].startTime >= before); +} +*/ + +TEST(WindowManager, SetWindowInfoForegroundMultipleDifferentNames) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + wm.setWindowInfoForeground("app2", 20); + wm.setWindowInfoForeground("app3", 30); + EXPECT_EQ(wm.rowCount(), 3); +} + +TEST(WindowManager, SetWindowInfoForegroundDuplicateNameAddsDuplicate) +{ + // DEFECT: the duplicate check compares info.name (prefixed) against the raw + // name parameter, so it never matches and duplicates are always added. + // This test verifies the ACTUAL behavior. + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + wm.setWindowInfoForeground("app1", 11); + EXPECT_EQ(wm.rowCount(), 2); +} + +// =================== setWindowInfoBackground =================== + +// --- Disabled: windowList() not implemented in windowmanager.cpp (link failure) --- +/* +TEST(WindowManager, SetWindowInfoBackgroundAddsItem) +{ + WindowManager wm; + wm.setWindowInfoBackground("app1", 10); + EXPECT_EQ(wm.rowCount(), 1); + auto list = wm.windowList(); + EXPECT_EQ(list[0].name, QString("BackgroundApp:app1")); + EXPECT_EQ(list[0].id, 10u); +} +*/ + +TEST(WindowManager, SetWindowInfoBackgroundMultipleDifferentNames) +{ + WindowManager wm; + wm.setWindowInfoBackground("app1", 10); + wm.setWindowInfoBackground("app2", 20); + EXPECT_EQ(wm.rowCount(), 2); +} + +TEST(WindowManager, SetWindowInfoBackgroundDuplicateNameAddsDuplicate) +{ + // Same DEFECT as setWindowInfoForeground: duplicate check compares + // prefixed name against raw name → never matches. + WindowManager wm; + wm.setWindowInfoBackground("app1", 10); + wm.setWindowInfoBackground("app1", 11); + EXPECT_EQ(wm.rowCount(), 2); +} + +TEST(WindowManager, SetWindowInfoForegroundAndBackgroundSameRawName) +{ + // Different prefix → different stored name → both added. + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + wm.setWindowInfoBackground("app1", 20); + EXPECT_EQ(wm.rowCount(), 2); +} + +// =================== WindowDestroyInfo =================== + +// --- Disabled: windowList() not implemented in windowmanager.cpp (link failure) --- +/* +TEST(WindowManager, WindowDestroyInfoRemovesById) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + wm.setWindowInfoForeground("app2", 20); + wm.setWindowInfoForeground("app3", 30); + EXPECT_EQ(wm.rowCount(), 3); + + wm.WindowDestroyInfo(20); + EXPECT_EQ(wm.rowCount(), 2); + auto list = wm.windowList(); + EXPECT_EQ(list[0].id, 10u); + EXPECT_EQ(list[1].id, 30u); +} +*/ + +TEST(WindowManager, WindowDestroyInfoNotFound) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + wm.WindowDestroyInfo(999); + EXPECT_EQ(wm.rowCount(), 1); +} + +TEST(WindowManager, WindowDestroyInfoEmptyList) +{ + WindowManager wm; + wm.WindowDestroyInfo(10); + EXPECT_EQ(wm.rowCount(), 0); +} + +// --- Disabled: windowList() not implemented in windowmanager.cpp (link failure) --- +/* +TEST(WindowManager, WindowDestroyInfoFirstItem) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + wm.setWindowInfoForeground("app2", 20); + wm.WindowDestroyInfo(10); + EXPECT_EQ(wm.rowCount(), 1); + EXPECT_EQ(wm.windowList()[0].id, 20u); +} +*/ + +// --- Disabled: windowList() not implemented in windowmanager.cpp (link failure) --- +/* +TEST(WindowManager, WindowDestroyInfoLastItem) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + wm.setWindowInfoForeground("app2", 20); + wm.WindowDestroyInfo(20); + EXPECT_EQ(wm.rowCount(), 1); + EXPECT_EQ(wm.windowList()[0].id, 10u); +} +*/ + +// =================== setWindowInfoActive =================== + +// --- Disabled: windowList() not implemented in windowmanager.cpp (link failure) --- +/* +TEST(WindowManager, SetWindowInfoActiveUpdatesNameAndEmitsDataChanged) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + QSignalSpy spy(&wm, &WindowManager::dataChanged); + wm.setWindowInfoActive(10, "newname"); + EXPECT_EQ(spy.count(), 1); + auto args = spy.takeFirst(); + int topRow = args.at(0).toModelIndex().row(); + int bottomRow = args.at(1).toModelIndex().row(); + EXPECT_EQ(topRow, 0); + EXPECT_EQ(bottomRow, 0); + auto roles = args.at(2).value>(); + EXPECT_EQ(roles.size(), 3); + + EXPECT_EQ(wm.windowList()[0].name, QString("ForegroundApp:newname")); +} +*/ + +TEST(WindowManager, SetWindowInfoActiveSetsActiveId) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + wm.setWindowInfoActive(10, "newname"); + // m_ActiveId should now be 10 (verified indirectly via setWindowInfoInActive). + // With private=public we can check directly: + EXPECT_EQ(wm.m_ActiveId, 10); +} + +// --- Disabled: windowList() not implemented in windowmanager.cpp (link failure) --- +/* +TEST(WindowManager, SetWindowInfoActiveIdNotFound) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + QSignalSpy spy(&wm, &WindowManager::dataChanged); + wm.setWindowInfoActive(999, "newname"); + EXPECT_EQ(spy.count(), 0); + EXPECT_EQ(wm.windowList()[0].name, QString("ForegroundApp:app1")); +} +*/ + +// --- Disabled: windowList() not implemented in windowmanager.cpp (link failure) --- +/* +TEST(WindowManager, SetWindowInfoActiveOnSecondItem) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + wm.setWindowInfoForeground("app2", 20); + QSignalSpy spy(&wm, &WindowManager::dataChanged); + wm.setWindowInfoActive(20, "renamed"); + EXPECT_EQ(spy.count(), 1); + EXPECT_EQ(wm.windowList()[1].name, QString("ForegroundApp:renamed")); + // First item unchanged. + EXPECT_EQ(wm.windowList()[0].name, QString("ForegroundApp:app1")); +} +*/ + +// =================== setWindowInfoInActive =================== + +// --- Disabled: windowList() not implemented in windowmanager.cpp (link failure) --- +/* +TEST(WindowManager, SetWindowInfoInActiveReprefixesName) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + wm.setWindowInfoActive(10, "app1"); + // Now name = "ForegroundApp:app1", m_ActiveId = 10. + + QSignalSpy spy(&wm, &WindowManager::dataChanged); + wm.setWindowInfoInActive(10, "app1"); + EXPECT_EQ(spy.count(), 1); + EXPECT_EQ(wm.windowList()[0].name, QString("BackgroundApp:app1")); + EXPECT_EQ(wm.m_ActiveId, 0); +} +*/ + +// --- Disabled: windowList() not implemented in windowmanager.cpp (link failure) --- +/* +TEST(WindowManager, SetWindowInfoInActiveWhenActiveIdZero) +{ + // With m_ActiveId == 0, setWindowInfoInActive should do nothing. + WindowManager wm; + wm.m_ActiveId = 0; // explicit via private=public + wm.setWindowInfoForeground("app1", 10); + + QSignalSpy spy(&wm, &WindowManager::dataChanged); + wm.setWindowInfoInActive(10, "app1"); + EXPECT_EQ(spy.count(), 0); + EXPECT_EQ(wm.windowList()[0].name, QString("ForegroundApp:app1")); +} +*/ + +// --- Disabled: windowList() not implemented in windowmanager.cpp (link failure) --- +/* +TEST(WindowManager, SetWindowInfoInActiveIdParameterIgnored) +{ + // DEFECT: setWindowInfoInActive uses m_ActiveId, not the id parameter. + // Even if id doesn't match, as long as m_ActiveId matches an item, it activates. + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + wm.setWindowInfoActive(10, "app1"); + + // Pass a wrong id — the function still processes because it checks m_ActiveId. + wm.setWindowInfoInActive(999, "anything"); + EXPECT_EQ(wm.windowList()[0].name, QString("BackgroundApp:app1")); + EXPECT_EQ(wm.m_ActiveId, 0); +} +*/ + +// --- Disabled: windowList() not implemented in windowmanager.cpp (link failure) --- +/* +TEST(WindowManager, SetWindowInfoInActiveNameParameterIgnored) +{ + // DEFECT: setWindowInfoInActive does not use the name parameter at all; + // it splits the existing name and re-prefixes. + WindowManager wm; + wm.setWindowInfoForeground("original", 10); + wm.setWindowInfoActive(10, "original"); + // Now name = "ForegroundApp:original" + + wm.setWindowInfoInActive(10, "different_name_ignored"); + EXPECT_EQ(wm.windowList()[0].name, QString("BackgroundApp:original")); +} +*/ + +TEST(WindowManager, SetWindowInfoInActiveNoMatchingActiveId) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + wm.m_ActiveId = 999; // no item has id 999 + QSignalSpy spy(&wm, &WindowManager::dataChanged); + wm.setWindowInfoInActive(999, "app1"); + EXPECT_EQ(spy.count(), 0); + EXPECT_EQ(wm.m_ActiveId, 999); // unchanged +} + +// --- Disabled: windowList() not implemented in windowmanager.cpp (link failure) --- +/* +TEST(WindowManager, SetWindowInfoInActiveActiveThenInactiveCycle) +{ + WindowManager wm; + wm.setWindowInfoForeground("app1", 10); + // name = "ForegroundApp:app1" + + wm.setWindowInfoActive(10, "app1"); + EXPECT_EQ(wm.windowList()[0].name, QString("ForegroundApp:app1")); + EXPECT_EQ(wm.m_ActiveId, 10); + + wm.setWindowInfoInActive(10, "app1"); + EXPECT_EQ(wm.windowList()[0].name, QString("BackgroundApp:app1")); + EXPECT_EQ(wm.m_ActiveId, 0); + + // After inactive, m_ActiveId is 0, calling again does nothing. + QSignalSpy spy(&wm, &WindowManager::dataChanged); + wm.setWindowInfoInActive(10, "app1"); + EXPECT_EQ(spy.count(), 0); +} +*/ + +// =================== AppRuntimeInfo operator== =================== + +TEST(WindowManager, AppRuntimeInfoEqualityTrue) +{ + AppRuntimeInfo a; + a.name = "app1"; + a.id = 10; + AppRuntimeInfo b; + b.name = "app1"; + b.id = 10; + EXPECT_TRUE(a == b); +} + +TEST(WindowManager, AppRuntimeInfoEqualityFalseByName) +{ + AppRuntimeInfo a; + a.name = "app1"; + a.id = 10; + AppRuntimeInfo b; + b.name = "app2"; + b.id = 10; + EXPECT_FALSE(a == b); +} + +TEST(WindowManager, AppRuntimeInfoEqualityFalseById) +{ + AppRuntimeInfo a; + a.name = "app1"; + a.id = 10; + AppRuntimeInfo b; + b.name = "app1"; + b.id = 20; + EXPECT_FALSE(a == b); +} + +TEST(WindowManager, AppRuntimeInfoEqualityFalseByBoth) +{ + AppRuntimeInfo a; + a.name = "app1"; + a.id = 10; + AppRuntimeInfo b; + b.name = "app2"; + b.id = 20; + EXPECT_FALSE(a == b); +} + +TEST(WindowManager, AppRuntimeInfoStartTimeNotInEquality) +{ + // operator== only checks name and id, not startTime. + AppRuntimeInfo a; + a.name = "app1"; + a.id = 10; + a.startTime = QDateTime::fromMSecsSinceEpoch(1000); + AppRuntimeInfo b; + b.name = "app1"; + b.id = 10; + b.startTime = QDateTime::fromMSecsSinceEpoch(2000); + EXPECT_TRUE(a == b); +}