diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 77e6bee23..66a201ad5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,3 +3,4 @@ # SPDX-License-Identifier: CC0-1.0 add_subdirectory(panels) +add_subdirectory(applets) diff --git a/tests/applets/CMakeLists.txt b/tests/applets/CMakeLists.txt new file mode 100644 index 000000000..937eb6b1a --- /dev/null +++ b/tests/applets/CMakeLists.txt @@ -0,0 +1,131 @@ +# 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" OFF) + +# ---- 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() + + find_program(LCOV_BIN lcov) + find_program(GENHTML_BIN genhtml) + if(LCOV_BIN AND GENHTML_BIN) + set(APPLETS_COVERAGE_DIR "${CMAKE_BINARY_DIR}/applets_coverage") + add_custom_target(applets_coverage + DEPENDS ${APPLETS_TEST_TARGETS} + COMMAND ${CMAKE_COMMAND} -E make_directory "${APPLETS_COVERAGE_DIR}" + COMMAND ${CMAKE_CTEST_COMMAND} --test-dir "${CMAKE_BINARY_DIR}" -R "^applets_" --output-on-failure + COMMAND ${LCOV_BIN} --capture --directory "${CMAKE_BINARY_DIR}" + --output-file "${APPLETS_COVERAGE_DIR}/applets.info" + COMMAND ${LCOV_BIN} --remove "${APPLETS_COVERAGE_DIR}/applets.info" + '*/usr/include/*' '*/usr/lib/*' '*/3rdparty/*' '*/tests/*' + '*/moc_*' '*/qrc_*' '*/_deps/*' '*_json.h' + -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 ===" + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}" + COMMENT "Run applets unit tests and generate lcov branch-coverage report" + VERBATIM + ) + else() + message(WARNING "lcov or genhtml not found; 'applets_coverage' target not created.") + endif() +endif() 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")})); +}