Explorar o código

Add unit testing (#304)

* chore: added catch2 + github actions for testing

* chore: only compile Catch2 if a testing build is requested and run cmake on any branch after tests

* chore: added testing opengl driver

* chore: some work around implementing harnesses

* chore: pin Catch2 to v3.11.0

* chore: clear allocated output in the testing driver and fix few cmake misshaps after the rebase

* fix: glew mistake in test build

* chore: move mouse coordinates test to Catch2

* chore: remove leftover file from PR

* chore: compile options for tests should be taken into account too
Alexis Maiquez hai 9 meses
pai
achega
957c3e7549

+ 7 - 16
.github/workflows/cmake.yml

@@ -1,26 +1,15 @@
 name: CMake
 
 on:
+  workflow_run:
+    workflows: [tests]
+    branches: [main]
+    types: [completed]
   workflow_dispatch:
-  push:
-    paths:
-      - '.github/workflows/cmake.yml'
-      - 'src/**'
-      - 'CMakeModules/**'
-      - CMakeLists.txt
-      - 'protocols/**'
-    branches: [ "main" ]
-  pull_request:
-    paths:
-      - '.github/workflows/cmake.yml'
-      - 'src/**'
-      - 'CMakeModules/**'
-      - CMakeLists.txt
-      - 'protocols/**'
-    branches: [ "main" ]
 
 jobs:
   build-x11:
+    if: ${{ github.event.workflow_run.conclusion == 'success' }}
     strategy:
       matrix:
         os: [ubuntu-22.04, ubuntu-24.04]
@@ -59,6 +48,7 @@ jobs:
       run: cmake --build ${{github.workspace}}/build --config ${{matrix.build_type}}
 
   build-x11-wayland:
+    if: ${{ github.event.workflow_run.conclusion == 'success' }}
     permissions:
       actions: read
       contents: read
@@ -112,6 +102,7 @@ jobs:
         uses: github/codeql-action/analyze@v3
 
   build-wayland:
+    if: ${{ github.event.workflow_run.conclusion == 'success' }}
     strategy:
       matrix:
         os: [ubuntu-22.04, ubuntu-24.04]

+ 62 - 0
.github/workflows/tests.yml

@@ -0,0 +1,62 @@
+name: Unit tests
+
+on:
+  workflow_dispatch:
+  push:
+    paths:
+      - '.github/workflows/tests.yml'
+      - 'src/**'
+      - 'CMakeModules/**'
+      - CMakeLists.txt
+      - 'protocols/**'
+    branches: [main]
+  pull_request:
+    paths:
+      - '.github/workflows/tests.yml'
+      - 'src/**'
+      - 'CMakeModules/**'
+      - CMakeLists.txt
+      - 'protocols/**'
+    branches: [main]
+
+env:
+  # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.)
+  BUILD_TYPE: Release
+
+jobs:
+  testing:
+    strategy:
+      matrix:
+        os: [ubuntu-24.04]
+    # The CMake configure and build commands are platform agnostic and should work equally well on Windows or Mac.
+    # You can convert this to a matrix build if you need cross-platform coverage.
+    # See: https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/managing-complex-workflows#using-a-build-matrix
+    runs-on: ${{ matrix.os }}
+
+    steps:
+    - uses: actions/checkout@v3
+      with:
+        submodules: true
+
+    # cache some build information
+    - name: ccache
+      uses: hendrikmuhs/ccache-action@v1.2
+      with:
+        key: ${{ github.job }}-${{ matrix.os }}  # Eg. "some_build-ubuntu-latest"
+
+    - name: Install dependencies
+      if: matrix.os == 'ubuntu-24.04'
+      run: sudo apt-get update && sudo apt-get -y install libgl-dev libglew-dev freeglut3-dev libsdl2-dev liblz4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libglm-dev libglfw3-dev libmpv-dev mpv libmpv2 libfftw3-dev
+
+    - name: Configure CMake
+      # Configure CMake in a 'build' subdirectory. `CMAKE_BUILD_TYPE` is only required if you are using a single-configuration generator such as make.
+      # See https://cmake.org/cmake/help/latest/variable/CMAKE_BUILD_TYPE.html?highlight=cmake_build_type
+      run: cmake -B ${{github.workspace}}/build -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} -DBUILD_TESTING=1
+
+    - name: Build
+      # Build your program with the given configuration
+      run: cmake --build ${{github.workspace}}/build --config ${{env.BUILD_TYPE}}
+
+    - name: Run tests
+      # Run the unit tests available
+      run: ${{github.workspace}}/build/output/tests

+ 3 - 0
.gitmodules

@@ -26,3 +26,6 @@
 	path = src/External/argparse
 	url = https://github.com/p-ranav/argparse.git
 	branch = master
+[submodule "src/External/Catch2"]
+	path = src/External/Catch2
+	url = https://github.com/catchorg/Catch2.git

+ 73 - 31
CMakeLists.txt

@@ -12,20 +12,18 @@ set(CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/CMakeModules")
 set(OpenGL_GL_PREFERENCE "LEGACY")
 set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
 
+# puts the app in demomode, which records a 5 second webm video and quits
 if(NOT DEMOMODE)
     set(DEMOMODE 0)
 endif()
 
+# if you're developing you might find this debug option useful for shader output, although RenderDoc is encouraged
 if(NOT ERRORONLY)
     set(ERRORONLY 0)
 endif()
 
 set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-narrowing")
 
-# if you're developing you might find this debug option useful for shader output, although RenderDoc is encouraged
-add_compile_definitions(ERRORONLY=${ERRORONLY})
-add_compile_definitions(DEMOMODE=${DEMOMODE})
-
 find_package(X11)
 find_package(OpenGL REQUIRED)
 find_package(GLEW REQUIRED)
@@ -133,7 +131,9 @@ add_subdirectory(src/External/glslang-WallpaperEngine glslang)
 add_subdirectory(src/External/SPIRV-Cross-WallpaperEngine spirv-cross)
 add_subdirectory(src/External/kissfft kissfft)
 add_subdirectory(src/External/argparse argparse)
-
+if(BUILD_TESTING)
+    add_subdirectory(src/External/Catch2)
+endif()
 # try to enable wayland builds when possible
 pkg_check_modules(WAYLAND_SUPPORT wayland-cursor wayland-protocols egl wayland-egl)
 
@@ -164,7 +164,6 @@ if(WAYLAND_SUPPORT_FOUND)
     add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/xdg-shell-protocol.c
         COMMAND ${WaylandScanner} private-code ${WAYLAND_PROTOCOLS_DIR}/stable/xdg-shell/xdg-shell.xml ${WAYLAND_OUTPUT_DIR}/xdg-shell-protocol.c)
 
-    add_compile_definitions(ENABLE_WAYLAND)
     include_directories(${WAYLAND_SUPPORT_INCLUDE_DIRS})
     include_directories(${WAYLAND_OUTPUT_DIR})
     set(WAYLAND_LIBRARIES
@@ -207,7 +206,6 @@ endif()
 
 if(X11_SUPPORT_FOUND)
     include_directories(${X11_INCLUDE_DIR} ${XRANDR_INCLUDE_DIR})
-    add_compile_definitions(ENABLE_X11)
 
     set(X11_LIBRARIES
         ${X11_LIBRARIES}
@@ -221,13 +219,6 @@ if(X11_SUPPORT_FOUND)
         ${X11_INCLUDE_DIR}
         ${XRANDR_INCLUDE_DIR})
     set(CMAKE_REQUIRED_LIBRARIES ${X11_LIBRARIES})
-
-    # make sure some of the X11 functions we'll use are available
-    check_function_exists(XSetIOErrorExitHandler HAVE_XSETIOERROREXITHANDLER)
-
-    if(HAVE_XSETIOERROREXITHANDLER)
-        add_compile_definitions(HAVE_XSETIOERROREXITHANDLER=1)
-    endif()
 endif()
 
 if(DEMOMODE)
@@ -265,10 +256,7 @@ include_directories(
     ${CMAKE_SOURCE_DIR}
     ${X11_INCLUDES})
 
-add_executable(
-    linux-wallpaperengine
-    src/main.cpp
-
+set(COMMON_SOURCES
     src/External/MimeTypes/MimeTypes.cpp
     src/External/MimeTypes/MimeTypes.h
 
@@ -465,8 +453,29 @@ add_executable(
     src/WallpaperEngine/Data/Builders/VectorBuilder.h
     src/WallpaperEngine/Data/Dumpers/StringPrinter.cpp
     src/WallpaperEngine/Data/Dumpers/StringPrinter.h
-    src/WallpaperEngine/Data/JSON.cpp
+    src/WallpaperEngine/Data/JSON.cpp)
+
+if(BUILD_TESTING)
+    add_executable(
+        tests
+
+        ${COMMON_SOURCES}
+
+        src/WallpaperEngine/Testing/Cases/Example.cpp
+        src/WallpaperEngine/Testing/Render/TestingOpenGLDriver.cpp
+        src/WallpaperEngine/Testing/Render/TestingOpenGLDriver.h
+        src/WallpaperEngine/Testing/Input/TestingMouseInput.cpp
+        src/WallpaperEngine/Testing/Input/TestingMouseInput.h
+        src/WallpaperEngine/Testing/Harnesses/RenderHarness.cpp
+        src/WallpaperEngine/Testing/Harnesses/RenderHarness.h
+        src/WallpaperEngine/Testing/Cases/MouseCoordinates.cpp)
+endif()
 
+add_executable(
+    linux-wallpaperengine
+    src/main.cpp
+
+    ${COMMON_SOURCES}
     ${WAYLAND_SOURCES}
     ${X11_SOURCES}
     ${DEMOMODE_SOURCES})
@@ -492,6 +501,51 @@ target_link_libraries (linux-wallpaperengine PUBLIC
     libcef_dll_wrapper
     argparse)
 
+if (BUILD_TESTING)
+    target_link_libraries (tests PRIVATE
+        Catch2::Catch2WithMain
+        ${OPENGL_LIBRARIES}
+        GLEW::GLEW
+        ${GLUT_LIBRARIES}
+        ${ZLIB_LIBRARIES}
+        ${LZ4_LIBRARY}
+        ${SDL2_LIBRARIES}
+        ${FFMPEG_LIBRARIES}
+        ${MPV_LIBRARY}
+        ${PULSEAUDIO_LIBRARY}
+        ${WAYLAND_LIBRARIES}
+        ${X11_LIBRARIES}
+        kissfft
+        glslang
+        spirv-cross-core
+        spirv-cross-glsl
+        glfw
+        libcef_lib
+        libcef_dll_wrapper
+        argparse)
+endif()
+
+target_compile_definitions(linux-wallpaperengine PUBLIC ERRORONLY=${ERRORONLY})
+target_compile_definitions(linux-wallpaperengine PUBLIC DEMOMODE=${DEMOMODE})
+# tests should give as much output as possible
+target_compile_definitions(tests PRIVATE ERRORONLY=0)
+target_compile_definitions(tests PRIVATE DEMOMODE=0)
+
+if(X11_SUPPORT_FOUND)
+    target_compile_definitions(linux-wallpaperengine PUBLIC ENABLE_X11)
+
+    # make sure some of the X11 functions we'll use are available
+    check_function_exists(XSetIOErrorExitHandler HAVE_XSETIOERROREXITHANDLER)
+
+    if(HAVE_XSETIOERROREXITHANDLER)
+        target_compile_definitions(linux-wallpaperengine PUBLIC HAVE_XSETIOERROREXITHANDLER=1)
+    endif()
+endif()
+
+if(WAYLAND_SUPPORT_FOUND)
+    target_compile_definitions(linux-wallpaperengine PUBLIC ENABLE_WAYLAND)
+endif()
+
 COPY_FILES(linux-wallpaperengine "${CEF_BINARY_FILES}" "${CEF_BINARY_DIR}" "${TARGET_OUTPUT_DIRECTORY}")
 COPY_FILES(linux-wallpaperengine "${CEF_RESOURCE_FILES}" "${CEF_RESOURCE_DIR}" "${TARGET_OUTPUT_DIRECTORY}")
 # remove the vulkan lib as chromium includes a broken libvulkan.so.1 with it
@@ -504,15 +558,3 @@ REPLACED_SET_EXECUTABLE_TARGET_PROPERTIES(linux-wallpaperengine)
 # set some install parameters if not in debug mode
 install(DIRECTORY ${TARGET_OUTPUT_DIRECTORY}/ DESTINATION .)
 install(FILES ${TARGET_OUTPUT_DIRECTORY}/${PROJECT_NAME} PERMISSIONS OWNER_READ OWNER_WRITE WORLD_EXECUTE WORLD_READ GROUP_READ DESTINATION .)
-
-# Unit tests for mouse coordinate conversions
-enable_testing()
-add_executable(test_mouse_coordinates
-    src/WallpaperEngine/Input/Drivers/test_mouse_coordinates.cpp
-)
-target_include_directories(test_mouse_coordinates PRIVATE
-    ${CMAKE_CURRENT_SOURCE_DIR}/src
-    ${CMAKE_CURRENT_SOURCE_DIR}
-)
-# GLM is header-only, no linking needed
-add_test(NAME input_mouse_coordinates COMMAND test_mouse_coordinates)

+ 0 - 106
PR_DESCRIPTION.md

@@ -1,106 +0,0 @@
-# Fix: Mouse Y-axis inversion on vertical axis
-
-## Problem
-
-Mouse interaction is inverted on the vertical axis only. This affects:
-- Particle system mouse-linked control points (introduced in #387, #400)
-- Parallax effects
-- Web wallpaper mouse interactions
-- Any wallpaper that responds to mouse position
-
-## Root Cause
-
-The issue stems from coordinate system mismatches between windowing systems and OpenGL:
-
-1. **GLFW/Wayland coordinate system**: Y=0 at top, Y=height at bottom (window coordinates)
-2. **OpenGL coordinate system**: Y=0 at bottom, Y=height at top (framebuffer coordinates)
-3. **CEF coordinate system**: Y=0 at top (browser coordinates)
-
-The code was not converting between these coordinate systems, causing the Y-axis to be inverted.
-
-## Solution
-
-Fixed coordinate system conversions in four locations:
-
-### 1. GLFW Mouse Input (`GLFWMouseInput.cpp`)
-- Converts from GLFW coordinates (Y=0 at top) to OpenGL coordinates (Y=0 at bottom)
-- Uses framebuffer size to invert Y coordinate
-
-### 2. Wayland Mouse Input (`WaylandOpenGLDriver.cpp`)
-- Converts from Wayland coordinates (Y=0 at top) to OpenGL coordinates (Y=0 at bottom)
-- Inverts Y before applying scale factor
-
-### 3. Scene Mouse Normalization (`CScene.cpp`)
-- Documents OpenGL coordinate convention (0=bottom, 1=top)
-- No code changes - only adds clarifying comments
-- Particle code expects normalized coordinates where 0=bottom, 1=top (OpenGL convention)
-
-### 4. Web Wallpaper Mouse Events (`CWeb.cpp`)
-- Converts from OpenGL coordinates (Y=0 at bottom) to CEF coordinates (Y=0 at top)
-- Ensures web wallpapers receive correct mouse coordinates
-
-## Testing
-
-### Automated Tests
-- [x] Unit tests added using doctest framework
-- [x] Tests verify all coordinate conversions (GLFW→OpenGL, Wayland→OpenGL, OpenGL→Normalized, OpenGL→CEF)
-- [x] Tests verify complete coordinate flow
-- [x] Tests cover different viewport sizes
-- [x] All tests pass (6 test cases, 17 assertions)
-
-Run tests with:
-```bash
-cd build
-make test_mouse_coordinates
-./output/test_mouse_coordinates
-# Or use ctest:
-ctest -R mouse_coordinate
-```
-
-### Manual Testing
-- [x] Tested with particle wallpapers using mouse-linked control points
-- [x] Tested with parallax-enabled wallpapers
-- [x] Tested with web wallpapers
-- [x] Verified both GLFW (X11) and Wayland backends
-- [x] Tested with various window sizes and viewport configurations
-
-### Test Plan
-See `TEST_PLAN.md` for detailed manual test instructions covering:
-- Basic coordinate conversion verification
-- Normalized coordinate range validation
-- Multi-backend consistency (GLFW/Wayland)
-- High DPI/scaling scenarios
-- Web wallpaper (CEF) coordinate handling
-- Window resizing edge cases
-
-**Note**: Automated unit tests for coordinate conversion would be valuable but require integration with the rendering system. Manual testing has been comprehensive and covers all identified use cases.
-
-## Impact
-
-- **Breaking**: No breaking changes
-- **Affected features**: Mouse interaction with wallpapers (particles, parallax, web)
-- **Backward compatibility**: Maintained - fixes incorrect behavior
-
-## Related
-
-This bug has existed since the initial implementation (2023). The recent particle rendering feature (#387, #400) made the issue more noticeable as it relies heavily on accurate mouse coordinates.
-
-## Files Changed
-
-- `src/WallpaperEngine/Input/Drivers/GLFWMouseInput.cpp`
-- `src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.cpp`
-- `src/WallpaperEngine/Render/Wallpapers/CScene.cpp`
-- `src/WallpaperEngine/Render/Wallpapers/CWeb.cpp`
-
-## Documentation
-
-- Added `COORDINATE_SYSTEM_DOCS.md` - Comprehensive documentation of coordinate system conventions
-- Added `TEST_PLAN.md` - Detailed test plan and instructions
-- Enhanced inline comments explaining coordinate conversions
-
-## Backward Compatibility
-
-This fix corrects incorrect behavior that has existed since 2023. The previous inverted behavior was a bug, not an intentional feature. No wallpapers should depend on the incorrect behavior, as it would have been unusable. However, if any user code or shaders were written expecting the old (incorrect) coordinates, they would need to be updated.
-
-**Migration**: No migration needed - this fixes incorrect behavior to match expected behavior.
-

+ 1 - 0
src/External/Catch2

@@ -0,0 +1 @@
+Subproject commit b3fb4b9feafcd8d91c5cb510a4775143fdbef02f

+ 7 - 4
src/WallpaperEngine/Application/ApplicationContext.cpp

@@ -231,6 +231,9 @@ ApplicationContext::getPlaylistFromConfig (const std::string& name) {
 ApplicationContext::ApplicationContext (int argc, char* argv []) :
     m_argc (argc),
     m_argv (argv) {
+}
+
+void ApplicationContext::loadSettingsFromArgv () {
     std::string lastScreen;
 
     argparse::ArgumentParser program ("linux-wallpaperengine", "0.0", argparse::default_arguments::help);
@@ -493,7 +496,7 @@ ApplicationContext::ApplicationContext (int argc, char* argv []) :
     );
 
     try {
-        program.parse_known_args (argc, argv);
+        program.parse_known_args (this->m_argc, this->m_argv);
 
         if (this->settings.general.defaultBackground.empty ()) {
             throw std::runtime_error ("At least one background ID must be specified");
@@ -508,8 +511,8 @@ ApplicationContext::ApplicationContext (int argc, char* argv []) :
 
         bufferStream << "Running with: ";
 
-        for (int i = 0; i < argc; i ++) {
-            bufferStream << argv [i];
+        for (int i = 0; i < this->m_argc; i ++) {
+            bufferStream << this->m_argv [i];
             bufferStream << " ";
         }
 
@@ -532,7 +535,7 @@ ApplicationContext::ApplicationContext (int argc, char* argv []) :
         this->settings.render.pauseOnFullscreen = false;
 #endif /* DEMOMODE */
    } catch (const std::runtime_error& e) {
-       throw std::runtime_error (std::string (e.what()) + ". Use " + std::string (argv[0]) + " --help for more information");
+       throw std::runtime_error (std::string (e.what()) + ". Use " + std::string (this->m_argv[0]) + " --help for more information");
    }
 }
 

+ 5 - 0
src/WallpaperEngine/Application/ApplicationContext.h

@@ -26,6 +26,11 @@ class ApplicationContext {
   public:
     ApplicationContext (int argc, char* argv []);
 
+    /**
+     * Parses the given argc and argv and builds settings for the app
+     */
+    void loadSettingsFromArgv ();
+
     enum WINDOW_MODE {
         /** Default window mode */
         NORMAL_WINDOW = 0,

+ 5 - 0
src/WallpaperEngine/Testing/Cases/Example.cpp

@@ -0,0 +1,5 @@
+#include <catch2/catch_test_macros.hpp>
+
+TEST_CASE ("Example test") {
+    REQUIRE (true);
+}

+ 18 - 31
src/WallpaperEngine/Input/Drivers/test_mouse_coordinates.cpp → src/WallpaperEngine/Testing/Cases/MouseCoordinates.cpp

@@ -1,17 +1,7 @@
-/**
- * Unit tests for mouse coordinate system conversions
- * 
- * Tests verify that coordinate conversions between different systems
- * (GLFW/Wayland → OpenGL → Normalized → CEF) work correctly.
- */
-
-#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
-#include "../../../External/json/tests/thirdparty/doctest/doctest.h"
+#include <catch2/catch_test_macros.hpp>
 #include <glm/glm.hpp>
 #include <cmath>
 
-namespace WallpaperEngine::Input::Drivers::Test {
-
 /**
  * Test GLFW to OpenGL coordinate conversion
  * GLFW: Y=0 at top, Y=height at bottom
@@ -19,17 +9,17 @@ namespace WallpaperEngine::Input::Drivers::Test {
  */
 TEST_CASE("GLFW to OpenGL coordinate conversion") {
     const int framebufferHeight = 1080;
-    
+
     // Mouse at top of screen (GLFW: Y=0)
     double glfwY = 0.0;
     double openglY = static_cast<double>(framebufferHeight) - glfwY;
     CHECK(openglY == 1080.0); // Should be at top in OpenGL (Y=height)
-    
+
     // Mouse at bottom of screen (GLFW: Y=height)
     glfwY = 1080.0;
     openglY = static_cast<double>(framebufferHeight) - glfwY;
     CHECK(openglY == 0.0); // Should be at bottom in OpenGL (Y=0)
-    
+
     // Mouse at middle of screen
     glfwY = 540.0;
     openglY = static_cast<double>(framebufferHeight) - glfwY;
@@ -43,17 +33,17 @@ TEST_CASE("GLFW to OpenGL coordinate conversion") {
  */
 TEST_CASE("Wayland to OpenGL coordinate conversion") {
     const double viewportHeight = 1080.0;
-    
+
     // Mouse at top of screen (Wayland: Y=0)
     double waylandY = 0.0;
     double openglY = viewportHeight - waylandY;
     CHECK(openglY == 1080.0); // Should be at top in OpenGL
-    
+
     // Mouse at bottom of screen (Wayland: Y=height)
     waylandY = 1080.0;
     openglY = viewportHeight - waylandY;
     CHECK(openglY == 0.0); // Should be at bottom in OpenGL
-    
+
     // Mouse at middle of screen
     waylandY = 540.0;
     openglY = viewportHeight - waylandY;
@@ -68,17 +58,17 @@ TEST_CASE("Wayland to OpenGL coordinate conversion") {
 TEST_CASE("OpenGL to normalized coordinate conversion") {
     const int viewportY = 0;
     const int viewportHeight = 1080;
-    
+
     // Mouse at top in OpenGL (Y=height)
     double openglY = 1080.0;
     double normalizedY = glm::clamp((openglY - viewportY) / static_cast<double>(viewportHeight), 0.0, 1.0);
     CHECK(normalizedY == 1.0); // Should be 1.0 (top)
-    
+
     // Mouse at bottom in OpenGL (Y=0)
     openglY = 0.0;
     normalizedY = glm::clamp((openglY - viewportY) / static_cast<double>(viewportHeight), 0.0, 1.0);
     CHECK(normalizedY == 0.0); // Should be 0.0 (bottom)
-    
+
     // Mouse at middle
     openglY = 540.0;
     normalizedY = glm::clamp((openglY - viewportY) / static_cast<double>(viewportHeight), 0.0, 1.0);
@@ -93,19 +83,19 @@ TEST_CASE("OpenGL to normalized coordinate conversion") {
 TEST_CASE("OpenGL to CEF coordinate conversion") {
     const int viewportHeight = 1080;
     const int viewportY = 0;
-    
+
     // Mouse at top in OpenGL (Y=height)
     double openglY = 1080.0;
     int clampedY = std::clamp(static_cast<int>(openglY - viewportY), 0, viewportHeight);
     int cefY = viewportHeight - clampedY;
     CHECK(cefY == 0); // Should be 0 (top in CEF)
-    
+
     // Mouse at bottom in OpenGL (Y=0)
     openglY = 0.0;
     clampedY = std::clamp(static_cast<int>(openglY - viewportY), 0, viewportHeight);
     cefY = viewportHeight - clampedY;
     CHECK(cefY == 1080); // Should be height (bottom in CEF)
-    
+
     // Mouse at middle
     openglY = 540.0;
     clampedY = std::clamp(static_cast<int>(openglY - viewportY), 0, viewportHeight);
@@ -121,13 +111,13 @@ TEST_CASE("Complete coordinate flow: GLFW to normalized") {
     const int framebufferHeight = 1080;
     const int viewportY = 0;
     const int viewportHeight = 1080;
-    
+
     // Mouse at top of screen
     double glfwY = 0.0;
     double openglY = static_cast<double>(framebufferHeight) - glfwY; // Convert to OpenGL
     double normalizedY = glm::clamp((openglY - viewportY) / static_cast<double>(viewportHeight), 0.0, 1.0);
     CHECK(normalizedY == 1.0); // Top should normalize to 1.0
-    
+
     // Mouse at bottom of screen
     glfwY = 1080.0;
     openglY = static_cast<double>(framebufferHeight) - glfwY;
@@ -147,7 +137,7 @@ TEST_CASE("Coordinate conversion with different viewport sizes") {
         double openglY = static_cast<double>(height) - glfwY;
         CHECK(openglY == 1080.0);
     }
-    
+
     // Test with 2560x1440 viewport
     {
         const int height = 1440;
@@ -155,7 +145,7 @@ TEST_CASE("Coordinate conversion with different viewport sizes") {
         double openglY = static_cast<double>(height) - glfwY;
         CHECK(openglY == 1440.0);
     }
-    
+
     // Test with 800x600 viewport
     {
         const int height = 600;
@@ -163,7 +153,4 @@ TEST_CASE("Coordinate conversion with different viewport sizes") {
         double openglY = static_cast<double>(height) - glfwY;
         CHECK(openglY == 600.0);
     }
-}
-
-} // namespace WallpaperEngine::Input::Drivers::Test
-
+}

+ 28 - 0
src/WallpaperEngine/Testing/Harnesses/RenderHarness.cpp

@@ -0,0 +1,28 @@
+#include "RenderHarness.h"
+
+using namespace WallpaperEngine::Testing::Harnesses;
+
+const char* argv[] = {
+    "",
+};
+
+RenderHarness::RenderHarness(ApplicationContext* context, WallpaperApplication* app) :
+    m_context (context),
+    m_app (app),
+    m_driver (*context, *app) {
+}
+
+RenderHarness::~RenderHarness() {
+    delete this->m_app;
+    delete this->m_context;
+}
+
+RenderHarness* RenderHarness::build (std::filesystem::path base) {
+    // build context, app and return a harness that owns it
+    auto context = new ApplicationContext (1, const_cast<char**> (argv));
+
+    return new RenderHarness (
+        context,
+        new WallpaperApplication (*context)
+    );
+}

+ 27 - 0
src/WallpaperEngine/Testing/Harnesses/RenderHarness.h

@@ -0,0 +1,27 @@
+#pragma once
+
+#include "WallpaperEngine/Testing/Render/TestingOpenGLDriver.h"
+
+namespace WallpaperEngine::Testing::Harnesses {
+using namespace WallpaperEngine::Testing::Render;
+/**
+ * Set of tools to interact and inspect what the render is doing
+ *
+ * IMPORTANT: THIS REPLACES SOME gl* METHODS WITH SHIMS
+ * TO PROPERLY ALLOW FOR TRACKING AND INSPECTING DATA, SO BE CAREFUL
+ * IF YOU NEED TO USE ANY gl* CALLS IN YOUR TESTS
+ */
+class RenderHarness {
+  public:
+    static RenderHarness* build (std::filesystem::path base);
+
+    ~RenderHarness ();
+  protected:
+    RenderHarness (ApplicationContext* context, WallpaperApplication* app);
+
+  private:
+    TestingOpenGLDriver m_driver;
+    ApplicationContext* m_context;
+    WallpaperApplication* m_app;
+};
+} // namespace WallpaperEngine::Testing::Harnesses

+ 19 - 0
src/WallpaperEngine/Testing/Input/TestingMouseInput.cpp

@@ -0,0 +1,19 @@
+#include "TestingMouseInput.h"
+
+using namespace WallpaperEngine::Testing::Input;
+
+
+void TestingMouseInput::update () {
+}
+
+glm::dvec2 TestingMouseInput::position () const {
+    return {};
+}
+
+MouseClickStatus TestingMouseInput::leftClick () const {
+    return MouseClickStatus::Released;
+}
+
+MouseClickStatus TestingMouseInput::rightClick () const {
+    return MouseClickStatus::Released;
+}

+ 15 - 0
src/WallpaperEngine/Testing/Input/TestingMouseInput.h

@@ -0,0 +1,15 @@
+#pragma once
+
+#include "WallpaperEngine/Input/InputContext.h"
+
+namespace WallpaperEngine::Testing::Input {
+using namespace WallpaperEngine::Input;
+
+class TestingMouseInput final : public MouseInput {
+  public:
+    void update () override;
+    [[nodiscard]] glm::dvec2 position () const override;
+    [[nodiscard]] MouseClickStatus leftClick () const override;
+    [[nodiscard]] MouseClickStatus rightClick () const override;
+};
+} // namespace WallpaperEngine::Testing::Input

+ 149 - 0
src/WallpaperEngine/Testing/Render/TestingOpenGLDriver.cpp

@@ -0,0 +1,149 @@
+#include "TestingOpenGLDriver.h"
+
+#include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Render/Drivers/Output/GLFWWindowOutput.h"
+
+using namespace WallpaperEngine::Testing::Render;
+
+void TestingCustomGLFWErrorHandler (int errorCode, const char* reason) {
+    sLog.error ("GLFW error ", errorCode, ": ", reason);
+}
+
+TestingOpenGLDriver::TestingOpenGLDriver(ApplicationContext& context, WallpaperApplication& app) :
+    m_mouseInput (),
+    VideoDriver (app, m_mouseInput),
+    m_context (context) {
+    glfwSetErrorCallback (TestingCustomGLFWErrorHandler);
+
+    // initialize glfw
+    if (glfwInit () == GLFW_FALSE)
+        sLog.exception ("Failed to initialize glfw");
+
+    // set some window hints (opengl version to be used)
+    glfwWindowHint (GLFW_SAMPLES, 4);
+    glfwWindowHint (GLFW_CONTEXT_VERSION_MAJOR, 3);
+    glfwWindowHint (GLFW_CONTEXT_VERSION_MINOR, 3);
+    glfwWindowHint (GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
+    glfwWindowHint (GLFW_VISIBLE, GLFW_FALSE);
+    // set X11-specific hints
+    glfwWindowHintString (GLFW_X11_CLASS_NAME, "linux-wallpaperengine debug window");
+    glfwWindowHintString (GLFW_X11_INSTANCE_NAME, "linux-wallpaperengine debug window");
+
+    glfwWindowHint (GLFW_OPENGL_DEBUG_CONTEXT, GL_TRUE);
+
+    // create window, size doesn't matter as long as we don't show it
+    this->m_window = glfwCreateWindow (640, 480, "linux-wallpaperengine debug window", nullptr, nullptr);
+
+    if (this->m_window == nullptr)
+        sLog.exception ("Cannot create window");
+
+    // make context current, required for glew initialization
+    glfwMakeContextCurrent (this->m_window);
+
+    // initialize glew for rendering
+    const GLenum result = glewInit ();
+
+    if (result != GLEW_OK)
+        sLog.error ("Failed to initialize GLEW: ", glewGetErrorString (result));
+
+    // setup output
+    if (context.settings.render.mode == ApplicationContext::EXPLICIT_WINDOW ||
+        context.settings.render.mode == ApplicationContext::NORMAL_WINDOW) {
+        m_output = new WallpaperEngine::Render::Drivers::Output::GLFWWindowOutput (context, *this);
+    } else {
+        throw std::runtime_error ("Invalid render mode");
+    }
+}
+
+TestingOpenGLDriver::~TestingOpenGLDriver () {
+    delete this->m_output;
+    glfwTerminate();
+}
+
+
+Output::Output& TestingOpenGLDriver::getOutput () {
+    return *this->m_output;
+}
+
+void* TestingOpenGLDriver::getProcAddress (const char* name) const {
+    return reinterpret_cast<void*> (glfwGetProcAddress (name));
+}
+
+float TestingOpenGLDriver::getRenderTime () const {
+    return static_cast<float> (glfwGetTime ());
+}
+
+
+bool TestingOpenGLDriver::closeRequested () {
+    return glfwWindowShouldClose (this->m_window);
+}
+
+void TestingOpenGLDriver::resizeWindow (glm::ivec2 size) {
+    glfwSetWindowSize (this->m_window, size.x, size.y);
+}
+
+void TestingOpenGLDriver::resizeWindow (glm::ivec4 sizeandpos) {
+    glfwSetWindowPos (this->m_window, sizeandpos.x, sizeandpos.y);
+    glfwSetWindowSize (this->m_window, sizeandpos.z, sizeandpos.w);
+}
+
+void TestingOpenGLDriver::showWindow () {
+    glfwShowWindow (this->m_window);
+}
+
+void TestingOpenGLDriver::hideWindow () {
+    glfwHideWindow (this->m_window);
+}
+
+glm::ivec2 TestingOpenGLDriver::getFramebufferSize () const {
+    glm::ivec2 size;
+
+    glfwGetFramebufferSize (this->m_window, &size.x, &size.y);
+
+    return size;
+}
+
+uint32_t TestingOpenGLDriver::getFrameCounter () const {
+    return this->m_frameCounter;
+}
+void TestingOpenGLDriver::dispatchEventQueue () {
+    static float startTime, endTime, minimumTime = 1.0f / this->m_context.settings.render.maximumFPS;
+    // get the start time of the frame
+    startTime = this->getRenderTime ();
+    // clear the screen
+    glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
+
+    for (const auto& [screen, viewport] : this->m_output->getViewports ())
+        this->getApp ().update (viewport);
+
+    // read the full texture into the image
+    if (this->m_output->haveImageBuffer ()) {
+        // 4.5 supports glReadnPixels, anything older doesn't...
+        if (GLEW_VERSION_4_5) {
+            glReadnPixels (0, 0, this->m_output->getFullWidth (), this->m_output->getFullHeight (), GL_BGRA,
+                           GL_UNSIGNED_BYTE, this->m_output->getImageBufferSize (), this->m_output->getImageBuffer ());
+        } else {
+            // fallback to old version
+            glReadPixels (0, 0, this->m_output->getFullWidth (), this->m_output->getFullHeight (), GL_BGRA, GL_UNSIGNED_BYTE, this->m_output->getImageBuffer ());
+        }
+
+        GLenum error = glGetError();
+
+        if (error != GL_NO_ERROR) {
+            sLog.exception("OpenGL error when reading texture ", error);
+        }
+    }
+
+    // TODO: FRAMETIME CONTROL SHOULD GO BACK TO THE CWALLPAPAERAPPLICATION ONCE ACTUAL PARTICLES ARE IMPLEMENTED
+    // TODO: AS THOSE, MORE THAN LIKELY, WILL REQUIRE OF A DIFFERENT PROCESSING RATE
+    // update the output with the given image
+    this->m_output->updateRender ();
+    // do buffer swapping first
+    glfwSwapBuffers (this->m_window);
+    // poll for events
+    glfwPollEvents ();
+    // increase frame counter
+    this->m_frameCounter++;
+    // get the end time of the frame
+    endTime = this->getRenderTime ();
+}

+ 40 - 0
src/WallpaperEngine/Testing/Render/TestingOpenGLDriver.h

@@ -0,0 +1,40 @@
+#pragma once
+
+#include "WallpaperEngine/Input/Drivers/GLFWMouseInput.h"
+#include "WallpaperEngine/Render/Drivers/VideoDriver.h"
+#include "WallpaperEngine/Application/WallpaperApplication.h"
+
+#include "WallpaperEngine/Testing/Input/TestingMouseInput.h"
+
+#include <GL/glew.h>
+#include <GLFW/glfw3.h>
+
+namespace WallpaperEngine::Testing::Render {
+using namespace WallpaperEngine::Render::Drivers;
+using namespace WallpaperEngine::Testing::Input;
+
+class TestingOpenGLDriver final  : public VideoDriver {
+  public:
+    explicit TestingOpenGLDriver (ApplicationContext& context, WallpaperApplication& app);
+    ~TestingOpenGLDriver () override;
+
+    [[nodiscard]] Output::Output & getOutput() override;
+    [[nodiscard]] void* getProcAddress (const char* name) const override;
+    [[nodiscard]] float getRenderTime () const override;
+    bool closeRequested () override;
+    void resizeWindow (glm::ivec2 size) override;
+    void resizeWindow (glm::ivec4 sizeandpos) override;
+    void showWindow () override;
+    void hideWindow () override;
+    [[nodiscard]] glm::ivec2 getFramebufferSize () const override;
+    [[nodiscard]] uint32_t getFrameCounter () const override;
+    void dispatchEventQueue () override;
+
+  private:
+    Output::Output* m_output = nullptr;
+    ApplicationContext& m_context;
+    GLFWwindow* m_window = nullptr;
+    TestingMouseInput m_mouseInput;
+    uint32_t m_frameCounter = 0;
+};
+} // namespace WallpaperEngine::Testing::Render

+ 2 - 0
src/main.cpp

@@ -44,6 +44,8 @@ int main (int argc, char* argv[]) {
 
         WallpaperEngine::Application::ApplicationContext appContext (argc, argv);
 
+        appContext.loadSettingsFromArgv ();
+
         app = new WallpaperEngine::Application::WallpaperApplication (appContext);
 
         // halt if the list-properties option was specified