diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 00e82539..64eb98dc 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,2 @@ -blank_issues_enabled: false -contact_links: - - name: Create new issue - url: https://github.com/projectM-visualizer/projectm/issues/new/choose - about: Please open new issues in the main projectM repository. We will then move the issue into the correct repository for you. \ No newline at end of file +blank_issues_enabled: true +contact_links: [] diff --git a/.github/workflows/buildcheck.yaml b/.github/workflows/buildcheck.yaml index cbf58fee..1feaf4ce 100644 --- a/.github/workflows/buildcheck.yaml +++ b/.github/workflows/buildcheck.yaml @@ -97,8 +97,6 @@ jobs: USERNAME: projectM-visualizer VCPKG_EXE: ${{ github.workspace }}/vcpkg/vcpkg FEED_URL: https://nuget.pkg.github.com/projectM-visualizer/index.json - VCPKG_BINARY_SOURCES: "clear;nuget,https://nuget.pkg.github.com/projectM-visualizer/index.json,readwrite" - steps: - name: Checkout vcpkg @@ -108,24 +106,95 @@ jobs: path: vcpkg submodules: recursive - - name: Bootstrap vcpkg + # Capture the exact vcpkg repository commit being used. + # This ensures our cache key changes automatically if vcpkg itself is updated. + - name: Get vcpkg commit + id: vcpkg_rev shell: pwsh - run: ${{ github.workspace }}/vcpkg/bootstrap-vcpkg.bat + run: | + $rev = (git -C "${{ github.workspace }}/vcpkg" rev-parse HEAD).Trim() + "rev=$rev" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + + # Checkout this repo before hashFiles() / manifest is referenced + - name: Checkout projectMSDL Sources + uses: actions/checkout@v4 + with: + path: frontend-sdl2 + submodules: recursive + + - name: Prepare vcpkg binary cache dir (forks) + if: github.repository != 'projectM-visualizer/frontend-sdl-cpp' + shell: pwsh + run: | + $cacheDir = Join-Path $env:GITHUB_WORKSPACE "vcpkg-binary-cache" + New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null + Write-Host "Cache dir: $cacheDir" + + # Restore fork cache (file-based binary cache persisted by actions/cache) + - name: Cache vcpkg binary cache (forks) + if: github.repository != 'projectM-visualizer/frontend-sdl-cpp' + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}\vcpkg-binary-cache + key: vcpkg-bincache-${{ runner.os }}-${{ steps.vcpkg_rev.outputs.rev }}-${{ hashFiles('frontend-sdl2/vcpkg.json', 'frontend-sdl2/vcpkg-configuration.json') }} + restore-keys: | + vcpkg-bincache-${{ runner.os }}-${{ steps.vcpkg_rev.outputs.rev }}- + vcpkg-bincache-${{ runner.os }}- + + - name: Show if binary cache has contents (forks) + if: github.repository != 'projectM-visualizer/frontend-sdl-cpp' + shell: pwsh + run: | + $cacheDir = Join-Path $env:GITHUB_WORKSPACE "vcpkg-binary-cache" + if (Test-Path $cacheDir) { + Get-ChildItem -Recurse $cacheDir | Select-Object -First 30 FullName + } else { + Write-Host "Binary cache directory does not exist." + } + # Configure vcpkg binary cache behavior + - name: Configure vcpkg binary cache (upstream) + if: github.repository == 'projectM-visualizer/frontend-sdl-cpp' + shell: pwsh + run: | + Write-Host "Using NuGet binary cache (readwrite) for upstream repo" + "VCPKG_BINARY_SOURCES=clear;nuget,${{ env.FEED_URL }},readwrite" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Configure vcpkg binary cache (forks) + if: github.repository != 'projectM-visualizer/frontend-sdl-cpp' + shell: pwsh + run: | + Write-Host "Using local file-based binary cache (readwrite) for fork/PR" + $cacheDir = Join-Path $env:GITHUB_WORKSPACE "vcpkg-binary-cache" + "VCPKG_DEFAULT_BINARY_CACHE=$cacheDir" | Out-File -FilePath $env:GITHUB_ENV -Append + "VCPKG_BINARY_SOURCES=clear;files,$cacheDir,readwrite" | Out-File -FilePath $env:GITHUB_ENV -Append + + # Upstream-only: authenticate to GitHub Packages NuGet feed - name: Add NuGet sources + if: github.repository == 'projectM-visualizer/frontend-sdl-cpp' + env: + VCPKG_PACKAGES_TOKEN: ${{ secrets.VCPKG_PACKAGES_TOKEN }} shell: pwsh run: | - .$(${{ env.VCPKG_EXE }} fetch nuget) ` - sources add ` + if (-not $env:VCPKG_PACKAGES_TOKEN) { + Write-Host "VCPKG_PACKAGES_TOKEN not set; skipping NuGet auth." + exit 0 + } + + $nuget = (& "${{ env.VCPKG_EXE }}" fetch nuget | Select-Object -Last 1).Trim() + & $nuget sources add ` -Source "${{ env.FEED_URL }}" ` -StorePasswordInClearText ` -Name GitHubPackages ` -UserName "${{ env.USERNAME }}" ` - -Password "${{ secrets.VCPKG_PACKAGES_TOKEN }}" - .$(${{ env.VCPKG_EXE }} fetch nuget) ` - setapikey "${{ secrets.VCPKG_PACKAGES_TOKEN }}" ` + -Password "$env:VCPKG_PACKAGES_TOKEN" + & $nuget setapikey "$env:VCPKG_PACKAGES_TOKEN" ` -Source "${{ env.FEED_URL }}" + - name: Bootstrap vcpkg + shell: pwsh + run: ${{ github.workspace }}/vcpkg/bootstrap-vcpkg.bat + - name: Checkout libprojectM Sources uses: actions/checkout@v4 with: @@ -140,12 +209,6 @@ jobs: cmake --build "${{ github.workspace }}/cmake-build-libprojectm" --config Release --parallel cmake --install "${{ github.workspace }}/cmake-build-libprojectm" --config Release - - name: Checkout projectMSDL Sources - uses: actions/checkout@v4 - with: - path: frontend-sdl2 - submodules: recursive - - name: Build projectMSDL run: | mkdir cmake-build-frontend-sdl2 diff --git a/CMakeLists.txt b/CMakeLists.txt index 1d1f4ed0..0a2359c3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,16 +6,25 @@ set(CMAKE_POSITION_INDEPENDENT_CODE YES) set_property(GLOBAL PROPERTY USE_FOLDERS ON) -project(projectMSDL - LANGUAGES C CXX - VERSION 2.0.0 - ) - list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake") option(ENABLE_FLAT_PACKAGE "Creates a \"flat\" install layout with the executable, configuration file(s) and preset/texture dirs directly in the install prefix." OFF) option(ENABLE_INSTALL_BDEPS "Installs all shared libraries projectMSDL requires to run. On some platforms, CMake 3.31 or higher is required for this to work!" OFF) +# Added option display to cmake build to allow testing +option(BUILD_TESTING "Build the frontend-sdl2 ctests" OFF) + +# Enable vcpkg manifest features according to build options (just like the projectm backend) +if(BUILD_TESTING) + list(APPEND VCPKG_MANIFEST_FEATURES test) +endif() + +# Moved below options to allow using options +project(projectMSDL + LANGUAGES C CXX + VERSION 2.0.0 +) + set(PROJECTMSDL_PROPERTIES_FILENAME "projectMSDL.properties") if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND NOT ENABLE_FLAT_PACKAGE) @@ -114,8 +123,10 @@ include(ImGui.cmake) add_subdirectory(src) -if(ENABLE_TESTING) - add_subdirectory(test) +# Adjusted testing build from existing code +if(BUILD_TESTING) + enable_testing() + add_subdirectory(tests) endif() include(install.cmake) @@ -135,3 +146,6 @@ message(STATUS "The projectMSDL binary will look for the following hard-coded pa message(STATUS " Configuration file: ${DEFAULT_CONFIG_PATH}") message(STATUS " Presets: ${DEFAULT_PRESETS_PATH}") message(STATUS " Textures: ${DEFAULT_TEXTURES_PATH}") +# Added testing message +message(STATUS "=============================================") +message(STATUS " Tests: ${BUILD_TESTING}") \ No newline at end of file diff --git a/cmake/SDL2Target.cmake b/cmake/SDL2Target.cmake index a949078f..2c759965 100644 --- a/cmake/SDL2Target.cmake +++ b/cmake/SDL2Target.cmake @@ -54,20 +54,30 @@ if(NOT TARGET SDL2::SDL2) endif() # Temporary fix to deal with wrong include dir set by SDL2's CMake configuration. -get_target_property(_SDL2_INCLUDE_DIR SDL2::SDL2 INTERFACE_INCLUDE_DIRECTORIES) -if(_SDL2_INCLUDE_DIR MATCHES "(.+)/SDL2\$" AND _SDL2_TARGET_TYPE STREQUAL STATIC_LIBRARY) - # Check if SDL2::SDL2 is aliased to SDL2::SDL2-static (will be the case for static-only builds) - get_target_property(_SDL2_ALIASED_TARGET SDL2::SDL2 ALIASED_TARGET) - if(_SDL2_ALIASED_TARGET) - set(_sdl2_target ${_SDL2_ALIASED_TARGET}) - else() - set(_sdl2_target SDL2::SDL2) - endif() +# Some SDL2 configs incorrectly report .../include/SDL2 instead of .../include. +get_target_property(_SDL2_INCLUDE_DIRS SDL2::SDL2 INTERFACE_INCLUDE_DIRECTORIES) + +if(_SDL2_INCLUDE_DIRS) + # The property can be a list, so handle each entry. + foreach(_dir IN LISTS _SDL2_INCLUDE_DIRS) + if(_dir MATCHES "(.+)/SDL2$") + set(_fixed_parent "${CMAKE_MATCH_1}") + + # If SDL2::SDL2 is an alias, patch the real target. + get_target_property(_SDL2_ALIASED_TARGET SDL2::SDL2 ALIASED_TARGET) + if(_SDL2_ALIASED_TARGET) + set(_sdl2_target "${_SDL2_ALIASED_TARGET}") + else() + set(_sdl2_target SDL2::SDL2) + endif() - message(STATUS "SDL2 include dir contains \"SDL2\" subdir (SDL bug #4004) - fixing to \"${CMAKE_MATCH_1}\".") - set_target_properties(${_sdl2_target} PROPERTIES - INTERFACE_INCLUDE_DIRECTORIES "${CMAKE_MATCH_1}" + message(STATUS "SDL2 include dir contains \"SDL2\" subdir - fixing to \"${_fixed_parent}\".") + set_target_properties(${_sdl2_target} PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "${_fixed_parent}" ) + break() + endif() + endforeach() endif() if(SDL2_VERSION AND SDL2_VERSION VERSION_LESS "2.0.5") diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ba764dcd..b48fc850 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -5,6 +5,8 @@ set(PROJECTM_CONFIGURATION_FILE "${CMAKE_CURRENT_BINARY_DIR}/projectMSDL.propert set(PROJECTM_CONFIGURATION_FILE "${PROJECTM_CONFIGURATION_FILE}" PARENT_SCOPE) configure_file(resources/projectMSDL.properties.in "${PROJECTM_CONFIGURATION_FILE}" @ONLY) +find_package(OpenGL REQUIRED) + add_executable(projectMSDL WIN32 MACOSX_BUNDLE AudioCapture.cpp AudioCapture.h @@ -82,6 +84,14 @@ target_link_libraries(projectMSDL SDL2::SDL2main ) +if (TARGET OpenGL::GL) + target_link_libraries(projectMSDL PRIVATE OpenGL::GL) +elseif (TARGET OpenGL::OpenGL) + target_link_libraries(projectMSDL PRIVATE OpenGL::OpenGL) +else() + message(FATAL_ERROR "No OpenGL CMake target found.") +endif() + if (MSVC) set_target_properties(projectMSDL PROPERTIES diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 00000000..301dbe10 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,132 @@ +# ====== +# Test-only core library +# (Eventually this should be moved into src/CMakeLists.txt to avoid +# duplicating source lists and dependancies.) +# ====== + +# Build a core library from production sources to test on +add_library(projectMSDL_testcorelib + # Add .cpp files as needed for testing + # Examples: + # ${PROJECT_SOURCE_DIR}/src/SDLRenderingWindow.cpp + # ${PROJECT_SOURCE_DIR}/src/RenderLoop.cpp + # ${PROJECT_SOURCE_DIR}/src/ProjectMWrapper.cpp + # For now, we'll include a simple file just so this thing works. It can be removed if unneeded later. + ${PROJECT_SOURCE_DIR}/src/FPSLimiter.cpp + ${PROJECT_SOURCE_DIR}/src/ProjectMSDLApplication.cpp + ${PROJECT_SOURCE_DIR}/src/RenderLoop.cpp + ${PROJECT_SOURCE_DIR}/src/ProjectMWrapper.cpp + ${PROJECT_SOURCE_DIR}/src/SDLRenderingWindow.cpp + ${PROJECT_SOURCE_DIR}/src/AudioCapture.cpp +) + +if (WIN32) + target_sources(projectMSDL_testcorelib PRIVATE + ${PROJECT_SOURCE_DIR}/src/AudioCaptureImpl_WASAPI.cpp + ) + target_compile_definitions(projectMSDL_testcorelib PRIVATE + AUDIO_IMPL_HEADER="AudioCaptureImpl_WASAPI.h" + USE_GLEW + ) +else() + target_sources(projectMSDL_testcorelib PRIVATE + ${PROJECT_SOURCE_DIR}/src/AudioCaptureImpl_SDL.cpp + ) + target_compile_definitions(projectMSDL_testcorelib PRIVATE + AUDIO_IMPL_HEADER="AudioCaptureImpl_SDL.h" + ) +endif() + +# Headers from the production source tree +target_include_directories(projectMSDL_testcorelib + PUBLIC + ${PROJECT_SOURCE_DIR}/src +) + +# Compile definitions that are commonly referenced +target_compile_definitions(projectMSDL_testcorelib + PRIVATE + PROJECTMSDL_CONFIG_LOCATION="${DEFAULT_CONFIG_PATH}" + PROJECTMSDL_VERSION="${PROJECT_VERSION}" +) + +# Linker set (add to this only when the linker asks) +target_link_libraries(projectMSDL_testcorelib + PRIVATE + SDL2::SDL2 + Poco::Util + Poco::Foundation + ProjectMSDL-GUI + ProjectMSDL-Notifications + libprojectM::playlist +) + +# ====== +# Test-only core library WITHOUT RenderLoop +# ====== + +# Build a core library from production sources to test on +add_library(projectMSDL_testcorelib_norenderloop + # Add .cpp files as needed for testing + # Examples: + # ${PROJECT_SOURCE_DIR}/src/SDLRenderingWindow.cpp + # ${PROJECT_SOURCE_DIR}/src/RenderLoop.cpp + # ${PROJECT_SOURCE_DIR}/src/ProjectMWrapper.cpp + # For now, we'll include a simple file just so this thing works. It can be removed if unneeded later. + ${PROJECT_SOURCE_DIR}/src/FPSLimiter.cpp + ${PROJECT_SOURCE_DIR}/src/ProjectMSDLApplication.cpp + ${PROJECT_SOURCE_DIR}/src/ProjectMWrapper.cpp + ${PROJECT_SOURCE_DIR}/src/SDLRenderingWindow.cpp + ${PROJECT_SOURCE_DIR}/src/AudioCapture.cpp +) + +if (WIN32) + target_sources(projectMSDL_testcorelib_norenderloop PRIVATE + ${PROJECT_SOURCE_DIR}/src/AudioCaptureImpl_WASAPI.cpp + ) + target_compile_definitions(projectMSDL_testcorelib_norenderloop PRIVATE + AUDIO_IMPL_HEADER="AudioCaptureImpl_WASAPI.h" + USE_GLEW + ) +else() + target_sources(projectMSDL_testcorelib_norenderloop PRIVATE + ${PROJECT_SOURCE_DIR}/src/AudioCaptureImpl_SDL.cpp + ) + target_compile_definitions(projectMSDL_testcorelib_norenderloop PRIVATE + AUDIO_IMPL_HEADER="AudioCaptureImpl_SDL.h" + ) +endif() + +# Headers from the production source tree +target_include_directories(projectMSDL_testcorelib_norenderloop + PUBLIC + ${PROJECT_SOURCE_DIR}/src +) + +# Compile definitions that are commonly referenced +target_compile_definitions(projectMSDL_testcorelib_norenderloop + PRIVATE + PROJECTMSDL_CONFIG_LOCATION="${DEFAULT_CONFIG_PATH}" + PROJECTMSDL_VERSION="${PROJECT_VERSION}" +) + +# Linker set (add to this only when the linker asks) +target_link_libraries(projectMSDL_testcorelib_norenderloop + PRIVATE + SDL2::SDL2 + Poco::Util + Poco::Foundation + ProjectMSDL-GUI + ProjectMSDL-Notifications + libprojectM::playlist +) + +# ====== +# Actual test subdirs +# Add to this when defining more testing directories +# (To be kept in this file after integration of above code into src/CMakeLists.txt) +# ====== +add_subdirectory(renderer) +add_subdirectory(audio) +add_subdirectory(cli) +add_subdirectory(ui) \ No newline at end of file diff --git a/tests/audio/CMakeLists.txt b/tests/audio/CMakeLists.txt new file mode 100644 index 00000000..609ba0e8 --- /dev/null +++ b/tests/audio/CMakeLists.txt @@ -0,0 +1,19 @@ +find_package(GTest 1.10 REQUIRED NO_MODULE) + +add_executable(projectMSDL-audio-tests + ListAudioDevicesTest.cpp +) + +target_include_directories(projectMSDL-audio-tests + PRIVATE + ${PROJECT_SOURCE_DIR}/src +) + +target_link_libraries(projectMSDL-audio-tests + PRIVATE + projectMSDL_testcorelib + GTest::gtest + GTest::gtest_main +) + +add_test(NAME projectMSDL-audio-tests COMMAND projectMSDL-audio-tests) \ No newline at end of file diff --git a/tests/audio/ListAudioDevicesTest.cpp b/tests/audio/ListAudioDevicesTest.cpp new file mode 100644 index 00000000..1dc86671 --- /dev/null +++ b/tests/audio/ListAudioDevicesTest.cpp @@ -0,0 +1,30 @@ +#include +#include "ProjectMSDLApplication.h" + +// Expose the protected ListAudioDevices for testing by deriving a small test subclass. +class TestableProjectMSDLApplication : public ProjectMSDLApplication { +public: + using ProjectMSDLApplication::ListAudioDevices; // make protected method public in test subclass +}; + +TEST(ListAudioDevicesTest, SetsAudioListDevicesOverrideToTrue) +{ + TestableProjectMSDLApplication app; + + auto cfg = app.CommandLineConfiguration(); + ASSERT_TRUE(cfg); // check configuration object exists + + EXPECT_FALSE(cfg->getBool("audio.listDevices", false)); + + // Call the option handler directly (name and value are unused in implementation). + app.ListAudioDevices("", ""); + + // Check to see if config has been correctly overridden + EXPECT_TRUE(cfg->getBool("audio.listDevices", false)); + + // Get config again, to test using a fresh config object + auto cfg2 = app.CommandLineConfiguration(); + ASSERT_TRUE(cfg2); // check configuration object exists + + EXPECT_TRUE(cfg2->getBool("audio.listDevices", false)); +} \ No newline at end of file diff --git a/tests/cli/CMakeLists.txt b/tests/cli/CMakeLists.txt new file mode 100644 index 00000000..4e670b2c --- /dev/null +++ b/tests/cli/CMakeLists.txt @@ -0,0 +1,22 @@ +find_package(GTest 1.10 REQUIRED NO_MODULE) + +add_executable(projectMSDL-cli-test + HelpOptionTest.cpp +) + +target_link_libraries(projectMSDL-cli-test + PRIVATE + GTest::gtest_main + Poco::Foundation +) + +# Ensure the app is built before the test runs +add_dependencies(projectMSDL-cli-test projectMSDL) + +# Pass the built binary path into the test as a compile definition +target_compile_definitions(projectMSDL-cli-test + PRIVATE + PROJECTMSDL_BINARY_PATH="$" +) + +add_test(NAME projectMSDL-cli-test COMMAND $) \ No newline at end of file diff --git a/tests/cli/HelpOptionTest.cpp b/tests/cli/HelpOptionTest.cpp new file mode 100644 index 00000000..d66e37d8 --- /dev/null +++ b/tests/cli/HelpOptionTest.cpp @@ -0,0 +1,129 @@ +// HelpOptionTest.cpp +// +// CLI integration test: verifies `projectMSDL --help` prints usage/help text +// and exits successfully without entering ProjectMSDLApplication::main() +// or starting RenderLoop::Run(). + +#include + +// Poco: process launching, pipes, timing, filesystem +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#ifndef PROJECTMSDL_BINARY_PATH +#error "PROJECTMSDL_BINARY_PATH is not defined. Check tests/cli/CMakeLists.txt." +#endif + +// Read entire contents of a pipe into a string. +static std::string ReadAll(Poco::Pipe& pipe) +{ + Poco::PipeInputStream is(pipe); + std::ostringstream oss; + Poco::StreamCopier::copyStream(is, oss); + return oss.str(); +} + +// Result of running the CLI. +struct CliResult +{ + int exitCode; + std::string output; // stdout + stderr combined +}; + +// Helper: launches projectMSDL with given args. +// Throws std::runtime_error on failure. +static CliResult RunCli(const std::vector& args, int timeoutSeconds = 3) +{ + const std::string binaryPath = PROJECTMSDL_BINARY_PATH; + + // Ensure binary exists and is executable. + Poco::File binFile(binaryPath); + if (!binFile.exists()) throw std::runtime_error("Binary not found: " + binaryPath); + if (!binFile.canExecute()) throw std::runtime_error("Binary not executable: " + binaryPath); + + // Capture stdout/stderr. + Poco::Pipe outPipe; + Poco::Pipe errPipe; + + // Launch the process and construct the ProcessHandle from the return value. + Poco::ProcessHandle handle = Poco::Process::launch(binaryPath, args, nullptr, &outPipe, &errPipe); + + // --- Timeout guard (detect accidental RenderLoop entry) --- + const Poco::Timespan timeout(timeoutSeconds, 0); + Poco::Timestamp start; + int exitCode = -1; + + while (true) + { + const int maybeExit = handle.tryWait(); // -1 if still running + if (maybeExit != -1) + { + exitCode = maybeExit; + break; + } + + if (start.elapsed() > timeout.totalMicroseconds()) + { + try { Poco::Process::kill(handle); } catch (...) {} + throw std::runtime_error("CLI process did not exit before timeout (possible RenderLoop entry)."); + } + + Poco::Thread::sleep(10); + } + + // Read output after exit. + std::string stdoutText; + std::string stderrText; + + try { stdoutText = ReadAll(outPipe); } + catch (const Poco::Exception& ex) + { + throw std::runtime_error(std::string("Error reading stdout: ") + ex.displayText()); + } + + try { stderrText = ReadAll(errPipe); } + catch (const Poco::Exception& ex) + { + throw std::runtime_error(std::string("Error reading stderr: ") + ex.displayText()); + } + + return { exitCode, stdoutText + (stderrText.empty() ? "" : "\n" + stderrText) }; +} + +TEST(CliHelpOption, HelpPrintsAndExitsWithoutRunning) +{ + CliResult result; + + try + { +#ifdef _WIN32 + result = RunCli({ "/help" }); +#else + result = RunCli({ "--help" }); +#endif + } + catch (const std::exception& ex) + { + FAIL() << "RunCli failed: " << ex.what(); + return; + } + + // Must exit successfully. + ASSERT_EQ(result.exitCode, 0) << "Non-zero exit (" << result.exitCode << "). Output:\n" << result.output; + + // Must print recognizable help/usage text. + EXPECT_NE(result.output.find("projectM SDL Standalone Visualizer"), std::string::npos) << "Expected help header not found.\nOutput:\n" << result.output; + EXPECT_NE(result.output.find("[options]"), std::string::npos) << "Expected usage/options text not found.\nOutput:\n" << result.output; +} \ No newline at end of file diff --git a/tests/renderer/CMakeLists.txt b/tests/renderer/CMakeLists.txt new file mode 100644 index 00000000..1f4cf9ae --- /dev/null +++ b/tests/renderer/CMakeLists.txt @@ -0,0 +1,39 @@ +# Requires gtest, otherwise fail +find_package(GTest 1.10 REQUIRED NO_MODULE) + +# === +# Integration Test +# === +# Creation of executable for this specific test +add_executable(projectMSDL-renderer-test + RenderSmokeTest.cpp +) + +# Call required libraries; testcore, gtest +target_link_libraries(projectMSDL-renderer-test + PRIVATE + projectMSDL_testcorelib + GTest::gtest + GTest::gtest_main +) + +# Add our test to the executable +add_test(NAME projectMSDL-renderer-test COMMAND projectMSDL-renderer-test) + + +# === +# Unit Test +# === +# Unit test executable (fake RenderLoop via symbols in MainExecutionTest.cpp) +add_executable(projectMSDL-mainexec-test + MainExecutionTest.cpp +) + +target_link_libraries(projectMSDL-mainexec-test + PRIVATE + projectMSDL_testcorelib_norenderloop + GTest::gtest + GTest::gtest_main +) + +add_test(NAME projectMSDL-mainexec-test COMMAND projectMSDL-mainexec-test) \ No newline at end of file diff --git a/tests/renderer/MainExecutionTest.cpp b/tests/renderer/MainExecutionTest.cpp new file mode 100644 index 00000000..ac39fff0 --- /dev/null +++ b/tests/renderer/MainExecutionTest.cpp @@ -0,0 +1,56 @@ +#define SDL_MAIN_HANDLED 1 + +#include + +#include +#include +#include + +#include +#include + +#include "ProjectMSDLApplication.h" +#include "RenderLoop.h" + +#include "AudioCapture.h" +#include "ProjectMWrapper.h" +#include "SDLRenderingWindow.h" +#include "gui/ProjectMGUI.h" +#include "notifications/QuitNotification.h" + +static int g_run_calls = 0; + +// RenderLoop.cpp is excluded from projectMSDL_testcorelib_norenderloop so we provide these symbols here. +RenderLoop::RenderLoop() + : _audioCapture(Poco::Util::Application::instance().getSubsystem()) + , _projectMWrapper(Poco::Util::Application::instance().getSubsystem()) + , _sdlRenderingWindow(Poco::Util::Application::instance().getSubsystem()) + , _projectMGui(Poco::Util::Application::instance().getSubsystem()) +{ +} + +// RenderLoop has an observer bound to this method. +void RenderLoop::QuitNotificationHandler(const Poco::AutoPtr&) +{ +} + +void RenderLoop::Run() +{ + ++g_run_calls; +} + +class TestableProjectMSDLApplication final : public ProjectMSDLApplication { +public: + using ProjectMSDLApplication::main; +}; + +TEST(ProjectMSDLApplicationMain, CallsRenderLoopRunExactlyOnceAndReturnsExitSuccess) +{ + g_run_calls = 0; + + TestableProjectMSDLApplication app; + const int rc = app.main(std::vector{}); + + EXPECT_EQ(g_run_calls, 1); + EXPECT_EQ(rc, EXIT_SUCCESS); +} \ No newline at end of file diff --git a/tests/renderer/RenderSmokeTest.cpp b/tests/renderer/RenderSmokeTest.cpp new file mode 100644 index 00000000..75a3cb00 --- /dev/null +++ b/tests/renderer/RenderSmokeTest.cpp @@ -0,0 +1,136 @@ +#include +#include "ProjectMSDLApplication.h" +#include "SDLRenderingWindow.h" +#include "ProjectMWrapper.h" + +#if defined(_WIN32) + #include +#endif + +#ifdef __APPLE__ + #define GL_SILENCE_DEPRECATION + #include +#endif + +#include +#include +#include + +struct RendererSmokeCleanup { + ProjectMWrapper* renderer = nullptr; + SDLRenderingWindow* window = nullptr; + + ~RendererSmokeCleanup() { + if (renderer) renderer->uninitialize(); + if (window) window->uninitialize(); + } +}; + +auto CheckGLError = [] (const char* where) { + GLenum err = glGetError(); + ASSERT_EQ(err, GL_NO_ERROR) << "OpenGL error at " << where << ": 0x" << std::hex << err; +}; + +// Helper function: +// Count how many pixels are NOT black +// Epsilon is our value for not black. Anything less than 2 on the GPU is black (some GPU's don't see 0,0,0 as black). +static size_t CountNonBlackPixels(const std::vector& rgba, unsigned char epsilon = 2) { + size_t count = 0; + for (size_t i = 0; i + 2 < rgba.size(); i += 4) { + if (rgba[i] > epsilon || rgba[i + 1] > epsilon || rgba[i + 2] > epsilon) + ++count; + } + return count; +} + +// Integration test: +// Verifies that: +// - App can create a window and OpenGL context +// - Renderer can draw something +// - Output is not just a blank screen +TEST(RenderSmokeTest, AppRendersSomething) { +// Tell SDL to use a dummy audio driver so audio init doesn't fail on Linux/MacOS +#if !defined(_WIN32) + setenv("SDL_AUDIODRIVER", "dummy", 1); +#endif + + // Simulate starting the real app + const char* argv0 = "projectMSDL"; + int argc = 1; + char* argv[] = { const_cast(argv0), nullptr }; + + // Create real app obj + ProjectMSDLApplication app; + + // Init the app + ASSERT_NO_THROW(app.init(argc, argv)); + + // Create SDL window + OpenGL context + auto& window = app.getSubsystem(); + + // Give SDL a moment / pump events + SDL_PumpEvents(); + + // Create the projectM renderer + auto& renderer = app.getSubsystem(); + + // Create a cleaner + RendererSmokeCleanup cleanup; + cleanup.window = &window; + cleanup.renderer = &renderer; + + // Make sure these actually initialized correctly + ASSERT_NO_THROW(window.initialize(app)); + ASSERT_NO_THROW(renderer.initialize(app)); + + // Get the size of the drawable OpenGL area + int width = 0, height = 0; + window.GetDrawableSize(width, height); + + // Fail if H/W are 0 + ASSERT_GT(width, 0); + ASSERT_GT(height, 0); + + // Make sure our OpenGL window is clear first + glViewport(0, 0, width, height); + glClearColor(0.f, 0.f, 0.f, 1.f); + glClear(GL_COLOR_BUFFER_BIT); + glFinish(); + CheckGLError("ClearColor"); + + // Render a few frames + for (int i = 0; i < 20; ++i) { + renderer.RenderFrame(); + glFinish(); + CheckGLError("RenderFrame"); + } + + // Prepare buffer and read pixels + const size_t bufSize = static_cast(width) * static_cast(height) * 4; + std::vector pixels(bufSize); + glPixelStorei(GL_PACK_ALIGNMENT, 1); // safe alignment for glReadPixels + + // Ensure we're bound to the default framebuffer because the renderer may have left an FBO bound + glBindFramebuffer(GL_FRAMEBUFFER, 0); + CheckGLError("glBindFramebuffer(0)"); + + // Pick a read buffer that actually exists (GL_BACK if double-buffered; otherwise GL_FRONT) + int doubleBuffered = 0; + SDL_GL_GetAttribute(SDL_GL_DOUBLEBUFFER, &doubleBuffered); + glReadBuffer(doubleBuffered ? GL_BACK : GL_FRONT); + CheckGLError("glReadBuffer"); + + // Read pixels from the chosen buffer + glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); + glFinish(); + CheckGLError("glReadPixels"); + + // Count non black pixels in back buffer + size_t nonBlack = CountNonBlackPixels(pixels); + + // Threshold: at least 100 pixels OR at least 0.2% of the image + size_t threshold = static_cast(width) * static_cast(height) / 500; + if (threshold < 100) threshold = 100; + + EXPECT_GT(nonBlack, threshold); +} \ No newline at end of file diff --git a/tests/ui/CMakeLists.txt b/tests/ui/CMakeLists.txt new file mode 100644 index 00000000..8335f6ac --- /dev/null +++ b/tests/ui/CMakeLists.txt @@ -0,0 +1,18 @@ +# Requires gtest, otherwise fail +find_package(GTest 1.10 REQUIRED NO_MODULE) + +# Creation of executable for this specific test +add_executable(projectMSDL-ui-test + EscOverlayToggleTest.cpp +) + +# Call required libraries +target_link_libraries(projectMSDL-ui-test + PRIVATE + ImGui + projectMSDL_testcorelib + GTest::gtest_main +) + +# Add our test to the executable +add_test(NAME projectMSDL-ui-test COMMAND projectMSDL-ui-test) \ No newline at end of file diff --git a/tests/ui/EscOverlayToggleTest.cpp b/tests/ui/EscOverlayToggleTest.cpp new file mode 100644 index 00000000..3e99c68c --- /dev/null +++ b/tests/ui/EscOverlayToggleTest.cpp @@ -0,0 +1,213 @@ +// tests/ui/EscOverlayToggleTest.cpp + +#include + +#include "ProjectMSDLApplication.h" +#include "RenderLoop.h" +#include "SDLRenderingWindow.h" +#include "ProjectMWrapper.h" +#include "gui/ProjectMGUI.h" + +#include +#include + +#include +#include +#include + +namespace { + +#if !defined(_WIN32) +void ConfigureHeadlessIfNeeded() +{ +#if defined(__linux__) + const char* display = std::getenv("DISPLAY"); + if (!display || display[0] == '\0') + { + if (!std::getenv("SDL_VIDEODRIVER")) + setenv("SDL_VIDEODRIVER", "dummy", 1); + } +#endif + if (!std::getenv("SDL_AUDIODRIVER")) + setenv("SDL_AUDIODRIVER", "dummy", 1); +} +#endif + +class RenderLoopTestHarness : public RenderLoop +{ +public: + void PollEventsPublic() { PollEvents(); } + bool WantsToQuitPublic() const { return _wantsToQuit; } +}; + +void PumpOnce(ProjectMGUI& gui, RenderLoopTestHarness& loop) +{ + loop.PollEventsPublic(); + gui.Draw(); +} + +void PushEscPress(SDL_Window* w) +{ + const Uint32 wid = w ? SDL_GetWindowID(w) : 0; + + SDL_Event e{}; + e.type = SDL_KEYDOWN; + e.key.type = SDL_KEYDOWN; + e.key.windowID = wid; + e.key.state = SDL_PRESSED; + e.key.repeat = 0; + e.key.keysym.sym = SDLK_ESCAPE; + e.key.keysym.scancode = SDL_SCANCODE_ESCAPE; + e.key.keysym.mod = KMOD_NONE; + ASSERT_EQ(SDL_PushEvent(&e), 1); + + e = SDL_Event{}; + e.type = SDL_KEYUP; + e.key.type = SDL_KEYUP; + e.key.windowID = wid; + e.key.state = SDL_RELEASED; + e.key.repeat = 0; + e.key.keysym.sym = SDLK_ESCAPE; + e.key.keysym.scancode = SDL_SCANCODE_ESCAPE; + e.key.keysym.mod = KMOD_NONE; + ASSERT_EQ(SDL_PushEvent(&e), 1); +} + +void PushBackgroundClick(SDL_Window* w, int x, int y) +{ + if (!w) return; + const Uint32 wid = SDL_GetWindowID(w); + + SDL_Event e{}; + e.type = SDL_MOUSEMOTION; + e.motion.windowID = wid; + e.motion.which = 0; + e.motion.x = x; + e.motion.y = y; + e.motion.xrel = 0; + e.motion.yrel = 0; + SDL_PushEvent(&e); + + e = SDL_Event{}; + e.type = SDL_MOUSEBUTTONDOWN; + e.button.windowID = wid; + e.button.which = 0; + e.button.button = SDL_BUTTON_LEFT; + e.button.state = SDL_PRESSED; + e.button.clicks = 1; + e.button.x = x; + e.button.y = y; + SDL_PushEvent(&e); + + e = SDL_Event{}; + e.type = SDL_MOUSEBUTTONUP; + e.button.windowID = wid; + e.button.which = 0; + e.button.button = SDL_BUTTON_LEFT; + e.button.state = SDL_RELEASED; + e.button.clicks = 1; + e.button.x = x; + e.button.y = y; + SDL_PushEvent(&e); +} + +bool WaitForImGuiReady(ProjectMGUI& gui, RenderLoopTestHarness& loop, int maxFrames) +{ + for (int i = 0; i < maxFrames; ++i) + { + PumpOnce(gui, loop); + if (ImGui::GetCurrentContext() != nullptr) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + return false; +} + +bool WaitForImGuiNotCapturingKeyboard(ProjectMGUI& gui, RenderLoopTestHarness& loop, int maxFrames) +{ + for (int i = 0; i < maxFrames; ++i) + { + PumpOnce(gui, loop); + + if (ImGui::GetCurrentContext() == nullptr) + { + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + continue; + } + + const ImGuiIO& io = ImGui::GetIO(); + if (!io.WantCaptureKeyboard) + return true; + + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + return false; +} + +} // namespace + +TEST(UIToggleOverlayTest, EscTogglesOverlayVisibilityOncePerPress) +{ +#if !defined(_WIN32) + ConfigureHeadlessIfNeeded(); +#endif + + const char* argv0 = "projectMSDL-ui-test"; + int argc = 1; + char* argv[] = { const_cast(argv0), nullptr }; + + ProjectMSDLApplication app; + ASSERT_NO_THROW(app.init(argc, argv)); + + auto& window = app.getSubsystem(); + auto& renderer = app.getSubsystem(); + auto& gui = app.getSubsystem(); + + ASSERT_NO_THROW(window.initialize(app)); + ASSERT_NO_THROW(renderer.initialize(app)); + ASSERT_NO_THROW(gui.initialize(app)); + + SDL_Window* sdlWin = window.GetRenderingWindow(); + ASSERT_NE(sdlWin, nullptr); + + RenderLoopTestHarness loop; + + ASSERT_TRUE(WaitForImGuiReady(gui, loop, 60)) << "ImGui context never became ready."; + + // Try to ensure no widget is focused/active by clicking the far bottom-right. + int w = 0, h = 0; + SDL_GetWindowSize(sdlWin, &w, &h); + PushBackgroundClick(sdlWin, (w > 0 ? w - 2 : 1), (h > 0 ? h - 2 : 1)); + + // Wait until ImGui naturally reports it is NOT capturing keyboard. + ASSERT_TRUE(WaitForImGuiNotCapturingKeyboard(gui, loop, 120)) + << "ImGui kept WantCaptureKeyboard=true. " + << "This can happen depending on UI state; test may be flaky on some platforms."; + + const bool initialVisible = gui.Visible(); + + // Press ESC once -> should flip + PushEscPress(sdlWin); + PumpOnce(gui, loop); + + EXPECT_EQ(gui.Visible(), !initialVisible); + EXPECT_FALSE(loop.WantsToQuitPublic()); + + // Again: click background + wait for no-capture before the second press + SDL_GetWindowSize(sdlWin, &w, &h); + PushBackgroundClick(sdlWin, (w > 0 ? w - 2 : 1), (h > 0 ? h - 2 : 1)); + + ASSERT_TRUE(WaitForImGuiNotCapturingKeyboard(gui, loop, 120)) + << "ImGui kept WantCaptureKeyboard=true before second ESC press."; + + // Press ESC again -> should restore + PushEscPress(sdlWin); + PumpOnce(gui, loop); + + EXPECT_EQ(gui.Visible(), initialVisible); + EXPECT_FALSE(loop.WantsToQuitPublic()); + + gui.uninitialize(); + renderer.uninitialize(); + window.uninitialize(); +} \ No newline at end of file diff --git a/vcpkg.json b/vcpkg.json index 924009d6..d360dddf 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -11,5 +11,13 @@ }, "projectm", "freetype" - ] + ], + "features": { + "test": { + "description": "Build unit tests", + "dependencies": [ + "gtest" + ] + } + } } \ No newline at end of file