From 4ae8baf82e79f0d9d53ba7680f19b77569e923a7 Mon Sep 17 00:00:00 2001 From: zhaofangxun Date: Thu, 20 Aug 2026 21:09:58 +0800 Subject: [PATCH] test: add GTest for applets dde-apps classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add unit tests for CategoryUtils namespace functions 2. Add unit tests for ItemsPage pagination manager 3. Include 54 test cases covering all public API branches 4. Add CMake build integration with coverage support Influence: 1. Test category parsing with DDE and XDG format strings 2. Test page operations including insert, move, and remove 3. Verify edge cases like empty input and page overflow test: 添加 applets dde-apps 类的单元测试 1. 添加 CategoryUtils 命名空间函数的单元测试 2. 添加 ItemsPage 分页管理器的单元测试 3. 包含 54 个测试用例覆盖所有公共 API 分支 4. 添加 CMake 构建集成及覆盖率支持 Influence: 1. 测试 DDE 和 XDG 格式字符串的分类解析 2. 测试页面操作包括插入、移动和删除 3. 验证空输入和页面溢出等边界情况 --- tests/CMakeLists.txt | 1 + tests/applets/CMakeLists.txt | 131 +++++++++ tests/applets/categoryutilstests.cpp | 159 ++++++++++ tests/applets/itemspagetests.cpp | 425 +++++++++++++++++++++++++++ 4 files changed, 716 insertions(+) create mode 100644 tests/applets/CMakeLists.txt create mode 100644 tests/applets/categoryutilstests.cpp create mode 100644 tests/applets/itemspagetests.cpp 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")})); +}