From 8675f8be8fab5a257395eae87825d89eac5aedb5 Mon Sep 17 00:00:00 2001 From: zhaofangxun Date: Thu, 20 Aug 2026 18:11:08 +0800 Subject: [PATCH] test: add 185 unit tests for dde-launchpad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add 14 Qt6 unit test files covering models and utils modules 2. Expand existing itemspage and searchfilter proxy model tests 3. Update CMakeLists.txt to register new targets with gcov coverage 4. Achieve 72.0% filtered line coverage and 86.1% function coverage Influence: 1. Build with cmake -DBUILD_TEST=ON and run all 15 test targets 2. Verify 185 test cases pass with zero failures 3. Check gcov coverage report for src/models and src/utils 4. Validate test isolation with no false-green cases test: 补充 dde-launchpad 单元测试 185 用例 1. 新增 14 个 Qt6 单元测试文件覆盖 models 和 utils 模块 2. 扩展已有的 itemspage 和 searchfilter 代理模型测试 3. 更新 CMakeLists.txt 注册新测试 target 并启用 gcov 覆盖率 4. 达到 72.0% 过滤后行覆盖率和 86.1% 函数覆盖率 Influence: 1. 使用 cmake -DBUILD_TEST=ON 编译并运行全部 15 个测试 target 2. 验证 185 条用例全部通过零失败 3. 检查 src/models 和 src/utils 模块的 gcov 覆盖率报告 4. 验证测试隔离性无 false-green 用例 --- tests/CMakeLists.txt | 88 +++- tests/blurhashtest.cpp | 162 +++++++ tests/categoryutilstest.cpp | 266 +++++++++++ tests/countlimitproxymodeltest.cpp | 204 ++++++++ tests/favoritedproxymodeltest.cpp | 243 ++++++++++ tests/freesortproxymodeltest.cpp | 97 ++++ tests/frequentlyusedproxymodeltest.cpp | 208 +++++++++ tests/iconutilstest.cpp | 126 +++++ tests/itemarrangementproxymodeltest.cpp | 303 ++++++++++++ tests/itemspagemodeltest.cpp | 192 ++++++++ tests/itemspagetest.cpp | 174 ++++++- tests/multipagesortfilterproxymodeltest.cpp | 187 ++++++++ tests/recentlyinstalledproxymodeltest.cpp | 108 +++++ tests/searchfilterproxymodeltest.cpp | 62 +++ tests/sortproxymodeltest.cpp | 488 ++++++++++++++++++++ 15 files changed, 2905 insertions(+), 3 deletions(-) create mode 100644 tests/blurhashtest.cpp create mode 100644 tests/categoryutilstest.cpp create mode 100644 tests/countlimitproxymodeltest.cpp create mode 100644 tests/favoritedproxymodeltest.cpp create mode 100644 tests/freesortproxymodeltest.cpp create mode 100644 tests/frequentlyusedproxymodeltest.cpp create mode 100644 tests/iconutilstest.cpp create mode 100644 tests/itemarrangementproxymodeltest.cpp create mode 100644 tests/itemspagemodeltest.cpp create mode 100644 tests/multipagesortfilterproxymodeltest.cpp create mode 100644 tests/recentlyinstalledproxymodeltest.cpp create mode 100644 tests/sortproxymodeltest.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 728f127c..e18ab360 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,15 +1,87 @@ -# SPDX-FileCopyrightText: 2024 UnionTech Software Technology Co., Ltd. +# SPDX-FileCopyrightText: 2024 - 2026 UnionTech Software Technology Co., Ltd. # # SPDX-License-Identifier: CC0-1.0 find_package(Qt6 REQUIRED COMPONENTS Core Test) +find_package(Qt6 REQUIRED COMPONENTS Gui Qml Svg) # needed by tested-source OBJECT libs +find_package(Qt6Core CONFIG REQUIRED Private) # sortproxymodel.cpp uses QAbstractItemModelPrivate qt_standard_project_setup() +# --------------------------------------------------------------------------- +# Tested-source OBJECT libraries +# +# Per the long-term coverage rule: the tested source files (src/models/*.cpp, +# src/utils/*.cpp) are compiled as OBJECT libraries and linked *directly* +# into every test executable, instead of being reached only through the +# launchpadcommon.so shared library. This ensures gcov .gcda runtime data +# is written to the test build directory and can be captured even in +# cross-prefix environments where the shared-library build path is not +# writable at runtime. +# +# launchpadcommon is still linked afterwards for transitive dependencies +# (D-Bus adaptors, dde-integration AppMgr, etc.). Symbols defined by the +# OBJECT libs take precedence over the same symbols in the shared library, +# so the gcov-instrumented copies in the test executable are the ones that +# actually execute. +# --------------------------------------------------------------------------- + +add_library(launchpad-test-models OBJECT + ${CMAKE_SOURCE_DIR}/src/models/appsmodel.cpp + ${CMAKE_SOURCE_DIR}/src/models/categorizedsortproxymodel.cpp + ${CMAKE_SOURCE_DIR}/src/models/countlimitproxymodel.cpp + ${CMAKE_SOURCE_DIR}/src/models/favoritedproxymodel.cpp + ${CMAKE_SOURCE_DIR}/src/models/freesortproxymodel.cpp + ${CMAKE_SOURCE_DIR}/src/models/frequentlyusedproxymodel.cpp + ${CMAKE_SOURCE_DIR}/src/models/itemarrangementproxymodel.cpp + ${CMAKE_SOURCE_DIR}/src/models/itemspage.cpp + ${CMAKE_SOURCE_DIR}/src/models/itemspagemodel.cpp + ${CMAKE_SOURCE_DIR}/src/models/multipagesortfilterproxymodel.cpp + ${CMAKE_SOURCE_DIR}/src/models/recentlyinstalledproxymodel.cpp + ${CMAKE_SOURCE_DIR}/src/models/searchfilterproxymodel.cpp + ${CMAKE_SOURCE_DIR}/src/models/sortproxymodel.cpp +) +target_include_directories(launchpad-test-models PRIVATE + ${CMAKE_SOURCE_DIR}/src/models + ${CMAKE_SOURCE_DIR}/src/utils + ${CMAKE_SOURCE_DIR}/src/ddeintegration +) +target_link_libraries(launchpad-test-models PUBLIC + Qt6::Core + Qt6::Gui + Qt6::Qml # QML_NAMED_ELEMENT / QML_SINGLETON in model headers (moc-generated registration) + Qt6::CorePrivate # sortproxymodel.cpp -> QAbstractItemModelPrivate + ${DTK_NS}::Core # DConfig, DFileWatcherManager, DPinyin (appsmodel.cpp, frequentlyusedproxymodel.cpp) +) + +add_library(launchpad-test-utils OBJECT + ${CMAKE_SOURCE_DIR}/src/utils/blurhash.cpp + ${CMAKE_SOURCE_DIR}/src/utils/categoryutils.cpp + ${CMAKE_SOURCE_DIR}/src/utils/iconutils.cpp +) +target_include_directories(launchpad-test-utils PRIVATE + ${CMAKE_SOURCE_DIR}/src/utils +) +target_link_libraries(launchpad-test-utils PUBLIC + Qt6::Core + Qt6::Gui + Qt6::Svg + ${DTK_NS}::Gui # DIcon, DIconTheme, DSvgRenderer (iconutils.cpp) +) + macro(launchpad_add_tests) foreach(_testname ${ARGN}) qt_add_executable(launchpad-${_testname} ${_testname}.cpp) - target_link_libraries(launchpad-${_testname} PRIVATE Qt6::Core Qt6::Test launchpadcommon) + # OBJECT libs first: their symbols take precedence over launchpadcommon.so, + # so the gcov-instrumented copies are the ones that execute at test time. + target_link_libraries(launchpad-${_testname} PRIVATE + Qt6::Core + Qt6::Test + launchpad-test-models + launchpad-test-utils + gio-utils # already OBJECT; covers src/gioutils/ (e.g. gioappinfotest) + launchpadcommon + ) add_test(NAME launchpad-${_testname} COMMAND launchpad-${_testname}) endforeach() endmacro() @@ -18,4 +90,16 @@ launchpad_add_tests( itemspagetest gioappinfotest searchfilterproxymodeltest + countlimitproxymodeltest + sortproxymodeltest + freesortproxymodeltest + recentlyinstalledproxymodeltest + multipagesortfilterproxymodeltest + categoryutilstest + blurhashtest + favoritedproxymodeltest + frequentlyusedproxymodeltest + itemarrangementproxymodeltest + iconutilstest + itemspagemodeltest ) diff --git a/tests/blurhashtest.cpp b/tests/blurhashtest.cpp new file mode 100644 index 00000000..a0fdb378 --- /dev/null +++ b/tests/blurhashtest.cpp @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include + +#include "../src/utils/blurhash.hpp" + +#include + +namespace { +Q_LOGGING_CATEGORY(logTest, "dde.launchpad.test") + +std::vector solidImage(size_t width, size_t height, + unsigned char r, unsigned char g, unsigned char b) +{ + std::vector image(width * height * 3, 0); + for (size_t i = 0; i < width * height; ++i) { + image[i * 3 + 0] = r; + image[i * 3 + 1] = g; + image[i * 3 + 2] = b; + } + return image; +} + +// blurhash layout: 1 (components) + 1 (maxAC) + 4 (DC) + 2 bytes per AC component +size_t expectedHashLength(int cx, int cy) +{ + return size_t(1 + 1 + 4 + (cx * cy - 1) * 2); +} +} + +class TestBlurhash : public QObject +{ + Q_OBJECT +private slots: + void decodeEmptyStringReturnsEmpty(); + void decodeInvalidHashReturnsEmpty(); + void encodeRejectsInvalidInput(); + void encodedHashHasExpectedLength(); + void decodedImageHasExpectedDimensions(); + void roundTripPreservesAverageColor(); + void decodeWithFourBytesPerPixel(); + void decodeTooShortHashReturnsEmpty(); + void decodeWrongSizeForComponentsReturnsEmpty(); +}; + +void TestBlurhash::decodeEmptyStringReturnsEmpty() +{ + qCInfo(logTest) << "Decoding an empty hash should return an empty image"; + const auto img = blurhash::decode(std::string_view{}, 8, 8); + QVERIFY(img.image.empty()); + QCOMPARE(img.width, size_t(0)); + QCOMPARE(img.height, size_t(0)); +} + +void TestBlurhash::decodeInvalidHashReturnsEmpty() +{ + qCInfo(logTest) << "Decoding a hash with invalid characters should return an empty image"; + const auto img = blurhash::decode(std::string_view("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"), 8, 8); + QVERIFY(img.image.empty()); +} + +void TestBlurhash::encodeRejectsInvalidInput() +{ + qCInfo(logTest) << "encode should return an empty string for invalid parameters"; + std::vector image = solidImage(4, 4, 255, 0, 0); + QVERIFY(blurhash::encode(image.data(), 0, 4, 1, 1).empty()); // width 0 + QVERIFY(blurhash::encode(nullptr, 4, 4, 1, 1).empty()); // null image + QVERIFY(blurhash::encode(image.data(), 4, 4, 0, 1).empty()); // components_x < 1 + QVERIFY(blurhash::encode(image.data(), 4, 4, 1, 10).empty()); // components_y > 9 +} + +void TestBlurhash::encodedHashHasExpectedLength() +{ + qCInfo(logTest) << "encode should produce a hash of the documented length"; + std::vector image = solidImage(8, 8, 128, 64, 200); + QCOMPARE(blurhash::encode(image.data(), 8, 8, 1, 1).size(), size_t(6)); // no AC components + QCOMPARE(blurhash::encode(image.data(), 8, 8, 1, 3).size(), expectedHashLength(1, 3)); // 10 + QCOMPARE(blurhash::encode(image.data(), 8, 8, 2, 2).size(), expectedHashLength(2, 2)); // 12 + QCOMPARE(blurhash::encode(image.data(), 8, 8, 4, 4).size(), expectedHashLength(4, 4)); // 36 +} + +void TestBlurhash::decodedImageHasExpectedDimensions() +{ + qCInfo(logTest) << "decode should produce an image matching the requested dimensions"; + constexpr size_t width = 16, height = 16; + std::vector image = solidImage(width, height, 128, 64, 200); + const std::string hash = blurhash::encode(image.data(), width, height, 2, 2); + QVERIFY(!hash.empty()); + + const auto decoded = blurhash::decode(hash, width, height); + QCOMPARE(decoded.width, width); + QCOMPARE(decoded.height, height); + QCOMPARE(decoded.image.size(), width * height * 3); +} + +void TestBlurhash::roundTripPreservesAverageColor() +{ + qCInfo(logTest) << "Round-trip should preserve the average color (DC component)"; + constexpr size_t width = 16, height = 16; + const unsigned char r = 128, g = 64, b = 200; + std::vector image = solidImage(width, height, r, g, b); + + const std::string hash = blurhash::encode(image.data(), width, height, 3, 3); + QVERIFY(!hash.empty()); + + const auto decoded = blurhash::decode(hash, width, height); + QCOMPARE(decoded.image.size(), width * height * 3); + + long rs = 0, gs = 0, bs = 0; + for (size_t i = 0; i < width * height; ++i) { + rs += decoded.image[i * 3 + 0]; + gs += decoded.image[i * 3 + 1]; + bs += decoded.image[i * 3 + 2]; + } + const long n = long(width * height); + const long avgR = rs / n, avgG = gs / n, avgB = bs / n; + // blurhash is lossy per-pixel, but the average (DC) stays close to the source color + QVERIFY(avgR >= long(r) - 15 && avgR <= long(r) + 15); + QVERIFY(avgG >= long(g) - 15 && avgG <= long(g) + 15); + QVERIFY(avgB >= long(b) - 15 && avgB <= long(b) + 15); +} + +void TestBlurhash::decodeWithFourBytesPerPixel() +{ + qCInfo(logTest) << "decode with bytesPerPixel=4 should produce a larger buffer"; + constexpr size_t width = 16, height = 16; + std::vector image = solidImage(width, height, 128, 64, 200); + const std::string hash = blurhash::encode(image.data(), width, height, 3, 3); + QVERIFY(!hash.empty()); + + const auto decoded = blurhash::decode(hash, width, height, 4); + QCOMPARE(decoded.width, width); + QCOMPARE(decoded.height, height); + // buffer should be width * height * 4 (not 3) + QCOMPARE(decoded.image.size(), width * height * 4); + // first 3 bytes of each pixel should still be the color channels + QVERIFY(decoded.image[0] != 255 || decoded.image[1] != 255 || decoded.image[2] != 255); +} + +void TestBlurhash::decodeTooShortHashReturnsEmpty() +{ + qCInfo(logTest) << "decode with a hash shorter than 10 chars should return empty"; + // hash must be >= 10 chars (1 + 1 + 4 + at least 4 for 2x2 components - 1 AC = 2 bytes -> 8, but 10 is the minimum check) + const auto img = blurhash::decode(std::string_view("short"), 8, 8); + QVERIFY(img.image.empty()); +} + +void TestBlurhash::decodeWrongSizeForComponentsReturnsEmpty() +{ + qCInfo(logTest) << "decode with a hash whose length doesn't match the component count should return empty"; + // 1 component char says 4x4 = 16 components -> needs 1+1+4+(16-1)*2 = 36 chars + // but we'll give only 12 chars + const auto img = blurhash::decode(std::string_view("LFE.}?a]a]a]a]a]a]a]"), 8, 8); + // this hash has wrong length for its declared components -> should return empty + QVERIFY(img.image.empty()); +} + +QTEST_MAIN(TestBlurhash) +#include "blurhashtest.moc" diff --git a/tests/categoryutilstest.cpp b/tests/categoryutilstest.cpp new file mode 100644 index 00000000..f8d5c9f6 --- /dev/null +++ b/tests/categoryutilstest.cpp @@ -0,0 +1,266 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include + +#include "../src/utils/categoryutils.h" + +namespace { +Q_LOGGING_CATEGORY(logTest, "dde.launchpad.test") + +using C = CategoryUtils::Categorytype; +} + +class TestCategoryUtils : public QObject +{ + Q_OBJECT +private slots: + void parseDDECategoryStringKnown(); + void parseDDECategoryStringUnknown(); + void parseXdgCategoryStringSingle(); + void parseXdgCategoryStringMultiValued(); + void parseXdgCategoryStringUnknown(); + void bestMatchedSingleCategory(); + void bestMatchedEmptyReturnsOthers(); + void bestMatchedOthersOnlyReturnsOthers(); + void bestMatchedMusicVideoTiePrefersVideo(); + void bestMatchedPlayerMapsToVideo(); + void bestMatchedMaxCountWins(); + void bestMatchedMultipleInternet(); + void parseDDECategoryStringAllKnown(); + void parseXdgCategoryStringMultipleDistinctCategories(); + void parseXdgCategoryStringAudioMapsToMusic(); + void bestMatchedDDECategoryWinsOverXdg(); + void bestMatchedGameVariants(); + void bestMatchedDevelopmentVariants(); + void bestMatchedReadingVariants(); + void bestMatchedSystemVariants(); + void bestMatchedGraphicsVariants(); + void bestMatchedChatVariants(); + void bestMatchedOfficeVariants(); + void bestMatchedMixedUnknownAndKnown(); + void bestMatchedAudioVideoEditingTie(); + void bestMatchedRecorderTie(); +}; + +void TestCategoryUtils::parseDDECategoryStringKnown() +{ + qCInfo(logTest) << "parseDDECategoryString should map the known DDE category names"; + QCOMPARE(int(CategoryUtils::parseDDECategoryString(QStringLiteral("music"))), int(C::CategoryMusic)); + QCOMPARE(int(CategoryUtils::parseDDECategoryString(QStringLiteral("internet"))), int(C::CategoryInternet)); + QCOMPARE(int(CategoryUtils::parseDDECategoryString(QStringLiteral("development"))), int(C::CategoryDevelopment)); + QCOMPARE(int(CategoryUtils::parseDDECategoryString(QStringLiteral("others"))), int(C::CategoryOthers)); +} + +void TestCategoryUtils::parseDDECategoryStringUnknown() +{ + qCInfo(logTest) << "Unknown DDE category strings should yield CategoryErr"; + QCOMPARE(int(CategoryUtils::parseDDECategoryString(QStringLiteral("unknown"))), int(C::CategoryErr)); + QCOMPARE(int(CategoryUtils::parseDDECategoryString(QStringLiteral("Music"))), int(C::CategoryErr)); // case sensitive +} + +void TestCategoryUtils::parseXdgCategoryStringSingle() +{ + qCInfo(logTest) << "parseXdgCategoryString should map single-valued XDG categories"; + const auto result = CategoryUtils::parseXdgCategoryString(QStringLiteral("webbrowser")); + QCOMPARE(result.size(), 1); + QVERIFY(result.contains(C::CategoryInternet)); +} + +void TestCategoryUtils::parseXdgCategoryStringMultiValued() +{ + qCInfo(logTest) << "audiovideo should map to both music and video"; + const auto result = CategoryUtils::parseXdgCategoryString(QStringLiteral("audiovideo")); + QCOMPARE(result.size(), 2); + QVERIFY(result.contains(C::CategoryMusic)); + QVERIFY(result.contains(C::CategoryVideo)); +} + +void TestCategoryUtils::parseXdgCategoryStringUnknown() +{ + qCInfo(logTest) << "Unknown XDG category strings should yield an empty list"; + const auto result = CategoryUtils::parseXdgCategoryString(QStringLiteral("totally-unknown-category")); + QVERIFY(result.isEmpty()); +} + +void TestCategoryUtils::bestMatchedSingleCategory() +{ + qCInfo(logTest) << "A single matching category should win"; + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("music")})), int(C::CategoryMusic)); + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("network")})), int(C::CategoryInternet)); + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("spreadsheet")})), int(C::CategoryOffice)); +} + +void TestCategoryUtils::bestMatchedEmptyReturnsOthers() +{ + qCInfo(logTest) << "An empty category list should be classified as others"; + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({})), int(C::CategoryOthers)); +} + +void TestCategoryUtils::bestMatchedOthersOnlyReturnsOthers() +{ + qCInfo(logTest) << "An only-others list should be classified as others"; + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("others")})), int(C::CategoryOthers)); +} + +void TestCategoryUtils::bestMatchedMusicVideoTiePrefersVideo() +{ + qCInfo(logTest) << "A music/video tie should prefer video"; + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("music"), QStringLiteral("video")})), + int(C::CategoryVideo)); +} + +void TestCategoryUtils::bestMatchedPlayerMapsToVideo() +{ + qCInfo(logTest) << "player (maps to music+video) should resolve to video"; + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("player")})), int(C::CategoryVideo)); +} + +void TestCategoryUtils::bestMatchedMaxCountWins() +{ + qCInfo(logTest) << "The category with the highest count should win"; + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("game"), QStringLiteral("game"), QStringLiteral("music")})), + int(C::CategoryGame)); +} + +void TestCategoryUtils::bestMatchedMultipleInternet() +{ + qCInfo(logTest) << "Several internet categories should aggregate into internet"; + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("webbrowser"), QStringLiteral("email")})), + int(C::CategoryInternet)); +} + +void TestCategoryUtils::parseDDECategoryStringAllKnown() +{ + qCInfo(logTest) << "parseDDECategoryString should map all known DDE category names"; + QCOMPARE(int(CategoryUtils::parseDDECategoryString(QStringLiteral("chat"))), int(C::CategoryChat)); + QCOMPARE(int(CategoryUtils::parseDDECategoryString(QStringLiteral("video"))), int(C::CategoryVideo)); + QCOMPARE(int(CategoryUtils::parseDDECategoryString(QStringLiteral("graphics"))), int(C::CategoryGraphics)); + QCOMPARE(int(CategoryUtils::parseDDECategoryString(QStringLiteral("office"))), int(C::CategoryOffice)); + QCOMPARE(int(CategoryUtils::parseDDECategoryString(QStringLiteral("game"))), int(C::CategoryGame)); + QCOMPARE(int(CategoryUtils::parseDDECategoryString(QStringLiteral("reading"))), int(C::CategoryReading)); + QCOMPARE(int(CategoryUtils::parseDDECategoryString(QStringLiteral("system"))), int(C::CategorySystem)); +} + +void TestCategoryUtils::parseXdgCategoryStringMultipleDistinctCategories() +{ + qCInfo(logTest) << "parseXdgCategoryString for several distinct known categories"; + // Each call returns the list for that single string; verify a batch of them + QCOMPARE(CategoryUtils::parseXdgCategoryString(QStringLiteral("ide")).size(), 1); + QVERIFY(CategoryUtils::parseXdgCategoryString(QStringLiteral("ide")).contains(C::CategoryDevelopment)); + QCOMPARE(CategoryUtils::parseXdgCategoryString(QStringLiteral("webdevelopment")).size(), 1); + QVERIFY(CategoryUtils::parseXdgCategoryString(QStringLiteral("webdevelopment")).contains(C::CategoryDevelopment)); + QCOMPARE(CategoryUtils::parseXdgCategoryString(QStringLiteral("arcadegame")).size(), 1); + QVERIFY(CategoryUtils::parseXdgCategoryString(QStringLiteral("arcadegame")).contains(C::CategoryGame)); + QCOMPARE(CategoryUtils::parseXdgCategoryString(QStringLiteral("wordprocessor")).size(), 1); + QVERIFY(CategoryUtils::parseXdgCategoryString(QStringLiteral("wordprocessor")).contains(C::CategoryOffice)); + QCOMPARE(CategoryUtils::parseXdgCategoryString(QStringLiteral("ircclient")).size(), 1); + QVERIFY(CategoryUtils::parseXdgCategoryString(QStringLiteral("ircclient")).contains(C::CategoryChat)); + QCOMPARE(CategoryUtils::parseXdgCategoryString(QStringLiteral("news")).size(), 1); + QVERIFY(CategoryUtils::parseXdgCategoryString(QStringLiteral("news")).contains(C::CategoryReading)); +} + +void TestCategoryUtils::parseXdgCategoryStringAudioMapsToMusic() +{ + qCInfo(logTest) << "audio should map to music"; + const auto result = CategoryUtils::parseXdgCategoryString(QStringLiteral("audio")); + QCOMPARE(result.size(), 1); + QVERIFY(result.contains(C::CategoryMusic)); +} + +void TestCategoryUtils::bestMatchedDDECategoryWinsOverXdg() +{ + qCInfo(logTest) << "A DDE category name should be matched before falling to XDG parsing"; + // 'music' is a DDE name -> CategoryMusic (via parseDDECategoryString) + // 'audio' is an XDG name -> CategoryMusic (via parseXdgCategoryString) + // mixing them should still yield Music + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("music"), QStringLiteral("audio")})), + int(C::CategoryMusic)); +} + +void TestCategoryUtils::bestMatchedGameVariants() +{ + qCInfo(logTest) << "Various game XDG categories should aggregate into game"; + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("actiongame"), QStringLiteral("arcadegame")})), + int(C::CategoryGame)); + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("boardgame"), QStringLiteral("cardgame"), QStringLiteral("puzzlegame")})), + int(C::CategoryGame)); +} + +void TestCategoryUtils::bestMatchedDevelopmentVariants() +{ + qCInfo(logTest) << "Development XDG categories should aggregate into development"; + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("ide"), QStringLiteral("debugger")})), + int(C::CategoryDevelopment)); + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("building"), QStringLiteral("revisioncontrol")})), + int(C::CategoryDevelopment)); +} + +void TestCategoryUtils::bestMatchedReadingVariants() +{ + qCInfo(logTest) << "Reading XDG categories should aggregate into reading"; + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("news"), QStringLiteral("translation")})), + int(C::CategoryReading)); +} + +void TestCategoryUtils::bestMatchedSystemVariants() +{ + qCInfo(logTest) << "System XDG categories should aggregate into system"; + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("desktopsettings"), QStringLiteral("packagemanager")})), + int(C::CategorySystem)); + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("terminalemulator"), QStringLiteral("filemanager")})), + int(C::CategorySystem)); +} + +void TestCategoryUtils::bestMatchedGraphicsVariants() +{ + qCInfo(logTest) << "Graphics XDG categories should aggregate into graphics"; + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("2dgraphics"), QStringLiteral("rastergraphics")})), + int(C::CategoryGraphics)); + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("photography"), QStringLiteral("viewer")})), + int(C::CategoryGraphics)); +} + +void TestCategoryUtils::bestMatchedChatVariants() +{ + qCInfo(logTest) << "Chat XDG categories should aggregate into chat"; + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("instantmessaging"), QStringLiteral("contactmanagement")})), + int(C::CategoryChat)); +} + +void TestCategoryUtils::bestMatchedOfficeVariants() +{ + qCInfo(logTest) << "Office XDG categories should aggregate into office"; + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("spreadsheet"), QStringLiteral("presentation")})), + int(C::CategoryOffice)); + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("science"), QStringLiteral("math")})), + int(C::CategoryOffice)); +} + +void TestCategoryUtils::bestMatchedMixedUnknownAndKnown() +{ + qCInfo(logTest) << "Unknown categories mixed with known ones should ignore unknowns"; + // 'totally-unknown' yields CategoryErr from DDE and empty from XDG -> ignored + // 'music' yields CategoryMusic -> should win + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("totally-unknown"), QStringLiteral("music")})), + int(C::CategoryMusic)); +} + +void TestCategoryUtils::bestMatchedAudioVideoEditingTie() +{ + qCInfo(logTest) << "audiovideoediting maps to both music and video -> tie -> video"; + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("audiovideoediting")})), + int(C::CategoryVideo)); +} + +void TestCategoryUtils::bestMatchedRecorderTie() +{ + qCInfo(logTest) << "recorder maps to both music and video -> tie -> video"; + QCOMPARE(int(CategoryUtils::parseBestMatchedCategory({QStringLiteral("recorder")})), + int(C::CategoryVideo)); +} + +QTEST_MAIN(TestCategoryUtils) +#include "categoryutilstest.moc" diff --git a/tests/countlimitproxymodeltest.cpp b/tests/countlimitproxymodeltest.cpp new file mode 100644 index 00000000..37ca5da0 --- /dev/null +++ b/tests/countlimitproxymodeltest.cpp @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include +#include + +#include "../src/models/countlimitproxymodel.h" + +namespace { +Q_LOGGING_CATEGORY(logTest, "dde.launchpad.test") +} + +class TestCountLimitProxyModel : public QObject +{ + Q_OBJECT +private slots: + void noLimitByDefault(); + void maxRowCountLimitsVisibleRows(); + void maxRowCountChangeEmitsSignal(); + void unchangedMaxRowCountDoesNotEmit(); + void sourceRowInsertionReevaluates(); + void sourceRowRemovalReevaluates(); + void maxRowCountEqualsSourceCount(); + void maxRowCountExceedsSourceCount(); + void setSourceModelWithNullDisconnects(); + void setSameSourceModelIsNoop(); +}; + +void TestCountLimitProxyModel::noLimitByDefault() +{ + qCInfo(logTest) << "CountLimitProxyModel should not limit rows when maxRowCount is unset"; + QStandardItemModel source; + CountLimitProxyModel proxy; + QCOMPARE(proxy.maxRowCount(), -1); + proxy.setSourceModel(&source); + + source.appendRow(new QStandardItem(QStringLiteral("a"))); + source.appendRow(new QStandardItem(QStringLiteral("b"))); + source.appendRow(new QStandardItem(QStringLiteral("c"))); + + QCOMPARE(proxy.rowCount(), 3); +} + +void TestCountLimitProxyModel::maxRowCountLimitsVisibleRows() +{ + qCInfo(logTest) << "Setting maxRowCount should cap the visible rows and keep the first ones"; + QStandardItemModel source; + for (int i = 0; i < 5; ++i) + source.appendRow(new QStandardItem(QStringLiteral("item-%1").arg(i))); + + CountLimitProxyModel proxy; + proxy.setSourceModel(&source); + proxy.setMaxRowCount(3); + QCOMPARE(proxy.maxRowCount(), 3); + QCOMPARE(proxy.rowCount(), 3); + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("item-0")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("item-1")); + QCOMPARE(proxy.data(proxy.index(2, 0), Qt::DisplayRole).toString(), QStringLiteral("item-2")); + + // relaxing the limit again shows everything + proxy.setMaxRowCount(-1); + QCOMPARE(proxy.rowCount(), 5); +} + +void TestCountLimitProxyModel::maxRowCountChangeEmitsSignal() +{ + qCInfo(logTest) << "Changing maxRowCount should emit maxRowCountChanged"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("a"))); + CountLimitProxyModel proxy; + proxy.setSourceModel(&source); + + QSignalSpy spy(&proxy, &CountLimitProxyModel::maxRowCountChanged); + proxy.setMaxRowCount(2); + QCOMPARE(spy.count(), 1); + + // maxRowCount == 0 rejects every row + proxy.setMaxRowCount(0); + QCOMPARE(spy.count(), 2); + QCOMPARE(proxy.rowCount(), 0); +} + +void TestCountLimitProxyModel::unchangedMaxRowCountDoesNotEmit() +{ + qCInfo(logTest) << "Setting the same maxRowCount should not emit the signal"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("a"))); + CountLimitProxyModel proxy; + proxy.setSourceModel(&source); + proxy.setMaxRowCount(2); + + QSignalSpy spy(&proxy, &CountLimitProxyModel::maxRowCountChanged); + proxy.setMaxRowCount(2); + QCOMPARE(spy.count(), 0); +} + +void TestCountLimitProxyModel::sourceRowInsertionReevaluates() +{ + qCInfo(logTest) << "Inserting source rows should re-evaluate the limit"; + QStandardItemModel source; + CountLimitProxyModel proxy; + proxy.setSourceModel(&source); + proxy.setMaxRowCount(2); + QCOMPARE(proxy.rowCount(), 0); + + for (int i = 0; i < 4; ++i) + source.appendRow(new QStandardItem(QStringLiteral("item-%1").arg(i))); + + QCOMPARE(proxy.rowCount(), 2); + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("item-0")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("item-1")); +} + +void TestCountLimitProxyModel::sourceRowRemovalReevaluates() +{ + qCInfo(logTest) << "Removing source rows should re-evaluate the limit and keep the first surviving rows"; + QStandardItemModel source; + for (int i = 0; i < 4; ++i) + source.appendRow(new QStandardItem(QStringLiteral("item-%1").arg(i))); + + CountLimitProxyModel proxy; + proxy.setSourceModel(&source); + proxy.setMaxRowCount(2); + QCOMPARE(proxy.rowCount(), 2); + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("item-0")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("item-1")); + + // setSourceModel connects rowsRemoved -> invalidate(), so removing a source row + // re-runs the filter against the (shifted) remaining rows: item-1, item-2 survive. + source.removeRow(0); + QCOMPARE(proxy.rowCount(), 2); + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("item-1")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("item-2")); +} + +void TestCountLimitProxyModel::maxRowCountEqualsSourceCount() +{ + qCInfo(logTest) << "maxRowCount == source row count should show all rows"; + QStandardItemModel source; + for (int i = 0; i < 3; ++i) + source.appendRow(new QStandardItem(QStringLiteral("item-%1").arg(i))); + + CountLimitProxyModel proxy; + proxy.setSourceModel(&source); + proxy.setMaxRowCount(3); + QCOMPARE(proxy.rowCount(), 3); // all visible + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("item-0")); + QCOMPARE(proxy.data(proxy.index(2, 0), Qt::DisplayRole).toString(), QStringLiteral("item-2")); +} + +void TestCountLimitProxyModel::maxRowCountExceedsSourceCount() +{ + qCInfo(logTest) << "maxRowCount > source row count should show all rows"; + QStandardItemModel source; + for (int i = 0; i < 2; ++i) + source.appendRow(new QStandardItem(QStringLiteral("item-%1").arg(i))); + + CountLimitProxyModel proxy; + proxy.setSourceModel(&source); + proxy.setMaxRowCount(100); + QCOMPARE(proxy.rowCount(), 2); // all visible, no extra rows +} + +void TestCountLimitProxyModel::setSourceModelWithNullDisconnects() +{ + qCInfo(logTest) << "setSourceModel(null) should disconnect from old model"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("a"))); + + CountLimitProxyModel proxy; + proxy.setSourceModel(&source); + proxy.setMaxRowCount(1); + QCOMPARE(proxy.rowCount(), 1); + + // set null source model + proxy.setSourceModel(nullptr); + QCOMPARE(proxy.rowCount(), 0); + + // old model changes should not affect proxy anymore + source.appendRow(new QStandardItem(QStringLiteral("b"))); + QCOMPARE(proxy.rowCount(), 0); +} + +void TestCountLimitProxyModel::setSameSourceModelIsNoop() +{ + qCInfo(logTest) << "setSourceModel with the same model should be a noop"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("a"))); + + CountLimitProxyModel proxy; + proxy.setSourceModel(&source); + QCOMPARE(proxy.rowCount(), 1); + + // set same model again + proxy.setSourceModel(&source); + QCOMPARE(proxy.rowCount(), 1); +} + +QTEST_MAIN(TestCountLimitProxyModel) +#include "countlimitproxymodeltest.moc" diff --git a/tests/favoritedproxymodeltest.cpp b/tests/favoritedproxymodeltest.cpp new file mode 100644 index 00000000..00cf17d4 --- /dev/null +++ b/tests/favoritedproxymodeltest.cpp @@ -0,0 +1,243 @@ +// 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 "../src/models/favoritedproxymodel.h" +#include "../src/models/appsmodel.h" + +namespace { +Q_LOGGING_CATEGORY(logTest, "dde.launchpad.test") + +enum SourceRoles { + SrcDesktopIdRole = Qt::UserRole + 1, + SrcNameRole, + SrcIconNameRole, + SrcNoDisplayRole, + SrcDDECategoryRole, + SrcInstalledTimeRole, + SrcLastLaunchedTimeRole, + SrcLaunchedTimesRole, + SrcAutoStartRole, + SrcCategoriesRole, + SrcVendorRole, + SrcGenericNameRole, +}; + +QStandardItem *makeApp(const QString &desktopId, const QString &name) +{ + auto item = new QStandardItem; + item->setData(desktopId, SrcDesktopIdRole); + item->setData(name, SrcNameRole); + item->setData(QStringLiteral("application-default-icon"), SrcIconNameRole); + item->setData(false, SrcNoDisplayRole); + item->setData(0, SrcDDECategoryRole); + return item; +} +} + +class TestFavoritedProxyModel : public QObject +{ + Q_OBJECT +private slots: + void initTestCase(); + void cleanupTestCase(); + void existsReturnsTrueForAddedFavorite(); + void addAndRemoveFavorite(); + void addDuplicateIsNoop(); + void removeNonexistentIsNoop(); + void pinToTopMovesToFront(); + void filterAcceptsOnlyFavorited(); + void lessThanOrdersByFavoriteListPosition(); + +private: + QStandardItemModel m_sourceModel; + // unique prefix so we can safely remove leftovers even if a prior test aborted + static inline const QString prefix = QStringLiteral("test-"); + void removeAllTestFavorites(); +}; + +void TestFavoritedProxyModel::removeAllTestFavorites() +{ + auto &proxy = FavoritedProxyModel::instance(); + // remove all test-managed IDs (best-effort; removeFavorite is noop if absent) + for (const QString &id : { + prefix + "exists.desktop", prefix + "add-remove.desktop", prefix + "dup.desktop", + prefix + "fav-1.desktop", prefix + "fav-2.desktop", prefix + "fav-3.desktop", + QStringLiteral("app-a.desktop"), QStringLiteral("app-b.desktop"), + QStringLiteral("app-c.desktop"), QStringLiteral("app-d.desktop") }) { + proxy.removeFavorite(id); + } +} + +void TestFavoritedProxyModel::initTestCase() +{ + m_sourceModel.setItemRoleNames({ + { SrcDesktopIdRole, QByteArrayLiteral("desktopId") }, + { SrcNameRole, QByteArrayLiteral("name") }, + { SrcIconNameRole, QByteArrayLiteral("iconName") }, + { SrcNoDisplayRole, QByteArrayLiteral("noDisplay") }, + { SrcDDECategoryRole, QByteArrayLiteral("ddeCategory") }, + { SrcInstalledTimeRole, QByteArrayLiteral("installedTime") }, + { SrcLastLaunchedTimeRole, QByteArrayLiteral("lastLaunchedTime") }, + { SrcLaunchedTimesRole, QByteArrayLiteral("launchedTimes") }, + { SrcAutoStartRole, QByteArrayLiteral("autoStart") }, + { SrcCategoriesRole, QByteArrayLiteral("categories") }, + { SrcVendorRole, QByteArrayLiteral("vendor") }, + { SrcGenericNameRole, QByteArrayLiteral("genericName") }, + }); + m_sourceModel.appendRow(makeApp("app-a.desktop", "App A")); + m_sourceModel.appendRow(makeApp("app-b.desktop", "App B")); + m_sourceModel.appendRow(makeApp("app-c.desktop", "App C")); + m_sourceModel.appendRow(makeApp("app-d.desktop", "App D")); + + AppsModel::instance().setSourceModel(&m_sourceModel); + AppsModel::instance().setReady(true); + + // Accessing the singleton triggers construction + load() + setSourceModel. + FavoritedProxyModel::instance(); + + // clean any leftovers from a prior aborted run + removeAllTestFavorites(); +} + +void TestFavoritedProxyModel::cleanupTestCase() +{ + // best-effort cleanup of in-memory state + removeAllTestFavorites(); + // delete the favorited.ini written by save() to prevent cross-run disk pollution + const QString basePath = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); + const QString iniPath = QDir(basePath).absoluteFilePath("favorited.ini"); + QFile::remove(iniPath); +} + +void TestFavoritedProxyModel::existsReturnsTrueForAddedFavorite() +{ + auto &proxy = FavoritedProxyModel::instance(); + const QString id = prefix + QStringLiteral("exists.desktop"); + QVERIFY(!proxy.exists(id)); + proxy.addFavorite(id); + QVERIFY(proxy.exists(id)); + proxy.removeFavorite(id); + QVERIFY(!proxy.exists(id)); +} + +void TestFavoritedProxyModel::addAndRemoveFavorite() +{ + auto &proxy = FavoritedProxyModel::instance(); + const QString id = prefix + QStringLiteral("add-remove.desktop"); + + proxy.addFavorite(id); + QVERIFY(proxy.exists(id)); + + proxy.removeFavorite(id); + QVERIFY(!proxy.exists(id)); +} + +void TestFavoritedProxyModel::addDuplicateIsNoop() +{ + auto &proxy = FavoritedProxyModel::instance(); + const QString id = prefix + QStringLiteral("dup.desktop"); + + proxy.addFavorite(id); + QVERIFY(proxy.exists(id)); + + // adding again should not crash or duplicate + proxy.addFavorite(id); + QVERIFY(proxy.exists(id)); + + // only one removal needed + proxy.removeFavorite(id); + QVERIFY(!proxy.exists(id)); +} + +void TestFavoritedProxyModel::removeNonexistentIsNoop() +{ + auto &proxy = FavoritedProxyModel::instance(); + // removing a non-existent favorite should not crash + proxy.removeFavorite(prefix + QStringLiteral("nonexistent-id.desktop")); +} + +void TestFavoritedProxyModel::pinToTopMovesToFront() +{ + auto &proxy = FavoritedProxyModel::instance(); + const QString id1 = prefix + QStringLiteral("fav-1.desktop"); + const QString id2 = prefix + QStringLiteral("fav-2.desktop"); + const QString id3 = prefix + QStringLiteral("fav-3.desktop"); + + proxy.addFavorite(id1); + proxy.addFavorite(id2); + proxy.addFavorite(id3); + + // pin fav-3 to top + proxy.pinToTop(id3); + QVERIFY(proxy.exists(id3)); + + // pin a non-existent id should be a noop + proxy.pinToTop(prefix + QStringLiteral("nonexistent.desktop")); + + // cleanup + proxy.removeFavorite(id1); + proxy.removeFavorite(id2); + proxy.removeFavorite(id3); +} + +void TestFavoritedProxyModel::filterAcceptsOnlyFavorited() +{ + auto &proxy = FavoritedProxyModel::instance(); + + // add favorites that match source model items (app-a, app-c) + proxy.addFavorite("app-a.desktop"); + proxy.addFavorite("app-c.desktop"); + + // proxy should show exactly the 2 favorited items (out of 4 source rows). + // predefined favorites (deepin-editor etc.) don't match the test source, so rowCount == 2. + QCOMPARE(proxy.rowCount(), 2); + + // unconditionally verify: every proxy row's desktopId is a favorited item + for (int i = 0; i < proxy.rowCount(); ++i) { + QString id = proxy.data(proxy.index(i, 0), AppsModel::DesktopIdRole).toString(); + QVERIFY(proxy.exists(id)); + } + + // cleanup + proxy.removeFavorite("app-a.desktop"); + proxy.removeFavorite("app-c.desktop"); + QCOMPARE(proxy.rowCount(), 0); +} + +void TestFavoritedProxyModel::lessThanOrdersByFavoriteListPosition() +{ + auto &proxy = FavoritedProxyModel::instance(); + + proxy.addFavorite("app-b.desktop"); + proxy.addFavorite("app-a.desktop"); + proxy.addFavorite("app-d.desktop"); + + // pin app-d to top so the order is: app-d, app-b, app-a + proxy.pinToTop("app-d.desktop"); + + // proxy should show exactly 3 favorited items + QCOMPARE(proxy.rowCount(), 3); + + // after sort, app-d should come before app-b which comes before app-a + QCOMPARE(proxy.data(proxy.index(0, 0), AppsModel::DesktopIdRole).toString(), QStringLiteral("app-d.desktop")); + QCOMPARE(proxy.data(proxy.index(1, 0), AppsModel::DesktopIdRole).toString(), QStringLiteral("app-b.desktop")); + QCOMPARE(proxy.data(proxy.index(2, 0), AppsModel::DesktopIdRole).toString(), QStringLiteral("app-a.desktop")); + + // cleanup + proxy.removeFavorite("app-b.desktop"); + proxy.removeFavorite("app-a.desktop"); + proxy.removeFavorite("app-d.desktop"); +} + +QTEST_MAIN(TestFavoritedProxyModel) +#include "favoritedproxymodeltest.moc" diff --git a/tests/freesortproxymodeltest.cpp b/tests/freesortproxymodeltest.cpp new file mode 100644 index 00000000..0677195b --- /dev/null +++ b/tests/freesortproxymodeltest.cpp @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include + +#include "../src/models/freesortproxymodel.h" +#include "../src/models/itemarrangementproxymodel.h" + +namespace { +Q_LOGGING_CATEGORY(logTest, "dde.launchpad.test") +} + +class TestFreeSortProxyModel : public QObject +{ + Q_OBJECT +private slots: + void sortByPageThenIndex(); + void descendingOrder(); + void missingRolesDefaultToZero(); +}; + +static QStandardItem *makeArrangedItem(const QString &name, int page, int indexInPage) +{ + auto item = new QStandardItem(name); + item->setData(page, ItemArrangementProxyModel::PageRole); + item->setData(indexInPage, ItemArrangementProxyModel::IndexInPageRole); + return item; +} + +void TestFreeSortProxyModel::sortByPageThenIndex() +{ + qCInfo(logTest) << "FreeSortProxyModel should sort by page then by index-in-page"; + QStandardItemModel source; + // intentionally appended out of order so the proxy actually has to sort + source.appendRow(makeArrangedItem(QStringLiteral("A"), 1, 0)); + source.appendRow(makeArrangedItem(QStringLiteral("B"), 0, 1)); + source.appendRow(makeArrangedItem(QStringLiteral("C"), 0, 0)); + source.appendRow(makeArrangedItem(QStringLiteral("D"), 1, 1)); + + FreeSortProxyModel proxy; + proxy.setSourceModel(&source); + proxy.sort(0, Qt::AscendingOrder); + + QCOMPARE(proxy.rowCount(), 4); + // ascending: C(0,0), B(0,1), A(1,0), D(1,1) + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("C")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("B")); + QCOMPARE(proxy.data(proxy.index(2, 0), Qt::DisplayRole).toString(), QStringLiteral("A")); + QCOMPARE(proxy.data(proxy.index(3, 0), Qt::DisplayRole).toString(), QStringLiteral("D")); +} + +void TestFreeSortProxyModel::descendingOrder() +{ + qCInfo(logTest) << "FreeSortProxyModel should honor descending sort order"; + QStandardItemModel source; + source.appendRow(makeArrangedItem(QStringLiteral("A"), 1, 0)); + source.appendRow(makeArrangedItem(QStringLiteral("B"), 0, 1)); + source.appendRow(makeArrangedItem(QStringLiteral("C"), 0, 0)); + source.appendRow(makeArrangedItem(QStringLiteral("D"), 1, 1)); + + FreeSortProxyModel proxy; + proxy.setSourceModel(&source); + proxy.sort(0, Qt::DescendingOrder); + + // descending: D(1,1), A(1,0), B(0,1), C(0,0) + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("D")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("A")); + QCOMPARE(proxy.data(proxy.index(2, 0), Qt::DisplayRole).toString(), QStringLiteral("B")); + QCOMPARE(proxy.data(proxy.index(3, 0), Qt::DisplayRole).toString(), QStringLiteral("C")); +} + +void TestFreeSortProxyModel::missingRolesDefaultToZero() +{ + qCInfo(logTest) << "Items without PageRole/IndexInPageRole should default to 0"; + QStandardItemModel source; + // items without PageRole or IndexInPageRole data -> toInt() returns 0 for both + source.appendRow(new QStandardItem(QStringLiteral("X"))); + source.appendRow(new QStandardItem(QStringLiteral("Y"))); + source.appendRow(new QStandardItem(QStringLiteral("Z"))); + + FreeSortProxyModel proxy; + proxy.setSourceModel(&source); + proxy.sort(0, Qt::AscendingOrder); + + // all items have page=0, index=0 -> equal keys -> stable source order + QCOMPARE(proxy.rowCount(), 3); + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("X")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("Y")); + QCOMPARE(proxy.data(proxy.index(2, 0), Qt::DisplayRole).toString(), QStringLiteral("Z")); +} + +QTEST_MAIN(TestFreeSortProxyModel) +#include "freesortproxymodeltest.moc" diff --git a/tests/frequentlyusedproxymodeltest.cpp b/tests/frequentlyusedproxymodeltest.cpp new file mode 100644 index 00000000..ce87ee39 --- /dev/null +++ b/tests/frequentlyusedproxymodeltest.cpp @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include +#include + +#include "../src/models/frequentlyusedproxymodel.h" + +namespace { +Q_LOGGING_CATEGORY(logTest, "dde.launchpad.test") + +constexpr int NameRole = Qt::UserRole + 1; +constexpr int DesktopIdRole = Qt::UserRole + 2; +constexpr int LaunchedTimesRole = Qt::UserRole + 3; +constexpr int LastLaunchedTimeRole = Qt::UserRole + 4; +} + +class TestFrequentlyUsedProxyModel : public QObject +{ + Q_OBJECT +private slots: + void filterAcceptsAllWhenNoRecentlyInstalled(); + void filterRejectsItemsInRecentlyInstalled(); + void lessThanByLaunchedTimes(); + void lessThanByLastLaunchedTimeWhenTimesEqual(); + void lessThanFallsToFrequentlyUsedWhenTimesZero(); + void setRecentlyInstalledModelEmitsSignal(); + void setSameRecentlyInstalledModelIsNoop(); + void componentCompleteSortsDescending(); + void classBeginIsNoop(); +}; + +static QStandardItem *makeApp(const QString &name, const QString &desktopId, + qint64 launchedTimes, qint64 lastLaunchedTime) +{ + auto item = new QStandardItem; + item->setData(name, NameRole); + item->setData(desktopId, DesktopIdRole); + item->setData(launchedTimes, LaunchedTimesRole); + item->setData(lastLaunchedTime, LastLaunchedTimeRole); + return item; +} + +static void populate(QStandardItemModel &source) +{ + // high usage, low usage, zero usage + source.appendRow(makeApp("A", "a.desktop", 100, 1000)); + source.appendRow(makeApp("B", "b.desktop", 50, 500)); + source.appendRow(makeApp("C", "c.desktop", 0, 0)); +} + +static void configureRoles(FrequentlyUsedProxyModel &proxy) +{ + QVERIFY(proxy.setProperty("desktopIdRole", DesktopIdRole)); + QCOMPARE(proxy.property("desktopIdRole").toInt(), DesktopIdRole); + QVERIFY(proxy.setProperty("launchedTimesRole", LaunchedTimesRole)); + QCOMPARE(proxy.property("launchedTimesRole").toInt(), LaunchedTimesRole); + QVERIFY(proxy.setProperty("lastLaunchedTimeRole", LastLaunchedTimeRole)); + QCOMPARE(proxy.property("lastLaunchedTimeRole").toInt(), LastLaunchedTimeRole); +} + +void TestFrequentlyUsedProxyModel::filterAcceptsAllWhenNoRecentlyInstalled() +{ + QStandardItemModel source; + populate(source); + + FrequentlyUsedProxyModel proxy; + configureRoles(proxy); + proxy.setSourceModel(&source); + + // no recentlyInstalledModel set -> all rows accepted + QCOMPARE(proxy.rowCount(), 3); +} + +void TestFrequentlyUsedProxyModel::filterRejectsItemsInRecentlyInstalled() +{ + QStandardItemModel source; + populate(source); + + // recentlyInstalledModel contains "b.desktop" + QStandardItemModel recentModel; + recentModel.setItemRoleNames({{ DesktopIdRole, QByteArrayLiteral("desktopId") }}); + auto recentItem = new QStandardItem; + recentItem->setData(QStringLiteral("b.desktop"), DesktopIdRole); + recentModel.appendRow(recentItem); + + FrequentlyUsedProxyModel proxy; + configureRoles(proxy); + proxy.setSourceModel(&source); + proxy.setRecentlyInstalledModel(&recentModel); + + // "b.desktop" is in recentlyInstalled -> filtered out + QCOMPARE(proxy.rowCount(), 2); + QCOMPARE(proxy.data(proxy.index(0, 0), DesktopIdRole).toString(), QStringLiteral("a.desktop")); + QCOMPARE(proxy.data(proxy.index(1, 0), DesktopIdRole).toString(), QStringLiteral("c.desktop")); +} + +void TestFrequentlyUsedProxyModel::lessThanByLaunchedTimes() +{ + QStandardItemModel source; + populate(source); + + FrequentlyUsedProxyModel proxy; + configureRoles(proxy); + proxy.setSourceModel(&source); + proxy.sort(0, Qt::DescendingOrder); + + // descending by launched times: A(100), B(50), C(0) + QCOMPARE(proxy.data(proxy.index(0, 0), DesktopIdRole).toString(), QStringLiteral("a.desktop")); + QCOMPARE(proxy.data(proxy.index(1, 0), DesktopIdRole).toString(), QStringLiteral("b.desktop")); + QCOMPARE(proxy.data(proxy.index(2, 0), DesktopIdRole).toString(), QStringLiteral("c.desktop")); +} + +void TestFrequentlyUsedProxyModel::lessThanByLastLaunchedTimeWhenTimesEqual() +{ + QStandardItemModel source; + // two apps with same launched times but different lastLaunchedTime + source.appendRow(makeApp("X", "x.desktop", 50, 200)); + source.appendRow(makeApp("Y", "y.desktop", 50, 100)); + + FrequentlyUsedProxyModel proxy; + configureRoles(proxy); + proxy.setSourceModel(&source); + proxy.sort(0, Qt::DescendingOrder); + + // same launchedTimes(50) -> compare lastLaunchedTime: X(200) > Y(100) + QCOMPARE(proxy.data(proxy.index(0, 0), DesktopIdRole).toString(), QStringLiteral("x.desktop")); + QCOMPARE(proxy.data(proxy.index(1, 0), DesktopIdRole).toString(), QStringLiteral("y.desktop")); +} + +void TestFrequentlyUsedProxyModel::lessThanFallsToFrequentlyUsedWhenTimesZero() +{ + QStandardItemModel source; + // two apps with zero launched times -> falls to lessThenByFrequentlyUsed + source.appendRow(makeApp("P", "p.desktop", 0, 0)); + source.appendRow(makeApp("Q", "q.desktop", 0, 0)); + + FrequentlyUsedProxyModel proxy; + configureRoles(proxy); + proxy.setSourceModel(&source); + proxy.sort(0, Qt::DescendingOrder); + + // both have 0 launched times and 0 lastLaunchedTime -> falls to frequentlyUsedAppIdList + // (from DConfig, likely empty -> both indexOf return -1 -> equal -> stable source order) + QCOMPARE(proxy.rowCount(), 2); + // stable source order: P (row 0) before Q (row 1) + QCOMPARE(proxy.data(proxy.index(0, 0), DesktopIdRole).toString(), QStringLiteral("p.desktop")); + QCOMPARE(proxy.data(proxy.index(1, 0), DesktopIdRole).toString(), QStringLiteral("q.desktop")); +} + +void TestFrequentlyUsedProxyModel::setRecentlyInstalledModelEmitsSignal() +{ + QStandardItemModel source; + populate(source); + + FrequentlyUsedProxyModel proxy; + configureRoles(proxy); + proxy.setSourceModel(&source); + + QStandardItemModel recentModel; + QSignalSpy spy(&proxy, &FrequentlyUsedProxyModel::recentlyInstalledModelChanged); + proxy.setRecentlyInstalledModel(&recentModel); + QCOMPARE(spy.count(), 1); + QCOMPARE(proxy.recentlyInstalledModel(), &recentModel); +} + +void TestFrequentlyUsedProxyModel::setSameRecentlyInstalledModelIsNoop() +{ + QStandardItemModel source; + populate(source); + + QStandardItemModel recentModel; + + FrequentlyUsedProxyModel proxy; + configureRoles(proxy); + proxy.setSourceModel(&source); + proxy.setRecentlyInstalledModel(&recentModel); + + QSignalSpy spy(&proxy, &FrequentlyUsedProxyModel::recentlyInstalledModelChanged); + proxy.setRecentlyInstalledModel(&recentModel); // same model + QCOMPARE(spy.count(), 0); +} + +void TestFrequentlyUsedProxyModel::componentCompleteSortsDescending() +{ + QStandardItemModel source; + populate(source); + + FrequentlyUsedProxyModel proxy; + configureRoles(proxy); + proxy.setSourceModel(&source); + proxy.componentComplete(); // sort(0, Qt::DescendingOrder) + + QCOMPARE(proxy.data(proxy.index(0, 0), DesktopIdRole).toString(), QStringLiteral("a.desktop")); +} + +void TestFrequentlyUsedProxyModel::classBeginIsNoop() +{ + FrequentlyUsedProxyModel proxy; + proxy.classBegin(); // just logs, no crash +} + +QTEST_MAIN(TestFrequentlyUsedProxyModel) +#include "frequentlyusedproxymodeltest.moc" diff --git a/tests/iconutilstest.cpp b/tests/iconutilstest.cpp new file mode 100644 index 00000000..06453f61 --- /dev/null +++ b/tests/iconutilstest.cpp @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include + +#include "../src/utils/iconutils.h" + +#include + +namespace { +Q_LOGGING_CATEGORY(logTest, "dde.launchpad.test") +} + +class TestIconUtils : public QObject +{ + Q_OBJECT +private slots: + void perfectIconSizeRoundsUp(); + void perfectIconSizeReturnsFirstForSmallInput(); + void perfectIconSizeReturnsLastForLargeInput(); + void perfectIconSizeReverseRoundsDown(); + void perfectIconSizeReverseReturnsLastForLargeInput(); + void perfectIconSizeReverseReturnsFirstForSmallInput(); + void getFolderPerfectIconCellReturnsValidPair(); + void getFolderPerfectIconCellWithDifferentSizes(); + void loadSvgReturnsEmptyForNonexistentFile(); + void loadSvgIntOverloadDelegatesToQSize(); +}; + +void TestIconUtils::perfectIconSizeRoundsUp() +{ + // sizes array: { 16, 18, 24, 32, 64, 96, 128, 256 } + QCOMPARE(IconUtils::perfectIconSize(17), 18); + QCOMPARE(IconUtils::perfectIconSize(20), 24); + QCOMPARE(IconUtils::perfectIconSize(25), 32); + QCOMPARE(IconUtils::perfectIconSize(50), 64); + QCOMPARE(IconUtils::perfectIconSize(100), 128); + QCOMPARE(IconUtils::perfectIconSize(200), 256); +} + +void TestIconUtils::perfectIconSizeReturnsFirstForSmallInput() +{ + // 15 < 16 -> lower_bound returns begin -> 16 + QCOMPARE(IconUtils::perfectIconSize(15), 16); + QCOMPARE(IconUtils::perfectIconSize(1), 16); + QCOMPARE(IconUtils::perfectIconSize(0), 16); +} + +void TestIconUtils::perfectIconSizeReturnsLastForLargeInput() +{ + // 300 > 256 -> lower_bound returns end -> returns sizes[0] = 16 (fallback) + QCOMPARE(IconUtils::perfectIconSize(300), 16); + QCOMPARE(IconUtils::perfectIconSize(1000), 16); +} + +void TestIconUtils::perfectIconSizeReverseRoundsDown() +{ + // reverse: finds the largest size <= input + QCOMPARE(IconUtils::perfectIconSizeReverse(17), 16); + QCOMPARE(IconUtils::perfectIconSizeReverse(20), 18); + QCOMPARE(IconUtils::perfectIconSizeReverse(30), 24); + QCOMPARE(IconUtils::perfectIconSizeReverse(50), 32); + QCOMPARE(IconUtils::perfectIconSizeReverse(100), 96); + QCOMPARE(IconUtils::perfectIconSizeReverse(200), 128); + QCOMPARE(IconUtils::perfectIconSizeReverse(300), 256); +} + +void TestIconUtils::perfectIconSizeReverseReturnsLastForLargeInput() +{ + // 1000 > 256 -> returns 256 (largest in array) + QCOMPARE(IconUtils::perfectIconSizeReverse(1000), 256); +} + +void TestIconUtils::perfectIconSizeReverseReturnsFirstForSmallInput() +{ + // 10 < 16 -> returns sizes[0] = 16 (fallback) + QCOMPARE(IconUtils::perfectIconSizeReverse(10), 16); + QCOMPARE(IconUtils::perfectIconSizeReverse(1), 16); +} + +void TestIconUtils::getFolderPerfectIconCellReturnsValidPair() +{ + auto result = IconUtils::getFolderPerfectIconCell(256, 4); + QVERIFY(result.first > 0); + QVERIFY(result.second > 0); + // iconSize should be a valid perfect icon size + QVERIFY(result.first == 16 || result.first == 18 || result.first == 24 || + result.first == 32 || result.first == 64 || result.first == 96 || + result.first == 128 || result.first == 256); +} + +void TestIconUtils::getFolderPerfectIconCellWithDifferentSizes() +{ + auto result96 = IconUtils::getFolderPerfectIconCell(96, 3); + QVERIFY(result96.first > 0); + QVERIFY(result96.second > 0); + + auto result128 = IconUtils::getFolderPerfectIconCell(128, 4); + QVERIFY(result128.first > 0); + QVERIFY(result128.second > 0); + + auto result192 = IconUtils::getFolderPerfectIconCell(192, 3); + QVERIFY(result192.first > 0); + QVERIFY(result192.second > 0); +} + +void TestIconUtils::loadSvgReturnsEmptyForNonexistentFile() +{ + // loadSvg with a file that doesn't exist should return an empty QPixmap + // (exercises the QFileInfo::exists -> false -> return QPixmap() path) + const QPixmap result = IconUtils::loadSvg(QStringLiteral("/nonexistent/file.svg"), QSize(32, 32)); + QVERIFY(result.isNull()); +} + +void TestIconUtils::loadSvgIntOverloadDelegatesToQSize() +{ + // The int overload should delegate to the QSize overload; with a nonexistent + // file it returns an empty QPixmap (exercises the wrapper line + delegation). + const QPixmap result = IconUtils::loadSvg(QStringLiteral("/nonexistent/file.svg"), 32); + QVERIFY(result.isNull()); +} + +QTEST_MAIN(TestIconUtils) +#include "iconutilstest.moc" diff --git a/tests/itemarrangementproxymodeltest.cpp b/tests/itemarrangementproxymodeltest.cpp new file mode 100644 index 00000000..749efed0 --- /dev/null +++ b/tests/itemarrangementproxymodeltest.cpp @@ -0,0 +1,303 @@ +// 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 "../src/models/itemarrangementproxymodel.h" +#include "../src/models/appsmodel.h" + +namespace { +Q_LOGGING_CATEGORY(logTest, "dde.launchpad.test") + +enum SourceRoles { + SrcDesktopIdRole = Qt::UserRole + 1, + SrcNameRole, + SrcIconNameRole, + SrcNoDisplayRole, + SrcDDECategoryRole, + SrcInstalledTimeRole, + SrcLastLaunchedTimeRole, + SrcLaunchedTimesRole, + SrcAutoStartRole, + SrcCategoriesRole, + SrcVendorRole, + SrcGenericNameRole, +}; + +QStandardItem *makeApp(const QString &desktopId, const QString &name, int category = 0) +{ + auto item = new QStandardItem; + item->setData(desktopId, SrcDesktopIdRole); + item->setData(name, SrcNameRole); + item->setData(QStringLiteral("application-default-icon"), SrcIconNameRole); + item->setData(false, SrcNoDisplayRole); + item->setData(category, SrcDDECategoryRole); + return item; +} +} + +class TestItemArrangementProxyModel : public QObject +{ + Q_OBJECT +private slots: + void initTestCase(); + void cleanupTestCase(); + void pageCountIsPositiveAfterSetup(); + void findItemReturnsValidPositionForExistingItem(); + void findItemReturnsInvalidForNonexistent(); + void dataReturnsPageAndIndexForAppRow(); + void dataReturnsAppItemType(); + void dataReturnsEmptyIconsNameForApp(); + void roleNamesContainsExtendedRoles(); + void allArrangedItemsContainsAllApps(); + void bringToFrontMovesItemToFirstPosition(); + void bringToFrontOnNonexistentIsNoop(); + void bringToFrontOnFirstItemIsNoop(); + void commitDndOperationWithSameIdIsNoop(); + void creatEmptyPageIncreasesPageCount(); + void removeEmptyPageRemovesEmptyPages(); + +private: + QStandardItemModel m_sourceModel; + int findRowForDesktopId(const QString &desktopId); +}; + +int TestItemArrangementProxyModel::findRowForDesktopId(const QString &desktopId) +{ + auto &model = ItemArrangementProxyModel::instance(); + for (int i = 0; i < model.rowCount(); ++i) { + if (model.data(model.index(i, 0), AppsModel::DesktopIdRole).toString() == desktopId) + return i; + } + return -1; +} + +void TestItemArrangementProxyModel::initTestCase() +{ + m_sourceModel.setItemRoleNames({ + { SrcDesktopIdRole, QByteArrayLiteral("desktopId") }, + { SrcNameRole, QByteArrayLiteral("name") }, + { SrcIconNameRole, QByteArrayLiteral("iconName") }, + { SrcNoDisplayRole, QByteArrayLiteral("noDisplay") }, + { SrcDDECategoryRole, QByteArrayLiteral("ddeCategory") }, + { SrcInstalledTimeRole, QByteArrayLiteral("installedTime") }, + { SrcLastLaunchedTimeRole, QByteArrayLiteral("lastLaunchedTime") }, + { SrcLaunchedTimesRole, QByteArrayLiteral("launchedTimes") }, + { SrcAutoStartRole, QByteArrayLiteral("autoStart") }, + { SrcCategoriesRole, QByteArrayLiteral("categories") }, + { SrcVendorRole, QByteArrayLiteral("vendor") }, + { SrcGenericNameRole, QByteArrayLiteral("genericName") }, + }); + m_sourceModel.appendRow(makeApp("arrange-a.desktop", "App A", 1)); + m_sourceModel.appendRow(makeApp("arrange-b.desktop", "App B", 2)); + m_sourceModel.appendRow(makeApp("arrange-c.desktop", "App C", 3)); + m_sourceModel.appendRow(makeApp("arrange-d.desktop", "App D", 4)); + m_sourceModel.appendRow(makeApp("arrange-e.desktop", "App E", 5)); + + AppsModel::instance().setSourceModel(&m_sourceModel); + AppsModel::instance().setReady(true); + + // Accessing the singleton triggers construction + onSourceModelChanged, + // which adds all AppsModel items to topLevel pages. + ItemArrangementProxyModel::instance(); +} + +void TestItemArrangementProxyModel::pageCountIsPositiveAfterSetup() +{ + QVERIFY(ItemArrangementProxyModel::instance().pageCount(0) > 0); +} + +void TestItemArrangementProxyModel::findItemReturnsValidPositionForExistingItem() +{ + auto &model = ItemArrangementProxyModel::instance(); + int row = findRowForDesktopId("arrange-a.desktop"); + QVERIFY(row >= 0); + + QModelIndex idx = model.index(row, 0); + int folder = model.data(idx, ItemArrangementProxyModel::FolderIdNumberRole).toInt(); + int page = model.data(idx, ItemArrangementProxyModel::PageRole).toInt(); + int indexInPage = model.data(idx, ItemArrangementProxyModel::IndexInPageRole).toInt(); + + // should be in top-level folder (0) + QCOMPARE(folder, 0); + QVERIFY(page >= 0); + QVERIFY(indexInPage >= 0); +} + +void TestItemArrangementProxyModel::findItemReturnsInvalidForNonexistent() +{ + auto &model = ItemArrangementProxyModel::instance(); + int row = findRowForDesktopId("nonexistent.desktop"); + QCOMPARE(row, -1); +} + +void TestItemArrangementProxyModel::dataReturnsPageAndIndexForAppRow() +{ + auto &model = ItemArrangementProxyModel::instance(); + int row = findRowForDesktopId("arrange-b.desktop"); + QVERIFY(row >= 0); + + QModelIndex idx = model.index(row, 0); + QVERIFY(model.data(idx, ItemArrangementProxyModel::PageRole).isValid()); + QVERIFY(model.data(idx, ItemArrangementProxyModel::IndexInPageRole).isValid()); + QVERIFY(model.data(idx, ItemArrangementProxyModel::FolderIdNumberRole).isValid()); +} + +void TestItemArrangementProxyModel::dataReturnsAppItemType() +{ + auto &model = ItemArrangementProxyModel::instance(); + int row = findRowForDesktopId("arrange-c.desktop"); + QVERIFY(row >= 0); + + QModelIndex idx = model.index(row, 0); + QCOMPARE(model.data(idx, ItemArrangementProxyModel::ItemTypeRole).toInt(), + ItemArrangementProxyModel::AppItemType); +} + +void TestItemArrangementProxyModel::dataReturnsEmptyIconsNameForApp() +{ + auto &model = ItemArrangementProxyModel::instance(); + int row = findRowForDesktopId("arrange-d.desktop"); + QVERIFY(row >= 0); + + QModelIndex idx = model.index(row, 0); + QVERIFY(!model.data(idx, ItemArrangementProxyModel::IconsNameRole).isValid()); +} + +void TestItemArrangementProxyModel::roleNamesContainsExtendedRoles() +{ + auto names = ItemArrangementProxyModel::instance().roleNames(); + // Source roleNames() only inserts IconsNameRole and ItemTypeRole (see + // itemarrangementproxymodel.cpp roleNames()); PageRole/IndexInPageRole/ + // FolderIdNumberRole are NOT in roleNames() — they are extended roles served + // via data() but not declared in the role-name hash. + QVERIFY(names.contains(ItemArrangementProxyModel::IconsNameRole)); + QVERIFY(names.contains(ItemArrangementProxyModel::ItemTypeRole)); + // the three page-arrangement roles are data-only, not in roleNames() + QVERIFY(!names.contains(ItemArrangementProxyModel::PageRole)); + QVERIFY(!names.contains(ItemArrangementProxyModel::IndexInPageRole)); + QVERIFY(!names.contains(ItemArrangementProxyModel::FolderIdNumberRole)); +} + +void TestItemArrangementProxyModel::allArrangedItemsContainsAllApps() +{ + // allArrangedItems is private, but itemsPage() is public and returns m_topLevel + auto all = ItemArrangementProxyModel::instance().itemsPage()->allArrangedItems(); + QVERIFY(all.contains("arrange-a.desktop")); + QVERIFY(all.contains("arrange-b.desktop")); + QVERIFY(all.contains("arrange-c.desktop")); + QVERIFY(all.contains("arrange-d.desktop")); + QVERIFY(all.contains("arrange-e.desktop")); +} + +void TestItemArrangementProxyModel::bringToFrontMovesItemToFirstPosition() +{ + auto &model = ItemArrangementProxyModel::instance(); + + // find the row for arrange-e (should NOT be at page 0, index 0 initially) + int row = findRowForDesktopId("arrange-e.desktop"); + QVERIFY(row >= 0); + + QSignalSpy spy(&model, &ItemArrangementProxyModel::itemBroughtToFront); + model.bringToFront("arrange-e.desktop"); + + // signal should have fired (item was moved) + QVERIFY(spy.count() >= 1); + + // after bringToFront, arrange-e should be at page 0, index 0 + row = findRowForDesktopId("arrange-e.desktop"); + QVERIFY(row >= 0); + QModelIndex idx = model.index(row, 0); + QCOMPARE(model.data(idx, ItemArrangementProxyModel::PageRole).toInt(), 0); + QCOMPARE(model.data(idx, ItemArrangementProxyModel::IndexInPageRole).toInt(), 0); +} + +void TestItemArrangementProxyModel::bringToFrontOnNonexistentIsNoop() +{ + auto &model = ItemArrangementProxyModel::instance(); + QSignalSpy spy(&model, &ItemArrangementProxyModel::itemBroughtToFront); + model.bringToFront("nonexistent-id.desktop"); + QCOMPARE(spy.count(), 0); // no signal emitted +} + +void TestItemArrangementProxyModel::bringToFrontOnFirstItemIsNoop() +{ + auto &model = ItemArrangementProxyModel::instance(); + + // self-establish: bring arrange-b to front first so it's at (0, 0) + int row = findRowForDesktopId("arrange-b.desktop"); + QVERIFY(row >= 0); + model.bringToFront("arrange-b.desktop"); + + // verify it's now at page 0, index 0 + row = findRowForDesktopId("arrange-b.desktop"); + QVERIFY(row >= 0); + QModelIndex idx = model.index(row, 0); + QCOMPARE(model.data(idx, ItemArrangementProxyModel::PageRole).toInt(), 0); + QCOMPARE(model.data(idx, ItemArrangementProxyModel::IndexInPageRole).toInt(), 0); + + // bringToFront on an item already at (0, 0) should be noop + QSignalSpy spy(&model, &ItemArrangementProxyModel::itemBroughtToFront); + model.bringToFront("arrange-b.desktop"); + QCOMPARE(spy.count(), 0); +} + +void TestItemArrangementProxyModel::commitDndOperationWithSameIdIsNoop() +{ + auto &model = ItemArrangementProxyModel::instance(); + // dragging onto itself should return early + model.commitDndOperation("arrange-a.desktop", "arrange-a.desktop", + ItemArrangementProxyModel::DndJoin); + // no crash, no assertion failure +} + +void TestItemArrangementProxyModel::creatEmptyPageIncreasesPageCount() +{ + auto &model = ItemArrangementProxyModel::instance(); + int before = model.pageCount(0); + int newPageIndex = model.creatEmptyPage(0); + QVERIFY(newPageIndex >= 0); + QCOMPARE(model.pageCount(0), before + 1); + + // cleanup: remove the empty page we just created so it doesn't leak into + // subsequent tests (removeEmptyPages removes ALL empty pages). + model.removeEmptyPage(); + QCOMPARE(model.pageCount(0), before); +} + +void TestItemArrangementProxyModel::removeEmptyPageRemovesEmptyPages() +{ + auto &model = ItemArrangementProxyModel::instance(); + // self-establish: record the baseline (real pages with apps, no empty pages) + int baseline = model.pageCount(0); + // ensure there are no pre-existing empty pages from prior tests + model.removeEmptyPage(); + QCOMPARE(model.pageCount(0), baseline); + + // create an empty page -> count increases by 1 + model.creatEmptyPage(0); + QCOMPARE(model.pageCount(0), baseline + 1); + + // remove empty pages -> the page we just created should be gone + model.removeEmptyPage(); + QCOMPARE(model.pageCount(0), baseline); +} + +void TestItemArrangementProxyModel::cleanupTestCase() +{ + // delete the item-arrangement.ini written by saveItemArrangementToUserData() + const QString basePath = QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation); + const QString iniPath = QDir(basePath).absoluteFilePath("deepin/dde-launchpad/item-arrangement.ini"); + QFile::remove(iniPath); +} + +QTEST_MAIN(TestItemArrangementProxyModel) +#include "itemarrangementproxymodeltest.moc" diff --git a/tests/itemspagemodeltest.cpp b/tests/itemspagemodeltest.cpp new file mode 100644 index 00000000..c161472d --- /dev/null +++ b/tests/itemspagemodeltest.cpp @@ -0,0 +1,192 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include +#include + +#include "../src/models/itemspagemodel.h" +#include "../src/models/itemarrangementproxymodel.h" +#include "../src/models/appsmodel.h" + +namespace { +Q_LOGGING_CATEGORY(logTest, "dde.launchpad.test") + +enum SourceRoles { + SrcDesktopIdRole = Qt::UserRole + 1, + SrcNameRole, + SrcIconNameRole, + SrcNoDisplayRole, + SrcDDECategoryRole, + SrcInstalledTimeRole, + SrcLastLaunchedTimeRole, + SrcLaunchedTimesRole, + SrcAutoStartRole, + SrcCategoriesRole, + SrcVendorRole, + SrcGenericNameRole, +}; + +QStandardItem *makeApp(const QString &desktopId, const QString &name) +{ + auto item = new QStandardItem; + item->setData(desktopId, SrcDesktopIdRole); + item->setData(name, SrcNameRole); + item->setData(QStringLiteral("application-default-icon"), SrcIconNameRole); + item->setData(false, SrcNoDisplayRole); + item->setData(0, SrcDDECategoryRole); + return item; +} +} + +class TestItemsPageModel : public QObject +{ + Q_OBJECT +private slots: + void initTestCase(); + void rowCountIsZeroWithoutSourceModel(); + void dataReturnsEmptyVariant(); + void setSourceModelWithNullIsNoop(); + void setSameSourceModelIsNoop(); + void setSourceModelConnectsToItemsPage(); + void rowCountReflectsItemsPageCount(); + void sigPageAddedInsertsRows(); + void sigPageRemovedRemovesRows(); + void sourceModelGetterReturnsSetModel(); + +private: + QStandardItemModel m_sourceModel; +}; + +void TestItemsPageModel::initTestCase() +{ + m_sourceModel.setItemRoleNames({ + { SrcDesktopIdRole, QByteArrayLiteral("desktopId") }, + { SrcNameRole, QByteArrayLiteral("name") }, + { SrcIconNameRole, QByteArrayLiteral("iconName") }, + { SrcNoDisplayRole, QByteArrayLiteral("noDisplay") }, + { SrcDDECategoryRole, QByteArrayLiteral("ddeCategory") }, + { SrcInstalledTimeRole, QByteArrayLiteral("installedTime") }, + { SrcLastLaunchedTimeRole, QByteArrayLiteral("lastLaunchedTime") }, + { SrcLaunchedTimesRole, QByteArrayLiteral("launchedTimes") }, + { SrcAutoStartRole, QByteArrayLiteral("autoStart") }, + { SrcCategoriesRole, QByteArrayLiteral("categories") }, + { SrcVendorRole, QByteArrayLiteral("vendor") }, + { SrcGenericNameRole, QByteArrayLiteral("genericName") }, + }); + m_sourceModel.appendRow(makeApp("page-a.desktop", "Page A")); + m_sourceModel.appendRow(makeApp("page-b.desktop", "Page B")); + m_sourceModel.appendRow(makeApp("page-c.desktop", "Page C")); + + AppsModel::instance().setSourceModel(&m_sourceModel); + AppsModel::instance().setReady(true); + + // Accessing the singleton triggers construction + onSourceModelChanged, + // which adds all AppsModel items to topLevel pages. + ItemArrangementProxyModel::instance(); +} + +void TestItemsPageModel::rowCountIsZeroWithoutSourceModel() +{ + ItemsPageModel model; + QCOMPARE(model.rowCount(), 0); +} + +void TestItemsPageModel::dataReturnsEmptyVariant() +{ + ItemsPageModel model; + // data() always returns empty QVariant (per source) + QVERIFY(!model.data(model.index(0, 0)).isValid()); +} + +void TestItemsPageModel::setSourceModelWithNullIsNoop() +{ + ItemsPageModel model; + QSignalSpy spy(&model, &ItemsPageModel::sourceModelChanged); + model.setSourceModel(nullptr); + QCOMPARE(spy.count(), 0); // null model -> skip, no signal + QCOMPARE(model.rowCount(), 0); +} + +void TestItemsPageModel::setSameSourceModelIsNoop() +{ + ItemsPageModel model; + auto &iapm = ItemArrangementProxyModel::instance(); + + model.setSourceModel(&iapm); + QSignalSpy spy(&model, &ItemsPageModel::sourceModelChanged); + model.setSourceModel(&iapm); // same model -> skip + QCOMPARE(spy.count(), 0); +} + +void TestItemsPageModel::setSourceModelConnectsToItemsPage() +{ + ItemsPageModel model; + auto &iapm = ItemArrangementProxyModel::instance(); + + QSignalSpy spy(&model, &ItemsPageModel::sourceModelChanged); + model.setSourceModel(&iapm); + QCOMPARE(spy.count(), 1); + QCOMPARE(model.sourceModel(), &iapm); +} + +void TestItemsPageModel::rowCountReflectsItemsPageCount() +{ + ItemsPageModel model; + auto &iapm = ItemArrangementProxyModel::instance(); + model.setSourceModel(&iapm); + + // rowCount should match the topLevel pageCount (apps added in initTestCase) + QCOMPARE(model.rowCount(), iapm.itemsPage()->pageCount()); + QVERIFY(model.rowCount() > 0); +} + +void TestItemsPageModel::sigPageAddedInsertsRows() +{ + ItemsPageModel model; + auto &iapm = ItemArrangementProxyModel::instance(); + model.setSourceModel(&iapm); + + int before = model.rowCount(); + // create an empty page -> sigPageAdded -> beginInsertRows/endInsertRows + QSignalSpy insertSpy(&model, &QAbstractItemModel::rowsInserted); + iapm.creatEmptyPage(0); + QVERIFY(insertSpy.count() >= 1); + QCOMPARE(model.rowCount(), before + 1); + + // cleanup + iapm.removeEmptyPage(); + QCOMPARE(model.rowCount(), before); +} + +void TestItemsPageModel::sigPageRemovedRemovesRows() +{ + ItemsPageModel model; + auto &iapm = ItemArrangementProxyModel::instance(); + model.setSourceModel(&iapm); + + int before = model.rowCount(); + // create an empty page first + iapm.creatEmptyPage(0); + QCOMPARE(model.rowCount(), before + 1); + + // remove it -> sigPageRemoved -> beginRemoveRows/endRemoveRows + QSignalSpy removeSpy(&model, &QAbstractItemModel::rowsRemoved); + iapm.removeEmptyPage(); + QVERIFY(removeSpy.count() >= 1); + QCOMPARE(model.rowCount(), before); +} + +void TestItemsPageModel::sourceModelGetterReturnsSetModel() +{ + ItemsPageModel model; + auto &iapm = ItemArrangementProxyModel::instance(); + model.setSourceModel(&iapm); + QCOMPARE(model.sourceModel(), &iapm); +} + +QTEST_MAIN(TestItemsPageModel) +#include "itemspagemodeltest.moc" diff --git a/tests/itemspagetest.cpp b/tests/itemspagetest.cpp index 481d2e5a..87ff5271 100644 --- a/tests/itemspagetest.cpp +++ b/tests/itemspagetest.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2024 - 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later @@ -18,6 +18,20 @@ private slots: void insertAndRemove(); void autoRemoveEmptyPage(); void dragItemToFolder(); + void findItemReturnsValidPosition(); + void findItemReturnsInvalidForNonexistent(); + void containsReturnsCorrect(); + void nameSetterAndGetter(); + void itemCountTotalAndPerPage(); + void firstNItemsReturnsUpToN(); + void allArrangedItemsReturnsAll(); + void removeItemsNotInRemovesAbsent(); + void removeEmptyPagesRemovesAllEmpty(); + void appendItemCreatesNewPageWhenFull(); + void moveItemPositionSamePageAdjacentAppendIsNoop(); + void moveItemPositionCrossPage(); + void removeItemNonExistentIsNoop(); + void insertItemToPageAutoSelectsPage(); }; void TestItemsPage::insertAndRemove() @@ -93,5 +107,163 @@ void TestItemsPage::dragItemToFolder() qCInfo(logTest) << "Verified page 0 contains items [1,2,3], dragItemToFolder test completed"; } +void TestItemsPage::findItemReturnsValidPosition() +{ + ItemsPage ip(3); + ip.appendPage({"a", "b", "c"}); + ip.appendPage({"d"}); + + auto [page, index] = ip.findItem("b"); + QCOMPARE(page, 0); + QCOMPARE(index, 1); + + auto [page2, index2] = ip.findItem("d"); + QCOMPARE(page2, 1); + QCOMPARE(index2, 0); +} + +void TestItemsPage::findItemReturnsInvalidForNonexistent() +{ + ItemsPage ip(3); + ip.appendPage({"a", "b"}); + + auto [page, index] = ip.findItem("nonexistent"); + QCOMPARE(page, -1); + QCOMPARE(index, -1); +} + +void TestItemsPage::containsReturnsCorrect() +{ + ItemsPage ip(3); + ip.appendPage({"a", "b"}); + + QVERIFY(ip.contains("a")); + QVERIFY(ip.contains("b")); + QVERIFY(!ip.contains("c")); +} + +void TestItemsPage::nameSetterAndGetter() +{ + ItemsPage ip(3); + QVERIFY(ip.name().isEmpty()); + ip.setName(QStringLiteral("MyFolder")); + QCOMPARE(ip.name(), QStringLiteral("MyFolder")); + ip.setName(QStringLiteral("Renamed")); + QCOMPARE(ip.name(), QStringLiteral("Renamed")); +} + +void TestItemsPage::itemCountTotalAndPerPage() +{ + ItemsPage ip(3); + ip.appendPage({"a", "b", "c"}); + ip.appendPage({"d", "e"}); + + QCOMPARE(ip.itemCount(), 5); + QCOMPARE(ip.itemCount(0), 3); + QCOMPARE(ip.itemCount(1), 2); +} + +void TestItemsPage::firstNItemsReturnsUpToN() +{ + ItemsPage ip(3); + ip.appendPage({"a", "b"}); + ip.appendPage({"c", "d", "e"}); + + QCOMPARE(ip.firstNItems(3), QStringList({"a", "b", "c"})); + QCOMPARE(ip.firstNItems(1), QStringList({"a"})); + QCOMPARE(ip.firstNItems(5), QStringList({"a", "b", "c", "d", "e"})); +} + +void TestItemsPage::allArrangedItemsReturnsAll() +{ + ItemsPage ip(3); + ip.appendPage({"a", "b"}); + ip.appendPage({"c"}); + + QCOMPARE(ip.allArrangedItems(), QStringList({"a", "b", "c"})); +} + +void TestItemsPage::removeItemsNotInRemovesAbsent() +{ + ItemsPage ip(3); + ip.appendPage({"a", "b", "c"}); + ip.appendPage({"d"}); + + QSet keep = {"a", "c", "d"}; + ip.removeItemsNotIn(keep); + + QCOMPARE(ip.allArrangedItems(), QStringList({"a", "c", "d"})); +} + +void TestItemsPage::removeEmptyPagesRemovesAllEmpty() +{ + ItemsPage ip(3); + ip.appendPage({"a"}); + ip.appendEmptyPage(); + ip.appendEmptyPage(); + QCOMPARE(ip.pageCount(), 3); + + ip.removeEmptyPages(); + QCOMPARE(ip.pageCount(), 1); + QCOMPARE(ip.items(0), QStringList({"a"})); +} + +void TestItemsPage::appendItemCreatesNewPageWhenFull() +{ + ItemsPage ip(2); + ip.appendPage({"a", "b"}); // page 0 full + QCOMPARE(ip.pageCount(), 1); + + ip.appendItem("c"); // should create page 1 + QCOMPARE(ip.pageCount(), 2); + QCOMPARE(ip.items(1), QStringList({"c"})); +} + +void TestItemsPage::moveItemPositionSamePageAdjacentAppendIsNoop() +{ + ItemsPage ip(3); + ip.appendPage({"a", "b", "c"}); + + // move from page 0, index 1 to page 0, index 0 with append=true + // since fromIndex(1) > toIndex(0) and append=true, and they're adjacent (1 == 0+1), + // the source does nothing. + ip.moveItemPosition(0, 1, 0, 0, true); + QCOMPARE(ip.items(0), QStringList({"a", "b", "c"})); +} + +void TestItemsPage::moveItemPositionCrossPage() +{ + ItemsPage ip(3); + ip.appendPage({"a", "b", "c"}); + ip.appendPage({"d", "e"}); + + // move "b" from page 0, index 1 to page 1, index 0 + ip.moveItemPosition(0, 1, 1, 0, false); + QCOMPARE(ip.items(0), QStringList({"a", "c"})); + QCOMPARE(ip.items(1), QStringList({"b", "d", "e"})); +} + +void TestItemsPage::removeItemNonExistentIsNoop() +{ + ItemsPage ip(3); + ip.appendPage({"a", "b"}); + QCOMPARE(ip.pageCount(), 1); + + ip.removeItem("nonexistent"); + QCOMPARE(ip.pageCount(), 1); + QCOMPARE(ip.items(0), QStringList({"a", "b"})); +} + +void TestItemsPage::insertItemToPageAutoSelectsPage() +{ + ItemsPage ip(3); + ip.appendPage({"a", "b", "c"}); // page 0 full + ip.appendPage({"d"}); + + // insertItemToPage with page=-1 should auto-select the last page with space + ip.insertItemToPage("e", -1); + QVERIFY(ip.contains("e")); +} + QTEST_MAIN(TestItemsPage) #include "itemspagetest.moc" diff --git a/tests/multipagesortfilterproxymodeltest.cpp b/tests/multipagesortfilterproxymodeltest.cpp new file mode 100644 index 00000000..48f7fe96 --- /dev/null +++ b/tests/multipagesortfilterproxymodeltest.cpp @@ -0,0 +1,187 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include + +#include "../src/models/multipagesortfilterproxymodel.h" +#include "../src/models/itemarrangementproxymodel.h" + +namespace { +Q_LOGGING_CATEGORY(logTest, "dde.launchpad.test") + +constexpr int NameRole = Qt::UserRole + 10; +} + +class TestMultipageSortFilterProxyModel : public QObject +{ + Q_OBJECT +private slots: + void filtersByFolderAndPage(); + void noMatchYieldsEmpty(); + void filterOnlyModeChangesSorting(); + void filterOnlyModeToggleDoesNotAutoResort(); +}; + +// build the source in the order: Z, Y, X, W (so the proxy actually has to sort) +static QStandardItem *makeArrangedItem(const QString &name, int folderId, int page, int indexInPage) +{ + auto item = new QStandardItem; + item->setData(name, NameRole); + item->setData(folderId, ItemArrangementProxyModel::FolderIdNumberRole); + item->setData(page, ItemArrangementProxyModel::PageRole); + item->setData(indexInPage, ItemArrangementProxyModel::IndexInPageRole); + return item; +} + +static void populateSource(QStandardItemModel &source) +{ + source.appendRow(makeArrangedItem(QStringLiteral("Z"), 0, 1, 0)); // source row 0 + source.appendRow(makeArrangedItem(QStringLiteral("Y"), 0, 0, 1)); // source row 1 + source.appendRow(makeArrangedItem(QStringLiteral("X"), 0, 0, 0)); // source row 2 + source.appendRow(makeArrangedItem(QStringLiteral("W"), 1, 0, 0)); // source row 3 +} + +void TestMultipageSortFilterProxyModel::filtersByFolderAndPage() +{ + qCInfo(logTest) << "Filter by folderId and pageId, sorted by page then index-in-page"; + QStandardItemModel source; + populateSource(source); + + MultipageSortFilterProxyModel proxy; + // folderId / pageId members are not initialized by the constructor, set them up front. + proxy.setProperty("folderId", 0); + QCOMPARE(proxy.property("folderId").toInt(), 0); + proxy.setProperty("pageId", -1); + QCOMPARE(proxy.property("pageId").toInt(), -1); + proxy.setProperty("filterOnlyMode", false); + QCOMPARE(proxy.property("filterOnlyMode").toBool(), false); + proxy.setModel(&source); + + // folder 0, all pages -> X(0,0,0), Y(0,0,1), Z(0,1,0) + QCOMPARE(proxy.rowCount(), 3); + QCOMPARE(proxy.data(proxy.index(0, 0), NameRole).toString(), QStringLiteral("X")); + QCOMPARE(proxy.data(proxy.index(1, 0), NameRole).toString(), QStringLiteral("Y")); + QCOMPARE(proxy.data(proxy.index(2, 0), NameRole).toString(), QStringLiteral("Z")); + + // restrict to page 0 -> X, Y + proxy.setProperty("pageId", 0); + QCOMPARE(proxy.property("pageId").toInt(), 0); + QCOMPARE(proxy.rowCount(), 2); + QCOMPARE(proxy.data(proxy.index(0, 0), NameRole).toString(), QStringLiteral("X")); + QCOMPARE(proxy.data(proxy.index(1, 0), NameRole).toString(), QStringLiteral("Y")); + + // restrict to page 1 -> Z + proxy.setProperty("pageId", 1); + QCOMPARE(proxy.property("pageId").toInt(), 1); + QCOMPARE(proxy.rowCount(), 1); + QCOMPARE(proxy.data(proxy.index(0, 0), NameRole).toString(), QStringLiteral("Z")); + + // switch to folder 1, all pages -> W + proxy.setProperty("folderId", 1); + QCOMPARE(proxy.property("folderId").toInt(), 1); + proxy.setProperty("pageId", -1); + QCOMPARE(proxy.property("pageId").toInt(), -1); + QCOMPARE(proxy.rowCount(), 1); + QCOMPARE(proxy.data(proxy.index(0, 0), NameRole).toString(), QStringLiteral("W")); +} + +void TestMultipageSortFilterProxyModel::noMatchYieldsEmpty() +{ + qCInfo(logTest) << "A folderId matching no source row should yield an empty proxy"; + QStandardItemModel source; + populateSource(source); + + MultipageSortFilterProxyModel proxy; + proxy.setProperty("folderId", 999); + QCOMPARE(proxy.property("folderId").toInt(), 999); + proxy.setProperty("pageId", -1); + QCOMPARE(proxy.property("pageId").toInt(), -1); + proxy.setModel(&source); + QCOMPARE(proxy.rowCount(), 0); +} + +void TestMultipageSortFilterProxyModel::filterOnlyModeChangesSorting() +{ + qCInfo(logTest) << "filterOnlyMode=true, after a forced re-sort, should fall back to sortRole-only ordering"; + QStandardItemModel source; + populateSource(source); + + MultipageSortFilterProxyModel proxy; + proxy.setProperty("folderId", 0); + QCOMPARE(proxy.property("folderId").toInt(), 0); + proxy.setProperty("pageId", -1); + QCOMPARE(proxy.property("pageId").toInt(), -1); + proxy.setProperty("filterOnlyMode", false); + QCOMPARE(proxy.property("filterOnlyMode").toBool(), false); + proxy.setModel(&source); + // default ordering (filterOnlyMode=false) by page then index-in-page -> X, Y, Z + QCOMPARE(proxy.data(proxy.index(0, 0), NameRole).toString(), QStringLiteral("X")); + QCOMPARE(proxy.data(proxy.index(1, 0), NameRole).toString(), QStringLiteral("Y")); + QCOMPARE(proxy.data(proxy.index(2, 0), NameRole).toString(), QStringLiteral("Z")); + + // Switching filterOnlyMode updates the member (read back to prove it took effect)... + proxy.setProperty("filterOnlyMode", true); + QCOMPARE(proxy.property("filterOnlyMode").toBool(), true); + // ...but onFilterOnlyModeChanged is not wired to any re-sort/invalidate in the source, + // so the order does NOT change yet (see filterOnlyModeToggleDoesNotAutoResort). + QCOMPARE(proxy.data(proxy.index(0, 0), NameRole).toString(), QStringLiteral("X")); + QCOMPARE(proxy.data(proxy.index(1, 0), NameRole).toString(), QStringLiteral("Y")); + QCOMPARE(proxy.data(proxy.index(2, 0), NameRole).toString(), QStringLiteral("Z")); + + // Force a re-evaluation: invalidate() re-runs the sort with the now-active + // filterOnlyMode==true lessThan (QSortFilterProxyModel::lessThan by sortRole, + // which is FolderIdNumberRole == 0 for all rows) -> stable source order Z, Y, X. + // (sort(0) alone would short-circuit: column/order equal to the sort(0) done in setModel.) + proxy.invalidate(); + QCOMPARE(proxy.rowCount(), 3); + QCOMPARE(proxy.data(proxy.index(0, 0), NameRole).toString(), QStringLiteral("Z")); + QCOMPARE(proxy.data(proxy.index(1, 0), NameRole).toString(), QStringLiteral("Y")); + QCOMPARE(proxy.data(proxy.index(2, 0), NameRole).toString(), QStringLiteral("X")); +} + +void TestMultipageSortFilterProxyModel::filterOnlyModeToggleDoesNotAutoResort() +{ + qCInfo(logTest) << "Toggling filterOnlyMode alone must not re-sort (signal is not wired in source)"; + QStandardItemModel source; + populateSource(source); + + MultipageSortFilterProxyModel proxy; + proxy.setProperty("folderId", 0); + QCOMPARE(proxy.property("folderId").toInt(), 0); + proxy.setProperty("pageId", -1); + QCOMPARE(proxy.property("pageId").toInt(), -1); + proxy.setProperty("filterOnlyMode", false); + QCOMPARE(proxy.property("filterOnlyMode").toBool(), false); + proxy.setModel(&source); + // filterOnlyMode=false ordering: X, Y, Z + QCOMPARE(proxy.data(proxy.index(0, 0), NameRole).toString(), QStringLiteral("X")); + QCOMPARE(proxy.data(proxy.index(2, 0), NameRole).toString(), QStringLiteral("Z")); + + // Toggling filterOnlyMode emits onFilterOnlyModeChanged, but the source never + // connects it to invalidate()/invalidateFilter(). The member is updated (read + // back below), yet no re-sort/re-filter is triggered, so the visible order is + // unchanged. + proxy.setProperty("filterOnlyMode", true); + QCOMPARE(proxy.property("filterOnlyMode").toBool(), true); + proxy.sort(0); // short-circuits: same column/order as the sort(0) inside setModel + QCOMPARE(proxy.data(proxy.index(0, 0), NameRole).toString(), QStringLiteral("X")); + QCOMPARE(proxy.data(proxy.index(1, 0), NameRole).toString(), QStringLiteral("Y")); + QCOMPARE(proxy.data(proxy.index(2, 0), NameRole).toString(), QStringLiteral("Z")); + + // Even flipping the sort order does NOT yield source order here: with + // filterOnlyMode==true the sortRole (FolderIdNumberRole) is identical for every + // row, so the stable sort preserves the *current* proxy mapping (X, Y, Z) + // rather than re-deriving source order. Only invalidate() re-builds from the + // source (see filterOnlyModeChangesSorting for that case). + proxy.sort(0, Qt::DescendingOrder); + QCOMPARE(proxy.data(proxy.index(0, 0), NameRole).toString(), QStringLiteral("X")); + QCOMPARE(proxy.data(proxy.index(1, 0), NameRole).toString(), QStringLiteral("Y")); + QCOMPARE(proxy.data(proxy.index(2, 0), NameRole).toString(), QStringLiteral("Z")); +} + +QTEST_MAIN(TestMultipageSortFilterProxyModel) +#include "multipagesortfilterproxymodeltest.moc" diff --git a/tests/recentlyinstalledproxymodeltest.cpp b/tests/recentlyinstalledproxymodeltest.cpp new file mode 100644 index 00000000..e273f13c --- /dev/null +++ b/tests/recentlyinstalledproxymodeltest.cpp @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include + +#include "../src/models/recentlyinstalledproxymodel.h" + +namespace { +Q_LOGGING_CATEGORY(logTest, "dde.launchpad.test") + +constexpr int NameRole = Qt::UserRole + 1; +constexpr int InstalledTimeRole = Qt::UserRole + 2; +constexpr int LastLaunchedTimeRole = Qt::UserRole + 3; +} + +class TestRecentlyInstalledProxyModel : public QObject +{ + Q_OBJECT +private slots: + void filtersAndSortsByInstalledTime(); + void ascendingOrder(); + void componentCompleteSortsDescending(); +}; + +static QStandardItem *makeApp(const QString &name, qint64 installed, qint64 launched) +{ + auto item = new QStandardItem; + item->setData(name, NameRole); + item->setData(installed, InstalledTimeRole); + item->setData(launched, LastLaunchedTimeRole); + return item; +} + +static void populateSource(QStandardItemModel &source) +{ + source.appendRow(makeApp(QStringLiteral("A"), 100, 0)); // installed, never launched -> kept + source.appendRow(makeApp(QStringLiteral("B"), 200, 0)); // installed, never launched -> kept + source.appendRow(makeApp(QStringLiteral("C"), 300, 50)); // already launched -> filtered out + source.appendRow(makeApp(QStringLiteral("D"), 0, 0)); // not installed -> filtered out + source.appendRow(makeApp(QStringLiteral("E"), 0, 10)); // launched but not installed -> filtered out +} + +// the role members are intentionally not initialized by the constructor, +// so they must be set before any filtering/sorting happens. The readback +// QCOMPAREs guard against a silent drift of the Q_PROPERTY name (a wrong +// name would make property() return an invalid variant -> toInt()==0). +static void configureProxy(RecentlyInstalledProxyModel &proxy, QStandardItemModel &source) +{ + proxy.setProperty("installedTimeRole", InstalledTimeRole); + QCOMPARE(proxy.property("installedTimeRole").toInt(), InstalledTimeRole); + proxy.setProperty("lastLaunchedTimeRole", LastLaunchedTimeRole); + QCOMPARE(proxy.property("lastLaunchedTimeRole").toInt(), LastLaunchedTimeRole); + proxy.setSourceModel(&source); +} + +void TestRecentlyInstalledProxyModel::filtersAndSortsByInstalledTime() +{ + qCInfo(logTest) << "Only never-launched, installed apps should be kept, newest first"; + QStandardItemModel source; + populateSource(source); + + RecentlyInstalledProxyModel proxy; + configureProxy(proxy, source); + proxy.sort(0, Qt::DescendingOrder); + + QCOMPARE(proxy.rowCount(), 2); + QCOMPARE(proxy.data(proxy.index(0, 0), NameRole).toString(), QStringLiteral("B")); // installed=200 + QCOMPARE(proxy.data(proxy.index(1, 0), NameRole).toString(), QStringLiteral("A")); // installed=100 +} + +void TestRecentlyInstalledProxyModel::ascendingOrder() +{ + qCInfo(logTest) << "Ascending sort should order by installed time ascending"; + QStandardItemModel source; + populateSource(source); + + RecentlyInstalledProxyModel proxy; + configureProxy(proxy, source); + proxy.sort(0, Qt::AscendingOrder); + + QCOMPARE(proxy.rowCount(), 2); + QCOMPARE(proxy.data(proxy.index(0, 0), NameRole).toString(), QStringLiteral("A")); // installed=100 + QCOMPARE(proxy.data(proxy.index(1, 0), NameRole).toString(), QStringLiteral("B")); // installed=200 +} + +void TestRecentlyInstalledProxyModel::componentCompleteSortsDescending() +{ + qCInfo(logTest) << "componentComplete() should sort by installed time descending (newest first)"; + QStandardItemModel source; + populateSource(source); + + RecentlyInstalledProxyModel proxy; + configureProxy(proxy, source); + // componentComplete() is what QML calls once the declarative object is ready; + // it issues sort(0, Qt::DescendingOrder), i.e. newest installed first. + proxy.componentComplete(); + + QCOMPARE(proxy.rowCount(), 2); + QCOMPARE(proxy.data(proxy.index(0, 0), NameRole).toString(), QStringLiteral("B")); // installed=200 + QCOMPARE(proxy.data(proxy.index(1, 0), NameRole).toString(), QStringLiteral("A")); // installed=100 +} + +QTEST_MAIN(TestRecentlyInstalledProxyModel) +#include "recentlyinstalledproxymodeltest.moc" diff --git a/tests/searchfilterproxymodeltest.cpp b/tests/searchfilterproxymodeltest.cpp index 185c7aaa..b0e823ba 100644 --- a/tests/searchfilterproxymodeltest.cpp +++ b/tests/searchfilterproxymodeltest.cpp @@ -49,6 +49,9 @@ private slots: void testSpecialCharacters(); void testSharedModelAdapter(); void testCategorySortSignalOrder(); + void testCategorizedSortAlphabetarySections(); + void testCategorizedSortDDECategorySections(); + void testCategorizedSortRoleNameAndCategoryType(); private: void setupTestData(); @@ -432,5 +435,64 @@ void TestSearchFilterProxyModel::testCategorySortSignalOrder() QStringLiteral("category") })); } +void TestSearchFilterProxyModel::testCategorizedSortAlphabetarySections() +{ + // Exercise alphabetarySections(): collect uppercased first chars of transliterated names. + auto &model = CategorizedSortProxyModel::instance(); + model.setCategoryType(CategorizedSortProxyModel::Alphabetary); + + const auto sections = model.alphabetarySections(); + // The test data contains English apps (Calculator, Editor, Browser, etc.) + // and Chinese apps (音乐, etc.). Each transliterated name starts with a + // letter; the set should be non-empty and sorted (with &/# at front if present). + QVERIFY(!sections.isEmpty()); + // Verify the returned list is sorted by the custom comparator (& < # < letters) + for (int i = 1; i < sections.size(); ++i) { + const QString &prev = sections[i - 1]; + const QString &curr = sections[i]; + // customLessThan: & < #, then normal < + if (prev == "&" && curr == "#") + continue; // & before # is valid + QVERIFY(prev != "#" || curr != "&"); // # before & would be wrong order + QVERIFY(curr >= prev || (prev == "&" && curr == "#")); + } +} + +void TestSearchFilterProxyModel::testCategorizedSortDDECategorySections() +{ + // Exercise DDECategorySections(): collect distinct DDECategory values. + auto &model = CategorizedSortProxyModel::instance(); + model.setCategoryType(CategorizedSortProxyModel::DDECategory); + + const auto sections = model.DDECategorySections(); + // The test data has items with DDECategoryRole set (via SourceDDECategoryRole % 11) + // so the section list should be non-empty and sorted ascending. + QVERIFY(!sections.isEmpty()); + // Verify sorted ascending + for (int i = 1; i < sections.size(); ++i) + QVERIFY(sections[i] >= sections[i - 1]); +} + +void TestSearchFilterProxyModel::testCategorizedSortRoleNameAndCategoryType() +{ + // Exercise sortRoleName() and categoryType() getter — all 3 branches. + auto &model = CategorizedSortProxyModel::instance(); + + // Set to DDECategory and verify getter returns DDECategory + model.setCategoryType(CategorizedSortProxyModel::DDECategory); + QCOMPARE(int(model.categoryType()), int(CategorizedSortProxyModel::DDECategory)); + // sortRoleName should return the name of the DDECategoryRole + QCOMPARE(model.sortRoleName(), QStringLiteral("category")); + + // Set to Alphabetary and verify getter returns Alphabetary + model.setCategoryType(CategorizedSortProxyModel::Alphabetary); + QCOMPARE(int(model.categoryType()), int(CategorizedSortProxyModel::Alphabetary)); + QCOMPARE(model.sortRoleName(), QStringLiteral("transliterated")); + + // FreeCategory branch: when categoryType is FreeCategory, isFreeSort=true + model.setCategoryType(CategorizedSortProxyModel::FreeCategory); + QCOMPARE(int(model.categoryType()), int(CategorizedSortProxyModel::FreeCategory)); +} + QTEST_MAIN(TestSearchFilterProxyModel) #include "searchfilterproxymodeltest.moc" diff --git a/tests/sortproxymodeltest.cpp b/tests/sortproxymodeltest.cpp new file mode 100644 index 00000000..8dc99443 --- /dev/null +++ b/tests/sortproxymodeltest.cpp @@ -0,0 +1,488 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include +#include + +#include "../src/models/sortproxymodel.h" + +namespace { +Q_LOGGING_CATEGORY(logTest, "dde.launchpad.test") + +// A minimal list model that allows changing data WITHOUT auto-emitting dataChanged, +// so we can control exactly which roles trigger handleDataChanged. This isolates +// the empty-roles path of SortProxyModel::handleDataChanged from the sort-role path. +class ControllableStringListModel : public QAbstractListModel +{ +public: + explicit ControllableStringListModel(const QStringList &items, QObject *parent = nullptr) + : QAbstractListModel(parent), m_items(items) {} + int rowCount(const QModelIndex & = QModelIndex()) const override { return m_items.size(); } + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override + { + if (!index.isValid() || role != Qt::DisplayRole) + return {}; + return m_items.value(index.row()); + } + void setDataSilent(int row, const QString &value) { m_items[row] = value; } + void emitDataChanged(int row, const QVector &roles = {}) + { + const QModelIndex idx = index(row, 0); + emit dataChanged(idx, idx, roles); + } +private: + QStringList m_items; +}; +} + +class TestSortProxyModel : public QObject +{ + Q_OBJECT +private slots: + void ascendingAndDescendingOrder(); + void mappingBetweenSourceAndProxy(); + void caseSensitivity(); + void sortByCustomRole(); + void rowsInsertedKeepsOrder(); + void rowsRemovedKeepsOrder(); + void dataChangedReorders(); + void replaceSourceModelResets(); + void sortColumnMinusOneRestoresNaturalOrder(); + void setSortColumnToSameIsNoop(); + void indexWithNoSourceModel(); + void indexWithOutOfRangeColumn(); + void dataWithInvalidProxyIndex(); + void mapToSourceWithInvalidIndex(); + void mapFromSourceWithInvalidIndex(); + void mapFromSourceWithParentReturnsEmpty(); + void handleModelReset(); + void removeMultipleConsecutiveRows(); + void dataChangedWithEmptyRolesReorders(); + void dataChangedWithNonSortRoleDoesNotReorder(); + void columnCountWithAndWithoutSource(); + void sortColumnAndSortOrderGetters(); +}; + +void TestSortProxyModel::ascendingAndDescendingOrder() +{ + qCInfo(logTest) << "SortProxyModel should sort ascending and descending by the display role"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("cherry"))); + source.appendRow(new QStandardItem(QStringLiteral("apple"))); + source.appendRow(new QStandardItem(QStringLiteral("banana"))); + + SortProxyModel proxy; + proxy.setSourceModel(&source); + proxy.sort(0, Qt::AscendingOrder); + + QCOMPARE(proxy.rowCount(), 3); + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("apple")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("banana")); + QCOMPARE(proxy.data(proxy.index(2, 0), Qt::DisplayRole).toString(), QStringLiteral("cherry")); + + proxy.sort(0, Qt::DescendingOrder); + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("cherry")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("banana")); + QCOMPARE(proxy.data(proxy.index(2, 0), Qt::DisplayRole).toString(), QStringLiteral("apple")); +} + +void TestSortProxyModel::mappingBetweenSourceAndProxy() +{ + qCInfo(logTest) << "mapFromSource/mapToSource should be consistent with the sort order"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("cherry"))); // source row 0 + source.appendRow(new QStandardItem(QStringLiteral("apple"))); // source row 1 + source.appendRow(new QStandardItem(QStringLiteral("banana"))); // source row 2 + + SortProxyModel proxy; + proxy.setSourceModel(&source); + proxy.sort(0, Qt::AscendingOrder); + // expected proxy order: apple(1), banana(2), cherry(0) + + QCOMPARE(proxy.mapFromSource(source.index(0, 0)).row(), 2); // cherry -> proxy 2 + QCOMPARE(proxy.mapFromSource(source.index(1, 0)).row(), 0); // apple -> proxy 0 + + QCOMPARE(proxy.mapToSource(proxy.index(0, 0)).row(), 1); // proxy 0 -> apple + QCOMPARE(proxy.mapToSource(proxy.index(2, 0)).row(), 0); // proxy 2 -> cherry +} + +void TestSortProxyModel::caseSensitivity() +{ + qCInfo(logTest) << "SortProxyModel should honor sortCaseSensitivity"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("apple"))); + source.appendRow(new QStandardItem(QStringLiteral("Banana"))); + source.appendRow(new QStandardItem(QStringLiteral("cherry"))); + + SortProxyModel proxy; + proxy.setSourceModel(&source); + QCOMPARE(proxy.sortCaseSensitivity(), Qt::CaseSensitive); + proxy.sort(0, Qt::AscendingOrder); + // case sensitive: 'B'(66) sorts before 'a'(97) + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("Banana")); + + proxy.setSortCaseSensitivity(Qt::CaseInsensitive); + QCOMPARE(proxy.sortCaseSensitivity(), Qt::CaseInsensitive); + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("apple")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("Banana")); +} + +void TestSortProxyModel::sortByCustomRole() +{ + qCInfo(logTest) << "SortProxyModel should sort by a custom sortRole"; + constexpr int WeightRole = Qt::UserRole + 1; + QStandardItemModel source; + source.setItemRoleNames({{Qt::DisplayRole, QByteArrayLiteral("display")}, + {WeightRole, QByteArrayLiteral("weight")}}); + + auto makeItem = [](const QString &name, int weight) { + auto item = new QStandardItem(name); + item->setData(weight, WeightRole); + return item; + }; + source.appendRow(makeItem(QStringLiteral("heavy"), 30)); + source.appendRow(makeItem(QStringLiteral("light"), 5)); + source.appendRow(makeItem(QStringLiteral("medium"), 20)); + + SortProxyModel proxy; + proxy.setSourceModel(&source); + proxy.setSortRole(WeightRole); + proxy.sort(0, Qt::AscendingOrder); + QCOMPARE(proxy.sortRole(), WeightRole); + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("light")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("medium")); + QCOMPARE(proxy.data(proxy.index(2, 0), Qt::DisplayRole).toString(), QStringLiteral("heavy")); +} + +void TestSortProxyModel::rowsInsertedKeepsOrder() +{ + qCInfo(logTest) << "Inserted source rows should appear at their sorted position"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("apple"))); + source.appendRow(new QStandardItem(QStringLiteral("cherry"))); + + SortProxyModel proxy; + proxy.setSourceModel(&source); + proxy.sort(0, Qt::AscendingOrder); + QCOMPARE(proxy.rowCount(), 2); + + QSignalSpy insertedSpy(&proxy, &QAbstractItemModel::rowsInserted); + source.appendRow(new QStandardItem(QStringLiteral("banana"))); + QVERIFY(insertedSpy.count() >= 1); + QCOMPARE(proxy.rowCount(), 3); + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("apple")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("banana")); + QCOMPARE(proxy.data(proxy.index(2, 0), Qt::DisplayRole).toString(), QStringLiteral("cherry")); +} + +void TestSortProxyModel::rowsRemovedKeepsOrder() +{ + qCInfo(logTest) << "Removed source rows should disappear while keeping the rest sorted"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("apple"))); + source.appendRow(new QStandardItem(QStringLiteral("banana"))); + source.appendRow(new QStandardItem(QStringLiteral("cherry"))); + + SortProxyModel proxy; + proxy.setSourceModel(&source); + proxy.sort(0, Qt::AscendingOrder); + + QSignalSpy removedSpy(&proxy, &QAbstractItemModel::rowsRemoved); + source.removeRow(1); // remove "banana" + QVERIFY(removedSpy.count() >= 1); + QCOMPARE(proxy.rowCount(), 2); + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("apple")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("cherry")); +} + +void TestSortProxyModel::dataChangedReorders() +{ + qCInfo(logTest) << "Changing source data should reorder the proxy"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("apple"))); + source.appendRow(new QStandardItem(QStringLiteral("banana"))); + source.appendRow(new QStandardItem(QStringLiteral("cherry"))); + + SortProxyModel proxy; + proxy.setSourceModel(&source); + proxy.sort(0, Qt::AscendingOrder); + // order: apple, banana, cherry + + source.item(1)->setText(QStringLiteral("zzz")); + // expected order after re-sort: apple, cherry, zzz + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("apple")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("cherry")); + QCOMPARE(proxy.data(proxy.index(2, 0), Qt::DisplayRole).toString(), QStringLiteral("zzz")); +} + +void TestSortProxyModel::replaceSourceModelResets() +{ + qCInfo(logTest) << "Replacing the source model should reset to the new rows"; + QStandardItemModel sourceA; + sourceA.appendRow(new QStandardItem(QStringLiteral("zeta"))); + sourceA.appendRow(new QStandardItem(QStringLiteral("alpha"))); + + SortProxyModel proxy; + proxy.setSourceModel(&sourceA); + proxy.sort(0, Qt::AscendingOrder); + QCOMPARE(proxy.rowCount(), 2); + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("alpha")); + + QStandardItemModel sourceB; + sourceB.appendRow(new QStandardItem(QStringLiteral("delta"))); + sourceB.appendRow(new QStandardItem(QStringLiteral("bravo"))); + sourceB.appendRow(new QStandardItem(QStringLiteral("charlie"))); + proxy.setSourceModel(&sourceB); + proxy.sort(0, Qt::AscendingOrder); + QCOMPARE(proxy.rowCount(), 3); + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("bravo")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("charlie")); + QCOMPARE(proxy.data(proxy.index(2, 0), Qt::DisplayRole).toString(), QStringLiteral("delta")); +} + +void TestSortProxyModel::sortColumnMinusOneRestoresNaturalOrder() +{ + qCInfo(logTest) << "sort(-1) should disable sorting and restore natural source order"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("cherry"))); // source row 0 + source.appendRow(new QStandardItem(QStringLiteral("apple"))); // source row 1 + source.appendRow(new QStandardItem(QStringLiteral("banana"))); // source row 2 + + SortProxyModel proxy; + proxy.setSourceModel(&source); + proxy.sort(0, Qt::AscendingOrder); + // ascending: apple(1), banana(2), cherry(0) + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("apple")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("banana")); + QCOMPARE(proxy.data(proxy.index(2, 0), Qt::DisplayRole).toString(), QStringLiteral("cherry")); + + // column -1 disables sorting: reorder() falls back to identity (natural source order) + proxy.sort(-1); + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("cherry")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("apple")); + QCOMPARE(proxy.data(proxy.index(2, 0), Qt::DisplayRole).toString(), QStringLiteral("banana")); +} + +void TestSortProxyModel::setSortColumnToSameIsNoop() +{ + qCInfo(logTest) << "setSortColumn to the same column should be a noop (no signal, no reorder)"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("cherry"))); + source.appendRow(new QStandardItem(QStringLiteral("apple"))); + source.appendRow(new QStandardItem(QStringLiteral("banana"))); + + SortProxyModel proxy; + proxy.setSourceModel(&source); + proxy.sort(0, Qt::AscendingOrder); + QCOMPARE(proxy.sortColumn(), 0); + QCOMPARE(proxy.sortOrder(), Qt::AscendingOrder); + + // setSortColumn to the same column -> no signal emitted + QSignalSpy spy(&proxy, &SortProxyModel::sortColumnChanged); + proxy.setSortColumn(0); + QCOMPARE(spy.count(), 0); + + // order unchanged + QCOMPARE(proxy.rowCount(), 3); + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("apple")); +} + +void TestSortProxyModel::indexWithNoSourceModel() +{ + qCInfo(logTest) << "index() with no source model should return invalid"; + SortProxyModel proxy; + QVERIFY(!proxy.index(0, 0).isValid()); + QCOMPARE(proxy.rowCount(), 0); +} + +void TestSortProxyModel::indexWithOutOfRangeColumn() +{ + qCInfo(logTest) << "index() with out-of-range column should return invalid"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("a"))); + + SortProxyModel proxy; + proxy.setSourceModel(&source); + QVERIFY(!proxy.index(0, 5).isValid()); // column 5 out of range + QVERIFY(!proxy.index(0, -1).isValid()); // negative column +} + +void TestSortProxyModel::dataWithInvalidProxyIndex() +{ + qCInfo(logTest) << "data() with invalid proxy index should return empty QVariant"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("a"))); + + SortProxyModel proxy; + proxy.setSourceModel(&source); + QVERIFY(!proxy.data(QModelIndex(), Qt::DisplayRole).isValid()); +} + +void TestSortProxyModel::mapToSourceWithInvalidIndex() +{ + qCInfo(logTest) << "mapToSource() with invalid index should return invalid"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("a"))); + + SortProxyModel proxy; + proxy.setSourceModel(&source); + QVERIFY(!proxy.mapToSource(QModelIndex()).isValid()); +} + +void TestSortProxyModel::mapFromSourceWithInvalidIndex() +{ + qCInfo(logTest) << "mapFromSource() with invalid index should return invalid"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("a"))); + + SortProxyModel proxy; + proxy.setSourceModel(&source); + QVERIFY(!proxy.mapFromSource(QModelIndex()).isValid()); +} + +void TestSortProxyModel::mapFromSourceWithParentReturnsEmpty() +{ + qCInfo(logTest) << "mapFromSource() with a parent index should return empty (flat model)"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("a"))); + + SortProxyModel proxy; + proxy.setSourceModel(&source); + // a child index (has parent) should return empty + QModelIndex childIdx = source.index(0, 0, source.index(0, 0)); + if (childIdx.isValid()) { + QVERIFY(!proxy.mapFromSource(childIdx).isValid()); + } +} + +void TestSortProxyModel::handleModelReset() +{ + qCInfo(logTest) << "source model reset should reset the proxy"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("cherry"))); + source.appendRow(new QStandardItem(QStringLiteral("apple"))); + + SortProxyModel proxy; + proxy.setSourceModel(&source); + proxy.sort(0, Qt::AscendingOrder); + QCOMPARE(proxy.rowCount(), 2); + + // reset source model + source.clear(); + QCOMPARE(proxy.rowCount(), 0); + + // add items again + source.appendRow(new QStandardItem(QStringLiteral("banana"))); + source.appendRow(new QStandardItem(QStringLiteral("apple"))); + QCOMPARE(proxy.rowCount(), 2); + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("apple")); +} + +void TestSortProxyModel::removeMultipleConsecutiveRows() +{ + qCInfo(logTest) << "removing multiple consecutive source rows should keep proxy sorted"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("a"))); + source.appendRow(new QStandardItem(QStringLiteral("b"))); + source.appendRow(new QStandardItem(QStringLiteral("c"))); + source.appendRow(new QStandardItem(QStringLiteral("d"))); + source.appendRow(new QStandardItem(QStringLiteral("e"))); + + SortProxyModel proxy; + proxy.setSourceModel(&source); + proxy.sort(0, Qt::AscendingOrder); + QCOMPARE(proxy.rowCount(), 5); + + // remove rows 1 and 2 (b and c) in one operation + source.removeRows(1, 2); + QCOMPARE(proxy.rowCount(), 3); + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("a")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("d")); + QCOMPARE(proxy.data(proxy.index(2, 0), Qt::DisplayRole).toString(), QStringLiteral("e")); +} + +void TestSortProxyModel::dataChangedWithEmptyRolesReorders() +{ + qCInfo(logTest) << "dataChanged with empty roles should trigger reorder (isolated from sort-role path)"; + // Use ControllableStringListModel to change data WITHOUT auto-emitting dataChanged, + // then emit dataChanged with empty roles to trigger the empty-roles reorder path. + ControllableStringListModel source({QStringLiteral("apple"), + QStringLiteral("banana"), + QStringLiteral("cherry")}); + + SortProxyModel proxy; + proxy.setSourceModel(&source); + proxy.sort(0, Qt::AscendingOrder); + // order: apple(0), banana(1), cherry(2) + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("apple")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("banana")); + QCOMPARE(proxy.data(proxy.index(2, 0), Qt::DisplayRole).toString(), QStringLiteral("cherry")); + + // silently change banana to zzz (no dataChanged emitted -> no auto-reorder) + source.setDataSilent(1, QStringLiteral("zzz")); + + // now emit dataChanged with empty roles -> handleDataChanged sees roles.isEmpty() -> reorder + source.emitDataChanged(1, {}); + + // expected order after reorder: apple(0), cherry(2), zzz(1) + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("apple")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("cherry")); + QCOMPARE(proxy.data(proxy.index(2, 0), Qt::DisplayRole).toString(), QStringLiteral("zzz")); +} + +void TestSortProxyModel::dataChangedWithNonSortRoleDoesNotReorder() +{ + qCInfo(logTest) << "dataChanged with non-sort role should not reorder"; + constexpr int UserRole1 = Qt::UserRole + 1; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("apple"))); + source.appendRow(new QStandardItem(QStringLiteral("banana"))); + + SortProxyModel proxy; + proxy.setSourceModel(&source); + proxy.sort(0, Qt::AscendingOrder); + // order: apple, banana + + // change a non-sort role on apple -> should NOT reorder + source.item(0)->setData(42, UserRole1); + emit source.dataChanged(source.index(0, 0), source.index(0, 0), { UserRole1 }); + + // order unchanged + QCOMPARE(proxy.data(proxy.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("apple")); + QCOMPARE(proxy.data(proxy.index(1, 0), Qt::DisplayRole).toString(), QStringLiteral("banana")); +} + +void TestSortProxyModel::columnCountWithAndWithoutSource() +{ + qCInfo(logTest) << "columnCount should return source column count or 0"; + SortProxyModel proxy; + QCOMPARE(proxy.columnCount(), 0); + + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("a"))); + proxy.setSourceModel(&source); + QCOMPARE(proxy.columnCount(), 1); +} + +void TestSortProxyModel::sortColumnAndSortOrderGetters() +{ + qCInfo(logTest) << "sortColumn and sortOrder getters should return current values"; + QStandardItemModel source; + source.appendRow(new QStandardItem(QStringLiteral("a"))); + + SortProxyModel proxy; + proxy.setSourceModel(&source); + QCOMPARE(proxy.sortColumn(), -1); // not yet sorted + QCOMPARE(proxy.sortOrder(), Qt::AscendingOrder); // default + + proxy.sort(0, Qt::DescendingOrder); + QCOMPARE(proxy.sortColumn(), 0); + QCOMPARE(proxy.sortOrder(), Qt::DescendingOrder); +} + +QTEST_MAIN(TestSortProxyModel) +#include "sortproxymodeltest.moc"