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"