// test includes #include "tests/conftest.cpp" // standard includes #include #include #include #include #include #include #include #include // lib includes #include #if defined(_WIN32) || defined(_WIN64) #include #endif // local includes #include "src/tray.h" // test includes #include "tests/notification_utils.h" #include "tests/screenshot_utils.h" constexpr const char *TRAY_ICON_ICO = "icon.ico"; constexpr const char *TRAY_ICON_PNG = "icon.png"; constexpr const char *TRAY_ICON_SVG = "icon.svg"; constexpr const char *TRAY_ICON2_ICO = "icon2.ico"; constexpr const char *TRAY_ICON2_PNG = "icon2.png"; constexpr const char *TRAY_ICON2_SVG = "icon2.svg"; constexpr const char *TRAY_ICON_THEMED = "mail-message-new"; constexpr const char *TRAY_ICON1 = TRAY_ICON_PNG; constexpr const char *TRAY_ICON2 = TRAY_ICON2_PNG; // File-scope tray data shared across all TrayTest instances namespace { struct TrayIconParam { const char *name; const char *icon; const char *alternateIcon; }; constexpr std::array TRAY_ICON_PARAMS { {{"svg", TRAY_ICON_SVG, TRAY_ICON2_SVG}, {"ico", TRAY_ICON_ICO, TRAY_ICON2_ICO}, {"png", TRAY_ICON_PNG, TRAY_ICON2_PNG}, {"themed", TRAY_ICON_THEMED, TRAY_ICON_THEMED}} }; std::string trayIconParamName(const ::testing::TestParamInfo &info) { return info.param.name; } void PrintTo(const TrayIconParam ¶m, std::ostream *os) { *os << param.name; } std::string nativeNotificationSkipReason() { #if defined(_WIN32) QUERY_USER_NOTIFICATION_STATE notification_state; if (const HRESULT ns = SHQueryUserNotificationState(¬ification_state); ns != S_OK || notification_state != QUNS_ACCEPTS_NOTIFICATIONS) { return "Notifications not accepted in this environment. SHQueryUserNotificationState result: " + std::to_string(ns) + ", state: " + std::to_string(notification_state); } #endif return {}; } } // namespace class TrayTest: public BaseTest { private: static TrayTest &fixtureFor(struct tray_menu *item) { return *static_cast(item->context); } std::array submenu7_8_ {{{.text = "7", .cb = submenu_cb, .context = this}, {.text = "-"}, {.text = "8", .cb = submenu_cb, .context = this}, {.text = nullptr}}}; std::array submenu5_6_ {{{.text = "5", .cb = submenu_cb, .context = this}, {.text = "6", .cb = submenu_cb, .context = this}, {.text = nullptr}}}; std::array submenuSecond_ {{{.text = "THIRD", .context = this, .submenu = submenu7_8_.data()}, {.text = "FOUR", .context = this, .submenu = submenu5_6_.data()}, {.text = nullptr}}}; std::array submenu_ {{{.text = "Hello", .cb = hello_cb, .context = this}, {.text = "Checked", .checked = 1, .checkbox = 1, .cb = toggle_cb, .context = this}, {.text = "Disabled", .disabled = 1}, {.text = "-"}, {.text = "SubMenu", .context = this, .submenu = submenuSecond_.data()}, {.text = "-"}, {.text = "Quit", .cb = quit_cb, .context = this}, {.text = nullptr}}}; std::array testTrayStorage_ {}; struct tray *testTray_ = ::new (static_cast(testTrayStorage_.data())) tray { .icon = TRAY_ICON1, .tooltip = "TestTray", .menu = submenu_.data(), .iconPathCount = 0, }; bool trayRunning_ {false}; protected: bool &trayRunning = trayRunning_; struct tray &testTray = *testTray_; struct tray_menu *const submenu = submenu_.data(); struct tray_menu *const submenu7_8 = submenu7_8_.data(); struct tray_menu *const submenu5_6 = submenu5_6_.data(); struct tray_menu *const submenu_second = submenuSecond_.data(); void ShutdownTray() { if (!trayRunning) { return; } // Ensure per-test notification state is cleared before teardown so // screenshot tests do not inherit prior notification popups. testTray.notification_title = nullptr; testTray.notification_text = nullptr; testTray.notification_icon = nullptr; testTray.notification_cb = nullptr; tray_update(&testTray); for (int i = 0; i < 20; ++i) { tray_loop(0); } tray_exit(); tray_loop(0); trayRunning = false; } // Dismisses the open menu from a background thread. void closeMenu() const { std::this_thread::sleep_for(std::chrono::milliseconds(100)); } // Capture a screenshot while the tray menu is open, then dismiss and exit. void captureMenuStateAndExit(const char *screenshotName) const { const bool positionMouse = lizardbyte::common::is_github_actions(); int positionMouseResult = -1; if (positionMouse) { WaitForTrayReady(); positionMouseResult = tray_position_mouse_over_icon(); EXPECT_EQ(positionMouseResult, 0); } std::atomic_bool exitRequested {false}; std::thread capture_thread([this, screenshotName, &exitRequested]() { // NOSONAR(cpp:S6168): C++17 has no std::jthread and this thread is explicitly joined EXPECT_TRUE(captureScreenshot(screenshotName)); closeMenu(); exitRequested.store(true, std::memory_order_release); }); tray_show_menu(); if (positionMouse) { const int restoreMouseResult = tray_restore_mouse_position(); if (positionMouseResult == 0) { EXPECT_EQ(restoreMouseResult, 0); } } while (tray_loop(0) == 0) { if (exitRequested.load(std::memory_order_acquire)) { tray_exit(); } std::this_thread::sleep_for(std::chrono::milliseconds(10)); } capture_thread.join(); } static void hello_cb(struct tray_menu *) { // Mock implementation } static void toggle_cb(struct tray_menu *item) { auto &fixture = fixtureFor(item); item->checked = !item->checked; tray_update(fixture.testTray_); } static void quit_cb(struct tray_menu *) { tray_exit(); } static void submenu_cb(struct tray_menu *item) { // Mock implementation tray_update(fixtureFor(item).testTray_); } void SetUp() override { BaseTest::SetUp(); // Skip tests if screenshot tooling is not available if (!ensureScreenshotReady()) { GTEST_SKIP() << "Screenshot tooling missing: " << screenshotUnavailableReason(); } if (screenshot::output_root().empty()) { GTEST_SKIP() << "Screenshot output path not initialized"; } // Ensure icon files exist in test binary directory std::filesystem::path projectRoot = testBinaryDir().parent_path(); auto ensureIconInTestDir = [&projectRoot, this](const char *iconName) { std::filesystem::path iconSource; if (std::filesystem::exists(projectRoot / "icons" / iconName)) { iconSource = projectRoot / "icons" / iconName; } else if (std::filesystem::exists(projectRoot / iconName)) { iconSource = projectRoot / iconName; } else if (std::filesystem::exists(std::filesystem::path(iconName))) { iconSource = std::filesystem::path(iconName); } if (!iconSource.empty()) { std::filesystem::path iconDest = testBinaryDir() / iconName; if (!std::filesystem::exists(iconDest)) { std::error_code ec; std::filesystem::copy_file(iconSource, iconDest, ec); if (ec) { std::cout << "Warning: Failed to copy icon file: " << ec.message() << std::endl; } } } }; auto ensureFileIconInTestDir = [&ensureIconInTestDir](const char *iconName) { if (std::filesystem::path(iconName).has_extension()) { ensureIconInTestDir(iconName); } }; for (const auto &iconParam : TRAY_ICON_PARAMS) { ensureFileIconInTestDir(iconParam.icon); ensureFileIconInTestDir(iconParam.alternateIcon); } trayRunning = false; testTray.icon = TRAY_ICON1; testTray.tooltip = "TestTray"; testTray.notification_title = nullptr; testTray.notification_text = nullptr; testTray.notification_icon = nullptr; testTray.notification_cb = nullptr; testTray.menu = submenu; submenu[1].checked = 1; } void TearDown() override { ShutdownTray(); tray_restore_mouse_position(); BaseTest::TearDown(); } // Process pending events to allow tray icon to appear. // Call this ONLY before screenshots to ensure the icon is visible. void WaitForTrayReady() const { for (int i = 0; i < 100; i++) { tray_loop(0); std::this_thread::sleep_for(std::chrono::milliseconds(5)); } } // Native tray tooltips are displayed asynchronously by the desktop shell. void WaitForTooltipReady() const { for (int i = 0; i < 40; ++i) { tray_loop(0); std::this_thread::sleep_for(std::chrono::milliseconds(50)); } } void WaitForNotificationReady() const { WaitForTrayReady(); #if defined(_WIN32) || defined(__APPLE__) if (lizardbyte::common::is_github_actions()) { for (int i = 0; i < 40; i++) { tray_loop(0); std::this_thread::sleep_for(std::chrono::milliseconds(50)); } } #endif } }; class TrayIconTest: public TrayTest, public ::testing::WithParamInterface {}; class TrayNotificationIconTest: public TrayTest, public ::testing::WithParamInterface {}; TEST_F(TrayTest, TestTrayInit) { int result = tray_init(&testTray); trayRunning = (result == 0); EXPECT_EQ(result, 0); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot("tray_icon_initial")); } TEST_P(TrayIconTest, TestTrayIconDisplay) { const auto &iconParam = GetParam(); testTray.icon = iconParam.icon; int result = tray_init(&testTray); trayRunning = (result == 0); EXPECT_EQ(result, 0); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot(std::string("tray_icon_") + iconParam.name)); } TEST_F(TrayTest, TestTrayLoop) { int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); // Test non-blocking loop (blocking=0) since blocking would hang without events int result = tray_loop(0); EXPECT_EQ(result, 0); } TEST_F(TrayTest, TestTrayUpdate) { int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); EXPECT_EQ(testTray.icon, TRAY_ICON1); // update the values testTray.icon = TRAY_ICON2; testTray.tooltip = "TestTray2"; tray_update(&testTray); EXPECT_EQ(testTray.icon, TRAY_ICON2); // put back the original values testTray.icon = TRAY_ICON1; testTray.tooltip = "TestTray"; tray_update(&testTray); EXPECT_EQ(testTray.icon, TRAY_ICON1); EXPECT_EQ(testTray.tooltip, "TestTray"); } TEST_F(TrayTest, TestToggleCallback) { int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); bool initialCheckedState = testTray.menu[1].checked; toggle_cb(&testTray.menu[1]); EXPECT_EQ(testTray.menu[1].checked, !initialCheckedState); } TEST_F(TrayTest, TestMenuItemCallback) { int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); // Test hello callback - it should work without crashing ASSERT_NE(testTray.menu[0].cb, nullptr); testTray.menu[0].cb(&testTray.menu[0]); } TEST_F(TrayTest, TestDisabledMenuItem) { int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); // Verify disabled menu item EXPECT_EQ(testTray.menu[2].disabled, 1); EXPECT_STREQ(testTray.menu[2].text, "Disabled"); } TEST_F(TrayTest, TestMenuSeparator) { int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); // Verify separator exists EXPECT_STREQ(testTray.menu[3].text, "-"); EXPECT_EQ(testTray.menu[3].cb, nullptr); } TEST_F(TrayTest, TestSubmenuStructure) { int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); // Verify submenu structure EXPECT_STREQ(testTray.menu[4].text, "SubMenu"); ASSERT_NE(testTray.menu[4].submenu, nullptr); // Verify nested submenu levels EXPECT_STREQ(testTray.menu[4].submenu[0].text, "THIRD"); ASSERT_NE(testTray.menu[4].submenu[0].submenu, nullptr); EXPECT_STREQ(testTray.menu[4].submenu[0].submenu[0].text, "7"); } TEST_F(TrayTest, TestSubmenuCallback) { int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); // Test submenu callback ASSERT_NE(testTray.menu[4].submenu[0].submenu[0].cb, nullptr); testTray.menu[4].submenu[0].submenu[0].cb(&testTray.menu[4].submenu[0].submenu[0]); } TEST_P(TrayNotificationIconTest, TestNotificationDisplay) { if (const std::string skipReason = nativeNotificationSkipReason(); !skipReason.empty()) { GTEST_SKIP() << skipReason; } const auto &iconParam = GetParam(); testTray.icon = iconParam.icon; int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); // Let the desktop shell process the new icon before sending its notification. WaitForTrayReady(); dismissNativeNotifications(); // Set notification properties testTray.notification_title = "Test Notification"; testTray.notification_text = "This is a test notification message"; testTray.notification_icon = iconParam.icon; tray_update(&testTray); WaitForNotificationReady(); EXPECT_TRUE(captureScreenshot(std::string("tray_notification_") + iconParam.name + "_icon")); // Clear notification testTray.notification_title = nullptr; testTray.notification_text = nullptr; testTray.notification_icon = nullptr; tray_update(&testTray); waitForNativeNotificationTimeout(); } TEST_P(TrayNotificationIconTest, TestNotificationCallback) { if (const std::string skipReason = nativeNotificationSkipReason(); !skipReason.empty()) { GTEST_SKIP() << skipReason; } const auto &iconParam = GetParam(); testTray.icon = iconParam.icon; static bool callbackInvoked = false; auto notification_callback = []() { callbackInvoked = true; }; int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); // Set notification with callback testTray.notification_title = "Clickable Notification"; testTray.notification_text = "Click this notification to test callback"; testTray.notification_icon = iconParam.icon; testTray.notification_cb = notification_callback; tray_update(&testTray); // Note: callback would be invoked by user interaction in a real scenario // In test environment, we verify it's set correctly EXPECT_NE(testTray.notification_cb, nullptr); // Clear notification testTray.notification_title = nullptr; testTray.notification_text = nullptr; testTray.notification_icon = nullptr; testTray.notification_cb = nullptr; tray_update(&testTray); waitForNativeNotificationTimeout(); } TEST_F(TrayTest, TestTooltipUpdate) { int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); // Test initial tooltip EXPECT_STREQ(testTray.tooltip, "TestTray"); // Update tooltip testTray.tooltip = "Updated Tooltip Text"; tray_update(&testTray); EXPECT_STREQ(testTray.tooltip, "Updated Tooltip Text"); // Restore original tooltip testTray.tooltip = "TestTray"; tray_update(&testTray); } TEST_F(TrayTest, TestTooltipDisplayOnHover) { testTray.icon = TRAY_ICON_SVG; int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); WaitForTrayReady(); ASSERT_EQ(tray_position_mouse_over_icon(), 0); WaitForTooltipReady(); EXPECT_TRUE(captureScreenshot("tray_tooltip_hover")); EXPECT_EQ(tray_restore_mouse_position(), 0); } TEST_F(TrayTest, TestMenuItemContext) { static int contextValue = 42; static bool contextCallbackInvoked = false; auto context_callback = [](struct tray_menu *item) { // NOSONAR(cpp:S995): must match tray_menu.cb signature void(*)(struct tray_menu*) if (item->context != nullptr) { const auto *value = static_cast(item->context); contextCallbackInvoked = (*value == 42); } }; // Create menu with context std::array context_menu_arr = {{{.text = "Context Item", .cb = context_callback, .context = &contextValue}, {.text = nullptr}}}; struct tray_menu *context_menu = context_menu_arr.data(); testTray.menu = context_menu; int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); // Verify context is set EXPECT_EQ(testTray.menu[0].context, &contextValue); // Invoke callback with context testTray.menu[0].cb(&testTray.menu[0]); EXPECT_TRUE(contextCallbackInvoked); // Restore original menu testTray.menu = submenu; } TEST_F(TrayTest, TestCheckboxStates) { testTray.icon = TRAY_ICON_SVG; int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); EXPECT_EQ(testTray.menu[1].checkbox, 1); EXPECT_EQ(testTray.menu[1].checked, 1); // Show menu open with checkbox in checked state captureMenuStateAndExit("tray_menu_checkbox_checked"); // Re-initialize tray with checkbox unchecked trayRunning = false; testTray.menu[1].checked = 0; initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); // Show menu open with checkbox in unchecked state captureMenuStateAndExit("tray_menu_checkbox_unchecked"); // Restore initial checked state testTray.menu[1].checked = 1; } TEST_P(TrayIconTest, TestMultipleIconUpdates) { const auto &iconParam = GetParam(); testTray.icon = iconParam.icon; int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); // Update icon multiple times testTray.icon = iconParam.alternateIcon; tray_update(&testTray); WaitForTrayReady(); EXPECT_TRUE(captureScreenshot(std::string("tray_icon_update_") + iconParam.name)); testTray.icon = iconParam.icon; tray_update(&testTray); } TEST_F(TrayTest, TestCompleteMenuHierarchy) { int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); // Verify complete menu structure int menuCount = 0; for (const struct tray_menu *m = testTray.menu; m->text != nullptr; m++) { menuCount++; } EXPECT_EQ(menuCount, 7); // Hello, Checked, Disabled, Sep, SubMenu, Sep, Quit // Verify all nested submenus ASSERT_NE(testTray.menu[4].submenu, nullptr); ASSERT_NE(testTray.menu[4].submenu[0].submenu, nullptr); ASSERT_NE(testTray.menu[4].submenu[1].submenu, nullptr); } TEST_F(TrayTest, TestIconPathArray) { int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); EXPECT_EQ(testTray.icon, TRAY_ICON1); testTray.icon = TRAY_ICON2; tray_update(&testTray); } TEST_F(TrayTest, TestQuitCallback) { int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); // Verify quit callback exists ASSERT_NE(testTray.menu[6].cb, nullptr); EXPECT_STREQ(testTray.menu[6].text, "Quit"); // Note: Actually calling quit_cb would terminate the tray, // which is tested separately in TestTrayExit } TEST_F(TrayTest, TestTrayShowMenu) { testTray.icon = TRAY_ICON_SVG; int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); // Screenshot shows the full menu open, including the SubMenu entry that leads to nested items captureMenuStateAndExit("tray_menu_shown"); } TEST_F(TrayTest, TestTrayExit) { tray_exit(); } TEST_F(TrayTest, TestMenuAppearsOnLeftClick) { // Regression test for: clicking the tray icon did not bring up the menu. // The activated(Trigger) signal was not connected to the menu popup logic. // tray_show_menu() exercises the same code path that the activated handler calls. testTray.icon = TRAY_ICON_SVG; int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); captureMenuStateAndExit("tray_menu_left_click"); } TEST_P(TrayNotificationIconTest, TestNotificationCallbackFiredOnClick) { // Regression test for: clicking a notification did not invoke the callback. // The test hook exercises the same stored callback used by Qt's messageClicked signal. const auto &iconParam = GetParam(); testTray.icon = iconParam.icon; static bool callbackInvoked = false; callbackInvoked = false; int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); testTray.notification_title = "Clickable Notification"; testTray.notification_text = "Click to test callback"; testTray.notification_icon = iconParam.icon; testTray.notification_cb = []() { callbackInvoked = true; }; tray_update(&testTray); // Allow the notification to be sent before simulating the click. WaitForTrayReady(); tray_simulate_notification_click(); tray_loop(0); EXPECT_TRUE(callbackInvoked); testTray.notification_title = nullptr; testTray.notification_text = nullptr; testTray.notification_icon = nullptr; testTray.notification_cb = nullptr; tray_update(&testTray); waitForNativeNotificationTimeout(); } TEST_F(TrayTest, TestMenuCallbackAfterNotificationUpdate) { static int callbackCount = 0; callbackCount = 0; auto first_item_callback = [](struct tray_menu *) { callbackCount++; }; void (*original_cb)(struct tray_menu *) = testTray.menu[0].cb; testTray.menu[0].cb = first_item_callback; int initResult = tray_init(&testTray); trayRunning = (initResult == 0); ASSERT_EQ(initResult, 0); tray_simulate_menu_item_click(0); tray_loop(0); EXPECT_EQ(callbackCount, 1); testTray.notification_title = "Menu Callback Regression"; testTray.notification_text = "Notification update should not break menu callbacks"; testTray.notification_icon = TRAY_ICON_SVG; tray_update(&testTray); WaitForTrayReady(); tray_simulate_menu_item_click(0); tray_loop(0); EXPECT_EQ(callbackCount, 2); testTray.notification_title = nullptr; testTray.notification_text = nullptr; testTray.notification_icon = nullptr; tray_update(&testTray); waitForNativeNotificationTimeout(); testTray.menu[0].cb = original_cb; } INSTANTIATE_TEST_SUITE_P( TrayIcons, TrayIconTest, ::testing::ValuesIn(TRAY_ICON_PARAMS), trayIconParamName ); INSTANTIATE_TEST_SUITE_P( TrayNotificationIcons, TrayNotificationIconTest, ::testing::ValuesIn(TRAY_ICON_PARAMS), trayIconParamName );