Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .clang-tidy
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
# Discipline"). clangd reads this automatically for in-editor hints; to silence
# naming noise temporarily, comment out `readability-identifier-naming` in Checks.
# Naming rules mirror AGENT.md "Code Style": PascalCase types, camelCase methods,
# snake_case files/variables, UPPER_CASE constants, lower_case namespaces.
# snake_case files/variables, kCamelCase constants (Google-style 'k' + CamelCase),
# CamelCase enum constants, lower_case namespaces.
---
Checks: >
-*,
Expand Down Expand Up @@ -71,7 +72,7 @@ CheckOptions:
- { key: readability-identifier-naming.StructCase, value: CamelCase }
- { key: readability-identifier-naming.UnionCase, value: CamelCase }
- { key: readability-identifier-naming.EnumCase, value: CamelCase }
- { key: readability-identifier-naming.EnumConstantCase, value: UPPER_CASE }
- { key: readability-identifier-naming.EnumConstantCase, value: CamelCase }
- { key: readability-identifier-naming.TypedefCase, value: CamelCase }
- { key: readability-identifier-naming.TypeAliasCase, value: CamelCase }
- { key: readability-identifier-naming.FunctionCase, value: camelBack }
Expand All @@ -80,7 +81,7 @@ CheckOptions:
- { key: readability-identifier-naming.VariableCase, value: camelBack }
- { key: readability-identifier-naming.PrivateMemberPrefix, value: m_ }
- { key: readability-identifier-naming.PrivateMemberCase, value: camelBack }
- { key: readability-identifier-naming.ConstantCase, value: UPPER_CASE }
- { key: readability-identifier-naming.GlobalConstantCase, value: UPPER_CASE }
- { key: readability-identifier-naming.GlobalConstantCase, value: CamelCase }
- { key: readability-identifier-naming.GlobalConstantPrefix, value: k }
- { key: readability-identifier-naming.NamespaceCase, value: lower_case }
- { key: readability-identifier-naming.FileIgnoringCase, value: lower_case }
3 changes: 3 additions & 0 deletions test/desktop/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,8 @@ add_subdirectory(wallpaper_animation)
# Add launcher (app discovery) tests subdirectory
add_subdirectory(launcher)

# Add desktop icon layer (grid math) tests subdirectory
add_subdirectory(desktop_icon_layer)

# Add builtin apps registry tests subdirectory
add_subdirectory(builtin_apps)
34 changes: 34 additions & 0 deletions test/desktop/desktop_icon_layer/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# DesktopIconLayer grid math unit tests.
# Exercises the pure computeGridDimensions() function only (no QWidget),
# but links the full static lib for symbol resolution.
add_gtest_executable(
TEST_NAME desktop_icon_layer_test
SOURCE_FILE desktop_icon_layer_test.cpp
LINK_LIBRARIES
cfdesktop_desktop_icon_layer
cflogger
cfbase
Qt6::Core
Qt6::Widgets
GTest::gtest
GTest::gtest_main
INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/desktop/ui/components
LABELS "desktop;unit;desktop_icon_layer"
LOG_MODULE desktop_icon_layer_tests
)

# DesktopShortcutStore JSON + seed unit tests (pure logic, no ConfigStore IO).
add_gtest_executable(
TEST_NAME desktop_shortcut_store_test
SOURCE_FILE desktop_shortcut_store_test.cpp
LINK_LIBRARIES
cfdesktop_desktop_icon_layer
cflogger
cfbase
Qt6::Core
GTest::gtest
GTest::gtest_main
INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/desktop/ui/components
LABELS "desktop;unit;desktop_icon_layer"
LOG_MODULE desktop_icon_layer_tests
)
115 changes: 115 additions & 0 deletions test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* @file test/desktop/desktop_icon_layer/desktop_icon_layer_test.cpp
* @brief GoogleTest unit tests for computeGridDimensions.
*
* Exercises the pure grid-sizing math (column floor, row capacity, adaptive
* tile scaling, floor-and-truncate) without instantiating a QWidget. Layout
* constants mirror the anonymous namespace in desktop_icon_layer.cpp:
* kCellMax=96, kCellMin=48, kGridSpacing=16, kMargin=24 (at cell=96 the stride
* is 96+16=112; usable = side - 2*24 + 16 = side - 32). When app_count exceeds
* the cell=96 capacity, computeGridDimensions scans cell from 96 down to 48 and
* picks the largest edge whose columns*rows still fits, truncating only when
* even cell=48 cannot fit everything.
*
* @author Charliechen114514 (chengh1922@mails.jlu.edu.cn)
* @date 2026-07-07
* @version 0.2
* @since 0.20
* @ingroup components
*/

#include "desktop_icon_layer/desktop_icon_layer.h"

#include <QSize>

#include <gtest/gtest.h>

using cf::desktop::desktop_component::computeGridDimensions;

TEST(DesktopIconLayerGrid, InvalidSizeReturnsZero) {
const auto d = computeGridDimensions(QSize(0, 0), 10);
EXPECT_EQ(d.columns, 0);
EXPECT_EQ(d.rows, 0);
EXPECT_EQ(d.shown, 0);
EXPECT_EQ(d.cell, 0);
}

TEST(DesktopIconLayerGrid, InvalidCountReturnsZero) {
const auto d = computeGridDimensions(QSize(1280, 720), 0);
EXPECT_EQ(d.shown, 0);
EXPECT_EQ(d.cell, 0);
}

TEST(DesktopIconLayerGrid, NegativeCountReturnsZero) {
const auto d = computeGridDimensions(QSize(1280, 720), -3);
EXPECT_EQ(d.shown, 0);
EXPECT_EQ(d.cell, 0);
}

TEST(DesktopIconLayerGrid, ColumnsFillWideWidth) {
// usable_w = 1280 - 32 = 1248; at cell=96 stride=112 -> 1248/112 = 11 cols.
// count=5 fits at cell=96, so no shrink.
const auto d = computeGridDimensions(QSize(1280, 720), 5);
EXPECT_EQ(d.columns, 11);
EXPECT_EQ(d.cell, 96);
}

TEST(DesktopIconLayerGrid, RowsAndCapacityForTypicalDesktop) {
// usable_h = 720 - 32 = 688; at cell=96 -> 688/112 = 6 rows.
const auto d = computeGridDimensions(QSize(1280, 720), 5);
EXPECT_EQ(d.rows, 6);
EXPECT_EQ(d.shown, 5); // Below capacity: all shown.
EXPECT_EQ(d.cell, 96);
}

TEST(DesktopIconLayerGrid, ScalesToFitWhenShrunk) {
// 1280x720, count=100. At cell=96 capacity is 11*6=66 < 100, so the scan
// shrinks the tile. cell=70 (stride 86): cols=floor(1248/86)=14,
// rows=floor(688/86)=8, capacity 112 >= 100. cell=71 (stride 87) drops rows
// to 7 -> 14*7=98 < 100, so 70 is the largest fitting edge. All 100 shown.
const auto d = computeGridDimensions(QSize(1280, 720), 100);
EXPECT_EQ(d.cell, 70);
EXPECT_EQ(d.columns, 14);
EXPECT_EQ(d.rows, 8);
EXPECT_EQ(d.shown, 100);
}

TEST(DesktopIconLayerGrid, TruncatesOnlyAtFloor) {
// 300x300, count=100. usable 268x268. Even cell=48 (stride 64) fits only
// floor(268/64)=4 cols * 4 rows = 16 < 100, so the scan exhausts and cell
// stays at the 48 floor; shown is honestly truncated to capacity.
const auto d = computeGridDimensions(QSize(300, 300), 100);
EXPECT_EQ(d.cell, 48);
EXPECT_EQ(d.columns, 4);
EXPECT_EQ(d.rows, 4);
EXPECT_EQ(d.shown, 16);
}

TEST(DesktopIconLayerGrid, NarrowWidthFloorOneColumn) {
// usable_w = 200 - 32 = 168; at cell=96 -> 168/112 = 1.
const auto d = computeGridDimensions(QSize(200, 720), 3);
EXPECT_EQ(d.columns, 1);
EXPECT_EQ(d.cell, 96);
}

TEST(DesktopIconLayerGrid, ExtremelyNarrowStillOneColumn) {
// usable_w = 50 - 32 = 18; at cell=96 -> 18/112 = 0 -> floored to 1.
const auto d = computeGridDimensions(QSize(50, 720), 3);
EXPECT_EQ(d.columns, 1);
EXPECT_EQ(d.cell, 96);
}

TEST(DesktopIconLayerGrid, ZeroHeightReportsNoLayout) {
// usable_h = 50 - 32 = 18; rows=0 at every cell -> capacity 0. cell reports
// 0 (no valid layout), not the floor, since no tile can be placed.
const auto d = computeGridDimensions(QSize(1280, 50), 10);
EXPECT_EQ(d.rows, 0);
EXPECT_EQ(d.shown, 0);
EXPECT_EQ(d.cell, 0);
}

TEST(DesktopIconLayerGrid, SingleAppShown) {
const auto d = computeGridDimensions(QSize(1280, 720), 1);
EXPECT_EQ(d.shown, 1);
EXPECT_EQ(d.cell, 96);
}
98 changes: 98 additions & 0 deletions test/desktop/desktop_icon_layer/desktop_shortcut_store_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* @file test/desktop/desktop_icon_layer/desktop_shortcut_store_test.cpp
* @brief GoogleTest unit tests for DesktopShortcutStore.
*
* Covers JSON serialize/deserialize round-trip, malformed/empty input, empty
* app_id filtering, and seedFrom() grid layout + cap. load()/save() (real
* ConfigStore IO) are intentionally not covered here to avoid polluting the
* host config; the serialization layer they delegate to is fully tested.
*
* @author Charliechen114514 (chengh1922@mails.jlu.edu.cn)
* @date 2026-07-07
* @version 0.1
* @since 0.20
* @ingroup components
*/

#include "desktop_icon_layer/desktop_shortcut.h"
#include "desktop_icon_layer/desktop_shortcut_store.h"

#include <QByteArray>
#include <QString>

#include <gtest/gtest.h>

using cf::desktop::desktop_component::AppEntry;
using cf::desktop::desktop_component::DesktopShortcut;
using cf::desktop::desktop_component::DesktopShortcutStore;

TEST(DesktopShortcutStore, SerializeDeserializeRoundTrip) {
QList<DesktopShortcut> in;
in.append({QStringLiteral("about"), 0, 0});
in.append({QStringLiteral("calculator"), 1, 0});
in.append({QStringLiteral("firefox"), 0, 1});
const QByteArray bytes = DesktopShortcutStore::serialize(in);
const QList<DesktopShortcut> out = DesktopShortcutStore::deserialize(bytes);
ASSERT_EQ(out.size(), in.size());
for (int i = 0; i < in.size(); ++i) {
EXPECT_EQ(out[i].app_id.toStdString(), in[i].app_id.toStdString());
EXPECT_EQ(out[i].col, in[i].col);
EXPECT_EQ(out[i].row, in[i].row);
}
}

TEST(DesktopShortcutStore, DeserializeEmptyReturnsEmpty) {
EXPECT_TRUE(DesktopShortcutStore::deserialize(QByteArray()).isEmpty());
}

TEST(DesktopShortcutStore, DeserializeMalformedReturnsEmpty) {
EXPECT_TRUE(DesktopShortcutStore::deserialize(QByteArray("not json{")).isEmpty());
}

TEST(DesktopShortcutStore, DeserializeSkipsEmptyAppId) {
const QByteArray bytes = R"([{"app_id":"","col":0,"row":0},{"app_id":"x","col":1,"row":0}])";
const auto out = DesktopShortcutStore::deserialize(bytes);
ASSERT_EQ(out.size(), 1);
EXPECT_EQ(out[0].app_id.toStdString(), "x");
}

TEST(DesktopShortcutStore, SeedFromLaysOutInGridOrder) {
QList<AppEntry> apps;
for (int i = 0; i < 5; ++i) {
AppEntry e;
e.app_id = QStringLiteral("app%1").arg(i);
apps.append(e);
}
const auto seed = DesktopShortcutStore::seedFrom(apps, 8);
ASSERT_EQ(seed.size(), 5);
// kSeedCols = 8, so the first 8 land in row 0, left-to-right.
for (int i = 0; i < 5; ++i) {
EXPECT_EQ(seed[i].app_id.toStdString(), "app" + std::to_string(i));
EXPECT_EQ(seed[i].col, i);
EXPECT_EQ(seed[i].row, 0);
}
}

TEST(DesktopShortcutStore, SeedFromWrapsToSecondRow) {
QList<AppEntry> apps;
for (int i = 0; i < 10; ++i) {
AppEntry e;
e.app_id = QStringLiteral("a%1").arg(i);
apps.append(e);
}
const auto seed = DesktopShortcutStore::seedFrom(apps, 8);
ASSERT_EQ(seed.size(), 10);
// Index 8 wraps to col 0, row 1.
EXPECT_EQ(seed[8].col, 0);
EXPECT_EQ(seed[8].row, 1);
}

TEST(DesktopShortcutStore, SeedFromCapsAtLimit) {
QList<AppEntry> apps;
for (int i = 0; i < 100; ++i) {
AppEntry e;
e.app_id = QStringLiteral("a%1").arg(i);
apps.append(e);
}
EXPECT_EQ(DesktopShortcutStore::seedFrom(apps, 8).size(), 12); // kSeedCount
}
19 changes: 19 additions & 0 deletions test/desktop/launcher/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,22 @@ add_gtest_executable(
LABELS "desktop;unit;launcher"
LOG_MODULE launcher_tests
)

# resolve_app_icon unit tests. Provides its own main() (constructs a
# QGuiApplication on the offscreen QPA platform for QIcon/QPixmap), so
# GTest::gtest_main is intentionally NOT linked here.
add_gtest_executable(
TEST_NAME app_icon_resolver_test
SOURCE_FILE app_icon_resolver_test.cpp
LINK_LIBRARIES
cfdesktop_launcher
cflogger
cfbase
Qt6::Core
Qt6::Gui
Qt6::Widgets
GTest::gtest
INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/desktop/ui/components
LABELS "desktop;unit;launcher"
LOG_MODULE launcher_tests
)
74 changes: 74 additions & 0 deletions test/desktop/launcher/app_icon_resolver_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* @file test/desktop/launcher/app_icon_resolver_test.cpp
* @brief GoogleTest unit tests for resolve_app_icon.
*
* Covers the resolution chain: empty path -> null, nonexistent absolute path
* -> null, bogus theme name -> null (graceful when no icon theme matches),
* and a real Qt resource path loads. QIcon/QPixmap require a QGuiApplication,
* so this file provides its own main() that constructs one on the offscreen
* QPA platform (headless-safe under ctest).
*
* @author Charliechen114514 (chengh1922@mails.jlu.edu.cn)
* @date 2026-07-07
* @version 0.1
* @since 0.20
* @ingroup components
*/

#include "launcher/app_icon_resolver.h"

#include <QGuiApplication>
#include <QPixmap>
#include <QSize>
#include <QString>

#include <gtest/gtest.h>

using cf::desktop::desktop_component::AppEntry;
using cf::desktop::desktop_component::resolve_app_icon;

namespace {
/// Builds a minimal AppEntry with only icon_path set (all the resolver reads).
AppEntry makeEntry(const QString& icon_path) {
AppEntry e;
e.app_id = QStringLiteral("test");
e.display_name = QStringLiteral("Test");
e.icon_path = icon_path;
return e;
}
} // namespace

TEST(AppIconResolver, EmptyPathReturnsNull) {
EXPECT_TRUE(resolve_app_icon(makeEntry(QString()), QSize(48, 48)).isNull());
}

TEST(AppIconResolver, NonexistentAbsolutePathReturnsNull) {
// Path-like value that fails to load returns null (no theme fall-through).
EXPECT_TRUE(resolve_app_icon(makeEntry(QStringLiteral("/nonexistent/cfdesktop/icon.png")),
QSize(48, 48))
.isNull());
}

TEST(AppIconResolver, BogusThemeNameReturnsNull) {
// A non-path value is treated as a theme name; an unknown name yields a
// null icon -> null pixmap. Proves graceful degradation, not a crash.
const auto pm =
resolve_app_icon(makeEntry(QStringLiteral("totally-bogus-cf-icon-xyz")), QSize(48, 48));
EXPECT_TRUE(pm.isNull());
}

TEST(AppIconResolver, ThemeLookupDoesNotCrashOnKnownName) {
// "firefox" may or may not resolve depending on the installed icon theme;
// the contract is only that the call is safe and returns something (null
// or a real pixmap) without throwing.
resolve_app_icon(makeEntry(QStringLiteral("firefox")), QSize(48, 48));
SUCCEED();
}

int main(int argc, char** argv) {
// QIcon/QPixmap need a QGuiApplication; offscreen keeps it headless for CI.
qputenv("QT_QPA_PLATFORM", "offscreen");
QGuiApplication app(argc, argv);
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
Loading
Loading