diff --git a/.github/workflows/config.yml b/.github/workflows/config.yml index 5717bc57..83a56c84 100644 --- a/.github/workflows/config.yml +++ b/.github/workflows/config.yml @@ -54,6 +54,25 @@ jobs: - name: Run Unit Tests run: platformio test -e native-test + screenshot-tests: + name: Screenshot Regression Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + token: ${{ github.token }} + show-progress: false + - name: Install Dependencies + run: sudo apt-get update && sudo apt-get install -y cmake g++ + - name: Build & Run Screenshot Tests + run: ./test/test_screenshots/build_and_run.sh + - name: Upload Screenshots on Failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: screenshot-regression-output + path: test/test_screenshots/output/ + pio-build: name: PlatformIO Build runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 6e2ed49f..b97023dc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ eppg-controller.ino.cpp src/sp140/sp140.ino.cpp diagnostics-logs/ +build-screenshot/ +test/test_screenshots/output/*.bmp +test/test_screenshots/output/png/ diff --git a/inc/sp140/lvgl/lv_conf.h b/inc/sp140/lvgl/lv_conf.h index 76423aa7..8baef2fb 100644 --- a/inc/sp140/lvgl/lv_conf.h +++ b/inc/sp140/lvgl/lv_conf.h @@ -134,7 +134,7 @@ * WIDGET USAGE *=================*/ -#define LV_USE_ANIMIMAGE 0 +#define LV_USE_ANIMIMG 0 #define LV_USE_ARC 1 diff --git a/platformio.ini b/platformio.ini index 19cecb91..af0eb745 100644 --- a/platformio.ini +++ b/platformio.ini @@ -84,7 +84,29 @@ build_flags = -I .pio/libdeps/native-test/CircularBuffer -D ARDUINO=1 build_src_filter = -<*> -test_ignore = neopixel* +test_ignore = neopixel*, test_screenshots lib_deps = ArduinoJson@7.4.3 rlogiacco/CircularBuffer@^1.4.0 + +[env:native-screenshot] +platform = native +test_framework = googletest +build_type = debug +build_flags = + -I test/screenshot_stubs + -I inc + -I inc/sp140/lvgl + -D LV_CONF_INCLUDE_SIMPLE + -D LV_LVGL_H_INCLUDE_SIMPLE + -D ARDUINO=1 + -D NATIVE_SCREENSHOT_TEST=1 + -std=c++17 +build_src_filter = + -<*> + + + + + + +test_filter = test_screenshots +lib_deps = + lvgl/lvgl@^9.5.0 diff --git a/test/screenshot_stubs/Adafruit_ST7735.h b/test/screenshot_stubs/Adafruit_ST7735.h new file mode 100644 index 00000000..16b5311b --- /dev/null +++ b/test/screenshot_stubs/Adafruit_ST7735.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include "SPI.h" + +#define INITR_BLACKTAB 0 +#define ST77XX_BLACK 0x0000 + +class Adafruit_ST7735 { + public: + Adafruit_ST7735(SPIClass* spi, int8_t cs, int8_t dc, int8_t rst) { + (void)spi; (void)cs; (void)dc; (void)rst; + } + void initR(uint8_t) {} + void setRotation(uint8_t) {} + void fillScreen(uint16_t) {} + void setAddrWindow(uint16_t, uint16_t, uint16_t, uint16_t) {} + void writePixels(uint16_t*, uint32_t) {} + void startWrite() {} + void endWrite() {} +}; diff --git a/test/screenshot_stubs/Arduino.h b/test/screenshot_stubs/Arduino.h new file mode 100644 index 00000000..9986b6f3 --- /dev/null +++ b/test/screenshot_stubs/Arduino.h @@ -0,0 +1,129 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +// Basic Arduino types and helpers for native screenshot tests +using std::chrono::steady_clock; +using std::chrono::milliseconds; + +class String { + public: + String() = default; + String(const char* s) : data_(s ? s : "") {} + bool concat(char c) { data_ += c; return true; } + bool concat(const char* s) { data_ += s ? s : ""; return true; } + const char* c_str() const { return data_.c_str(); } + size_t length() const { return data_.size(); } + private: + std::string data_; +}; + +struct __FlashStringHelper; + +class Print { + public: + virtual ~Print() = default; + virtual size_t write(uint8_t) { return 1; } + virtual size_t write(const uint8_t* buffer, size_t size) { (void)buffer; return size; } + size_t write(const char* s) { return write(reinterpret_cast(s), s ? strlen(s) : 0); } + template void printf(const char*, Args...) {} + template void print(const T&) {} + template void println(const T&) {} + void println() {} +}; + +class Printable { + public: + virtual ~Printable() = default; + virtual size_t printTo(Print&) const { return 0; } +}; + +class Stream : public Print { + public: + virtual int available() { return 0; } + virtual int read() { return -1; } + virtual size_t readBytes(char* buffer, size_t length) { (void)buffer; (void)length; return 0; } +}; + +struct DummySerial : public Stream { + inline void begin(unsigned long) {} +}; + +static DummySerial USBSerial; +static DummySerial Serial; + +#ifndef PROGMEM +#define PROGMEM +#endif + +#ifndef pgm_read_byte +inline uint8_t pgm_read_byte(const void* p) { return *reinterpret_cast(p); } +#endif + +inline unsigned long millis() { + static auto start = steady_clock::now(); + return (unsigned long)std::chrono::duration_cast(steady_clock::now() - start).count(); +} + +inline void delay(unsigned long ms) { + std::this_thread::sleep_for(std::chrono::milliseconds(ms)); +} + +typedef uint16_t word; + +// FreeRTOS type stubs +typedef void* QueueHandle_t; +typedef void* TaskHandle_t; + +// GPIO stubs +#ifndef INPUT +#define INPUT 0 +#endif +#ifndef OUTPUT +#define OUTPUT 1 +#endif +#ifndef LOW +#define LOW 0 +#endif +#ifndef HIGH +#define HIGH 1 +#endif +#ifndef F +#define F(x) x +#endif + +inline void pinMode(int, int) {} +inline void digitalWrite(int, int) {} +inline int digitalRead(int) { return 0; } +inline int analogRead(int) { return 0; } +inline void analogReadResolution(int) {} + +// Math helpers +#ifndef constrain +template +inline T constrain(T x, T a, T b) { return x < a ? a : (x > b ? b : x); } +#endif + +#ifndef min +template +inline T min(T a, T b) { return a < b ? a : b; } +#endif + +#ifndef max +template +inline T max(T a, T b) { return a > b ? a : b; } +#endif + +inline long map(long x, long in_min, long in_max, long out_min, long out_max) { + return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min; +} + +// LEDC stubs (ESP32) +inline void ledcAttach(int, int, int) {} +inline void ledcWrite(int, int) {} +inline void ledcDetach(int) {} diff --git a/test/screenshot_stubs/BMS_CAN.h b/test/screenshot_stubs/BMS_CAN.h new file mode 100644 index 00000000..8d584e9d --- /dev/null +++ b/test/screenshot_stubs/BMS_CAN.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +// Stub BMS_CAN class for native builds +class BMS_CAN { + public: + static constexpr float TEMP_PROBE_DISCONNECTED = -999.0f; + BMS_CAN() {} +}; diff --git a/test/screenshot_stubs/NimBLEDevice.h b/test/screenshot_stubs/NimBLEDevice.h new file mode 100644 index 00000000..95ef6045 --- /dev/null +++ b/test/screenshot_stubs/NimBLEDevice.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +// Minimal NimBLE stubs for native builds +class NimBLECharacteristic { + public: + void setValue(const uint8_t*, size_t) {} + void notify() {} +}; + +class NimBLEServer { + public: + uint16_t getConnectedCount() { return 0; } +}; + +class NimBLEService {}; +class NimBLEAdvertising {}; + +class NimBLEDevice { + public: + static void init(const char*) {} + static NimBLEServer* createServer() { return nullptr; } +}; diff --git a/test/screenshot_stubs/NimBLEServer.h b/test/screenshot_stubs/NimBLEServer.h new file mode 100644 index 00000000..99137d78 --- /dev/null +++ b/test/screenshot_stubs/NimBLEServer.h @@ -0,0 +1,2 @@ +#pragma once +#include "NimBLEDevice.h" diff --git a/test/screenshot_stubs/NimBLEUtils.h b/test/screenshot_stubs/NimBLEUtils.h new file mode 100644 index 00000000..d9e01524 --- /dev/null +++ b/test/screenshot_stubs/NimBLEUtils.h @@ -0,0 +1,2 @@ +#pragma once +// Stub - no content needed diff --git a/test/screenshot_stubs/SPI.h b/test/screenshot_stubs/SPI.h new file mode 100644 index 00000000..c20b20c6 --- /dev/null +++ b/test/screenshot_stubs/SPI.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +#define HSPI 2 + +class SPIClass { + public: + SPIClass(int bus = 0) { (void)bus; } + void begin(int sck = -1, int miso = -1, int mosi = -1, int ss = -1) { + (void)sck; (void)miso; (void)mosi; (void)ss; + } + void end() {} +}; diff --git a/test/screenshot_stubs/freertos/FreeRTOS.h b/test/screenshot_stubs/freertos/FreeRTOS.h new file mode 100644 index 00000000..5b73144c --- /dev/null +++ b/test/screenshot_stubs/freertos/FreeRTOS.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +// FreeRTOS type stubs for native builds +typedef void* SemaphoreHandle_t; +typedef void* QueueHandle_t; +typedef void* TaskHandle_t; +typedef uint32_t TickType_t; +typedef int32_t BaseType_t; +typedef uint32_t UBaseType_t; + +#define pdTRUE 1 +#define pdFALSE 0 +#define pdPASS pdTRUE +#define portMAX_DELAY 0xFFFFFFFF + +inline TickType_t pdMS_TO_TICKS(uint32_t ms) { return ms; } + +// Semaphore stubs +inline SemaphoreHandle_t xSemaphoreCreateMutex() { return (void*)1; } +inline BaseType_t xSemaphoreTake(SemaphoreHandle_t, TickType_t) { return pdTRUE; } +inline BaseType_t xSemaphoreGive(SemaphoreHandle_t) { return pdTRUE; } + +// Task stubs +inline void vTaskDelay(TickType_t) {} +inline void vTaskDelete(TaskHandle_t) {} + +// Queue stubs +inline QueueHandle_t xQueueCreate(UBaseType_t, UBaseType_t) { return (void*)1; } +inline BaseType_t xQueueSend(QueueHandle_t, const void*, TickType_t) { return pdTRUE; } +inline BaseType_t xQueueReceive(QueueHandle_t, void*, TickType_t) { return pdFALSE; } +inline BaseType_t xQueueOverwrite(QueueHandle_t, const void*) { return pdTRUE; } +inline UBaseType_t uxQueueMessagesWaiting(QueueHandle_t) { return 0; } +inline void xQueueReset(QueueHandle_t) {} diff --git a/test/screenshot_stubs/freertos/queue.h b/test/screenshot_stubs/freertos/queue.h new file mode 100644 index 00000000..04fe3f3f --- /dev/null +++ b/test/screenshot_stubs/freertos/queue.h @@ -0,0 +1,2 @@ +#pragma once +#include "FreeRTOS.h" diff --git a/test/screenshot_stubs/freertos/semphr.h b/test/screenshot_stubs/freertos/semphr.h new file mode 100644 index 00000000..04fe3f3f --- /dev/null +++ b/test/screenshot_stubs/freertos/semphr.h @@ -0,0 +1,2 @@ +#pragma once +#include "FreeRTOS.h" diff --git a/test/screenshot_stubs/freertos/task.h b/test/screenshot_stubs/freertos/task.h new file mode 100644 index 00000000..04fe3f3f --- /dev/null +++ b/test/screenshot_stubs/freertos/task.h @@ -0,0 +1,2 @@ +#pragma once +#include "FreeRTOS.h" diff --git a/test/test_screenshots/CMakeLists.txt b/test/test_screenshots/CMakeLists.txt new file mode 100644 index 00000000..a489df47 --- /dev/null +++ b/test/test_screenshots/CMakeLists.txt @@ -0,0 +1,106 @@ +cmake_minimum_required(VERSION 3.14) +project(eppg_screenshot_tests LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_C_STANDARD 11) + +# Paths +set(PROJECT_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../..) +set(STUBS_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../screenshot_stubs) +set(LVGL_DIR "" CACHE PATH "Path to LVGL source (e.g., /tmp/lvgl)") +set(GTEST_DIR "" CACHE PATH "Path to GoogleTest source (e.g., /tmp/googletest)") + +if(NOT LVGL_DIR OR NOT EXISTS ${LVGL_DIR}/CMakeLists.txt) + # Try FetchContent as fallback + include(FetchContent) + FetchContent_Declare( + lvgl + GIT_REPOSITORY https://github.com/lvgl/lvgl.git + GIT_TAG v9.5.0 + GIT_SHALLOW TRUE + ) + FetchContent_GetProperties(lvgl) + if(NOT lvgl_POPULATED) + FetchContent_Populate(lvgl) + set(LVGL_DIR ${lvgl_SOURCE_DIR}) + endif() +endif() + +if(NOT GTEST_DIR OR NOT EXISTS ${GTEST_DIR}/CMakeLists.txt) + include(FetchContent) + FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest.git + GIT_TAG v1.15.2 + GIT_SHALLOW TRUE + ) + FetchContent_MakeAvailable(googletest) +else() + add_subdirectory(${GTEST_DIR} ${CMAKE_BINARY_DIR}/googletest) +endif() + +# LVGL configuration: our lv_conf.h must be visible +# We compile LVGL sources directly rather than using its CMake to control flags precisely +file(GLOB_RECURSE LVGL_SOURCES + ${LVGL_DIR}/src/*.c +) + +add_library(lvgl STATIC ${LVGL_SOURCES}) +target_include_directories(lvgl PUBLIC + ${LVGL_DIR} + ${PROJECT_ROOT}/inc/sp140/lvgl # For lv_conf.h +) +target_compile_definitions(lvgl PUBLIC + LV_CONF_INCLUDE_SIMPLE + LV_LVGL_H_INCLUDE_SIMPLE +) +# Suppress warnings in LVGL source +target_compile_options(lvgl PRIVATE -w) + +# Project UI source files (the actual code under test) +set(UI_SOURCES + ${PROJECT_ROOT}/src/sp140/lvgl/lvgl_main_screen.cpp + ${PROJECT_ROOT}/src/sp140/lvgl/lvgl_updates.cpp + ${PROJECT_ROOT}/src/sp140/lvgl/lvgl_alerts.cpp +) + +# Emulator sources +set(EMULATOR_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/emulator_display.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/emulator_stubs.cpp +) + +# Test sources +set(TEST_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/test_screenshots.cpp +) + +add_executable(screenshot_tests + ${UI_SOURCES} + ${EMULATOR_SOURCES} + ${TEST_SOURCES} +) + +target_include_directories(screenshot_tests PRIVATE + ${STUBS_DIR} # Stubs FIRST (Arduino.h, SPI.h, etc.) + ${PROJECT_ROOT}/inc # Project headers (sp140/structs.h, etc.) + ${PROJECT_ROOT}/inc/sp140/lvgl # lv_conf.h + ${LVGL_DIR} # LVGL headers + ${CMAKE_CURRENT_SOURCE_DIR} # emulator_display.h +) + +target_compile_definitions(screenshot_tests PRIVATE + LV_CONF_INCLUDE_SIMPLE + LV_LVGL_H_INCLUDE_SIMPLE + ARDUINO=1 + NATIVE_SCREENSHOT_TEST=1 +) + +target_link_libraries(screenshot_tests PRIVATE + lvgl + gtest + gtest_main +) + +# Suppress warnings in UI sources (they may use Arduino idioms) +set_source_files_properties(${UI_SOURCES} PROPERTIES COMPILE_FLAGS "-w") diff --git a/test/test_screenshots/build_and_run.sh b/test/test_screenshots/build_and_run.sh new file mode 100755 index 00000000..849f30b9 --- /dev/null +++ b/test/test_screenshots/build_and_run.sh @@ -0,0 +1,139 @@ +#!/bin/bash +# Build and run the LVGL screenshot tests +# Usage: +# ./test/test_screenshots/build_and_run.sh [--local] [--update-references] +# +# --local Use Ninja (if installed), ccache (if installed), and a portable CPU +# count for parallel builds. If the build dir was configured without +# --local, it is cleared once so the generator can switch to Ninja. +# You can also set EPPG_SCREENSHOT_LOCAL=1 instead of the flag. +# +# Prerequisites: cmake, g++ (or clang++), optional: ninja, ccache +# LVGL and GoogleTest are fetched automatically if not cached. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +BUILD_DIR="$PROJECT_ROOT/build-screenshot" + +LOCAL_BUILD=0 +if [ "${EPPG_SCREENSHOT_LOCAL:-}" = 1 ]; then + LOCAL_BUILD=1 +fi + +PASS_TO_TEST=() +for arg in "$@"; do + case "$arg" in + --local) LOCAL_BUILD=1 ;; + *) PASS_TO_TEST+=("$arg") ;; + esac +done + +UPDATE_REFS=0 +for arg in "${PASS_TO_TEST[@]}"; do + if [ "$arg" = "--update-references" ]; then + UPDATE_REFS=1 + break + fi +done + +# Parallel jobs: full portable count only with --local; CI keeps nproc || 4 (Linux). +build_parallel_jobs() { + if [ "$LOCAL_BUILD" != 1 ]; then + nproc 2>/dev/null || echo 4 + return + fi + if [ -n "${EPPG_SCREENSHOT_BUILD_JOBS:-}" ]; then + echo "$EPPG_SCREENSHOT_BUILD_JOBS" + return + fi + nproc 2>/dev/null && return + getconf _NPROCESSORS_ONLN 2>/dev/null && return + sysctl -n hw.ncpu 2>/dev/null && return + echo 4 +} + +CMAKE_GEN_ARGS=() +CMAKE_CCACHE_ARGS=() +if [ "$LOCAL_BUILD" = 1 ]; then + if command -v ninja >/dev/null 2>&1; then + CMAKE_GEN_ARGS=(-G Ninja) + else + echo "Warning: --local: 'ninja' not found; using CMake's default generator." >&2 + fi + if command -v ccache >/dev/null 2>&1; then + CMAKE_CCACHE_ARGS=( + -DCMAKE_C_COMPILER_LAUNCHER=ccache + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache + ) + else + echo "Warning: --local: 'ccache' not found; building without compiler cache." >&2 + fi +fi + +echo "=== LVGL Screenshot Tests ===" +echo "Project root: $PROJECT_ROOT" +echo "Build dir: $BUILD_DIR" +if [ "$LOCAL_BUILD" = 1 ]; then + ninja_status="default" + [ "${#CMAKE_GEN_ARGS[@]}" -gt 0 ] && ninja_status="Ninja" + ccache_status="off" + [ "${#CMAKE_CCACHE_ARGS[@]}" -gt 0 ] && ccache_status="on" + echo "Local fast path: generator=${ninja_status}, ccache=${ccache_status}, jobs=$(build_parallel_jobs)" +fi + +mkdir -p "$BUILD_DIR" + +# If the cached generator or build type no longer matches, clear the build dir once. +if [ -f "$BUILD_DIR/CMakeCache.txt" ]; then + cached_gen=$(grep '^CMAKE_GENERATOR:INTERNAL=' "$BUILD_DIR/CMakeCache.txt" 2>/dev/null | cut -d= -f2) + cached_type=$(grep '^CMAKE_BUILD_TYPE:STRING=' "$BUILD_DIR/CMakeCache.txt" 2>/dev/null | cut -d= -f2) + want_gen="Unix Makefiles" + [ "${#CMAKE_GEN_ARGS[@]}" -gt 0 ] && want_gen="Ninja" + if [ "$cached_gen" != "$want_gen" ] || [ "$cached_type" != "$BUILD_TYPE" ]; then + echo "Clearing $BUILD_DIR (generator/build type changed: ${cached_gen}/${cached_type} -> ${want_gen}/${BUILD_TYPE})..." + rm -rf "$BUILD_DIR" + mkdir -p "$BUILD_DIR" + fi +fi + +cd "$BUILD_DIR" + +echo "" +echo "--- Configuring CMake ---" +# --local uses RelWithDebInfo: optimized binary (5-10x faster renderer) with debug symbols. +# CI keeps Debug for faithful failure diagnostics. +BUILD_TYPE="Debug" +[ "$LOCAL_BUILD" = 1 ] && BUILD_TYPE="RelWithDebInfo" +# shellcheck disable=SC2086 +cmake "$SCRIPT_DIR" \ + -DCMAKE_BUILD_TYPE="$BUILD_TYPE" \ + "${CMAKE_GEN_ARGS[@]}" \ + "${CMAKE_CCACHE_ARGS[@]}" \ + ${LVGL_DIR:+-DLVGL_DIR="$LVGL_DIR"} \ + ${GTEST_DIR:+-DGTEST_DIR="$GTEST_DIR"} + +echo "" +echo "--- Building ---" +cmake --build . --parallel "$(build_parallel_jobs)" + +echo "" +echo "--- Running tests ---" +cd "$PROJECT_ROOT" +mkdir -p test/test_screenshots/output +mkdir -p test/test_screenshots/reference + +if [ "$UPDATE_REFS" = 1 ]; then + echo "Updating reference screenshots..." + rm -rf test/test_screenshots/reference/*.bmp +fi + +"$BUILD_DIR/screenshot_tests" "${PASS_TO_TEST[@]}" + +echo "" +echo "--- Screenshots ---" +echo "Output: test/test_screenshots/output/" +echo "Reference: test/test_screenshots/reference/" +echo "" +ls -la test/test_screenshots/output/*.bmp 2>/dev/null | awk '{print $NF, $5}' diff --git a/test/test_screenshots/emulator_display.cpp b/test/test_screenshots/emulator_display.cpp new file mode 100644 index 00000000..e7ba846c --- /dev/null +++ b/test/test_screenshots/emulator_display.cpp @@ -0,0 +1,372 @@ +#include "emulator_display.h" + +#include +#include +#include + +// Include project headers that define the display globals +#include "sp140/lvgl/lvgl_core.h" +#include "sp140/lvgl/lvgl_main_screen.h" +#include "sp140/structs.h" + +// Full-screen framebuffer: 160 x 128 pixels, RGB565 +static uint16_t framebuffer[SCREEN_WIDTH * SCREEN_HEIGHT]; + +// LVGL draw buffer - must be aligned to LV_DRAW_BUF_ALIGN (4 bytes). +// LVGL v9 asserts on misaligned buffers and spins in while(1) on failure. +static uint8_t lvgl_buf1[SCREEN_WIDTH * SCREEN_HEIGHT * 2] __attribute__((aligned(4))); + +// Define globals declared in lvgl_core.h +lv_display_t* main_display = nullptr; +int8_t displayCS = -1; +Adafruit_ST7735* tft_driver = nullptr; +uint32_t lvgl_last_update = 0; +SemaphoreHandle_t spiBusMutex = nullptr; + +// Define lvglMutex from lvgl_updates.h +SemaphoreHandle_t lvglMutex = nullptr; + +// Track initialization state +static bool lvgl_initialized = false; + +uint16_t* emulator_get_framebuffer() { + return framebuffer; +} + +// Flush callback: copies rendered pixels into our framebuffer +static void emulator_flush_cb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) { + int32_t w = area->x2 - area->x1 + 1; + int32_t h = area->y2 - area->y1 + 1; + uint16_t* src = (uint16_t*)px_map; + + for (int32_t y = 0; y < h; y++) { + for (int32_t x = 0; x < w; x++) { + int fb_x = area->x1 + x; + int fb_y = area->y1 + y; + if (fb_x >= 0 && fb_x < SCREEN_WIDTH && fb_y >= 0 && fb_y < SCREEN_HEIGHT) { + framebuffer[fb_y * SCREEN_WIDTH + fb_x] = src[y * w + x]; + } + } + } + + lv_display_flush_ready(disp); +} + +lv_display_t* emulator_init_display(bool darkMode) { + // Clear framebuffer + memset(framebuffer, 0, sizeof(framebuffer)); + + if (!lvgl_initialized) { + // First time: initialize LVGL + lv_init(); + lvgl_initialized = true; + + // Create display with framebuffer flush + main_display = lv_display_create(SCREEN_WIDTH, SCREEN_HEIGHT); + lv_display_set_flush_cb(main_display, emulator_flush_cb); + lv_display_set_buffers(main_display, lvgl_buf1, NULL, sizeof(lvgl_buf1), + LV_DISPLAY_RENDER_MODE_FULL); + lv_display_set_color_format(main_display, LV_COLOR_FORMAT_RGB565); + } + + // Set up theme (matches real hardware setup in lvgl_core.cpp) + lv_theme_t* theme = lv_theme_default_init( + main_display, + lv_palette_main(LV_PALETTE_BLUE), + lv_palette_main(LV_PALETTE_AMBER), + darkMode, + LV_FONT_DEFAULT); + lv_display_set_theme(main_display, theme); + + return main_display; +} + +void emulator_render_frame() { + // Tick LVGL forward so any time-dependent layout/animation work is scheduled. + lv_tick_inc(100); + // Drain LVGL's internal timer queue before forcing the refresh. Without + // this the first render on a freshly-created screen can block waiting for + // deferred layout passes that are only triggered by lv_timer_handler(). + for (int i = 0; i < 64; i++) { + lv_timer_handler(); + } + lv_refr_now(main_display); +} + +// Write a 24-bit BMP file from the RGB565 framebuffer +bool emulator_save_bmp(const char* filename) { + FILE* f = fopen(filename, "wb"); + if (!f) return false; + + const int width = SCREEN_WIDTH; + const int height = SCREEN_HEIGHT; + // BMP rows must be padded to 4-byte boundaries + int row_bytes = width * 3; + int padding = (4 - (row_bytes % 4)) % 4; + int padded_row = row_bytes + padding; + int pixel_data_size = padded_row * height; + int file_size = 54 + pixel_data_size; + + // BMP File Header (14 bytes) + uint8_t file_header[14] = { + 'B', 'M', + (uint8_t)(file_size), (uint8_t)(file_size >> 8), + (uint8_t)(file_size >> 16), (uint8_t)(file_size >> 24), + 0, 0, 0, 0, + 54, 0, 0, 0 + }; + fwrite(file_header, 1, 14, f); + + // BMP Info Header (40 bytes) + uint8_t info_header[40] = {}; + info_header[0] = 40; // header size + info_header[4] = (uint8_t)(width); + info_header[5] = (uint8_t)(width >> 8); + info_header[8] = (uint8_t)(height); + info_header[9] = (uint8_t)(height >> 8); + info_header[12] = 1; // color planes + info_header[14] = 24; // bits per pixel + // compression = 0 (BI_RGB), rest zeroed + fwrite(info_header, 1, 40, f); + + // Pixel data (BMP is bottom-up) + uint8_t pad_bytes[3] = {0, 0, 0}; + for (int y = height - 1; y >= 0; y--) { + for (int x = 0; x < width; x++) { + uint16_t rgb565 = framebuffer[y * width + x]; + // Convert RGB565 to BGR24 + uint8_t r = ((rgb565 >> 11) & 0x1F) * 255 / 31; + uint8_t g = ((rgb565 >> 5) & 0x3F) * 255 / 63; + uint8_t b = (rgb565 & 0x1F) * 255 / 31; + uint8_t bgr[3] = {b, g, r}; + fwrite(bgr, 1, 3, f); + } + if (padding > 0) { + fwrite(pad_bytes, 1, padding, f); + } + } + + fclose(f); + return true; +} + +int emulator_compare_bmp(const char* file_a, const char* file_b) { + FILE* fa = fopen(file_a, "rb"); + FILE* fb = fopen(file_b, "rb"); + if (!fa || !fb) { + if (fa) fclose(fa); + if (fb) fclose(fb); + return -1; // File open error + } + + // Get file sizes + fseek(fa, 0, SEEK_END); + fseek(fb, 0, SEEK_END); + long size_a = ftell(fa); + long size_b = ftell(fb); + fseek(fa, 0, SEEK_SET); + fseek(fb, 0, SEEK_SET); + + if (size_a != size_b) { + fclose(fa); + fclose(fb); + return -2; // Size mismatch + } + + // Skip BMP header (54 bytes), compare pixel data + fseek(fa, 54, SEEK_SET); + fseek(fb, 54, SEEK_SET); + + int diff_count = 0; + int pixel_count = SCREEN_WIDTH * SCREEN_HEIGHT; + for (int i = 0; i < pixel_count; i++) { + uint8_t bgr_a[3], bgr_b[3]; + if (fread(bgr_a, 1, 3, fa) != 3 || fread(bgr_b, 1, 3, fb) != 3) break; + if (bgr_a[0] != bgr_b[0] || bgr_a[1] != bgr_b[1] || bgr_a[2] != bgr_b[2]) { + diff_count++; + } + } + + fclose(fa); + fclose(fb); + return diff_count; +} + +// --------------------------------------------------------------------------- +// 3-panel diff visualizer: [reference | output | diff-highlighted] +// Matching pixels in the diff panel are dimmed to 30% brightness so regressions +// jump out immediately. Differing pixels are shown as magenta (255, 0, 255). +// The combined image is (SCREEN_WIDTH * 3 + 4) wide with 2-px white separators. +// --------------------------------------------------------------------------- +bool emulator_save_diff_bmp(const char* ref_path, const char* out_path, + const char* diff_path) { + const int W = SCREEN_WIDTH; + const int H = SCREEN_HEIGHT; + const int PANEL_BYTES = W * H * 3; + + // Heap-allocate pixel buffers (bottom-up BMP rows, BGR order) + uint8_t* ref_px = static_cast(malloc(PANEL_BYTES)); + uint8_t* out_px = static_cast(malloc(PANEL_BYTES)); + if (!ref_px || !out_px) { free(ref_px); free(out_px); return false; } + + auto read_pixels = [&](const char* path, uint8_t* dst) -> bool { + FILE* f = fopen(path, "rb"); + if (!f) return false; + fseek(f, 54, SEEK_SET); // skip BMP header + bool ok = (fread(dst, 1, PANEL_BYTES, f) == (size_t)PANEL_BYTES); + fclose(f); + return ok; + }; + + if (!read_pixels(ref_path, ref_px) || !read_pixels(out_path, out_px)) { + free(ref_px); free(out_px); return false; + } + + // Combined image layout: ref | 2px sep | output | 2px sep | diff + const int SEP = 2; + const int TOTAL_W = W * 3 + SEP * 2; + int row_bytes = TOTAL_W * 3; + int padding = (4 - (row_bytes % 4)) % 4; + int padded = row_bytes + padding; + int pix_size = padded * H; + int file_size = 54 + pix_size; + + FILE* f = fopen(diff_path, "wb"); + if (!f) { free(ref_px); free(out_px); return false; } + + // BMP file header + uint8_t fhdr[14] = { + 'B', 'M', + (uint8_t)file_size, (uint8_t)(file_size >> 8), + (uint8_t)(file_size >> 16),(uint8_t)(file_size >> 24), + 0, 0, 0, 0, 54, 0, 0, 0 + }; + fwrite(fhdr, 1, 14, f); + + // BMP info header + uint8_t ihdr[40] = {}; + ihdr[0] = 40; + ihdr[4] = (uint8_t)TOTAL_W; ihdr[5] = (uint8_t)(TOTAL_W >> 8); + ihdr[8] = (uint8_t)H; ihdr[9] = (uint8_t)(H >> 8); + ihdr[12] = 1; // color planes + ihdr[14] = 24; // bits per pixel + fwrite(ihdr, 1, 40, f); + + // Rows are written bottom-up (matches how emulator_save_bmp stores them) + uint8_t pad_bytes[3] = {0, 0, 0}; + for (int row = 0; row < H; row++) { + // Source row index inside the bottom-up pixel arrays + int src_row = row; // same order: both arrays are bottom-up + + for (int panel = 0; panel < 3; panel++) { + // Write 2-px white separator before panels 1 and 2 + if (panel > 0) { + for (int s = 0; s < SEP; s++) { + uint8_t white[3] = {255, 255, 255}; + fwrite(white, 1, 3, f); + } + } + + for (int col = 0; col < W; col++) { + int idx = (src_row * W + col) * 3; + uint8_t r_b = ref_px[idx], r_g = ref_px[idx+1], r_r = ref_px[idx+2]; + uint8_t o_b = out_px[idx], o_g = out_px[idx+1], o_r = out_px[idx+2]; + bool differs = (r_b != o_b || r_g != o_g || r_r != o_r); + + uint8_t px[3]; + if (panel == 0) { + // Left: reference as-is + px[0] = r_b; px[1] = r_g; px[2] = r_r; + } else if (panel == 1) { + // Middle: output as-is + px[0] = o_b; px[1] = o_g; px[2] = o_r; + } else { + // Right: diff — magenta where different, dimmed output where same + if (differs) { + px[0] = 255; px[1] = 0; px[2] = 255; // magenta (BGR) + } else { + px[0] = (uint8_t)(o_b * 30 / 100); + px[1] = (uint8_t)(o_g * 30 / 100); + px[2] = (uint8_t)(o_r * 30 / 100); + } + } + fwrite(px, 1, 3, f); + } + } + if (padding > 0) fwrite(pad_bytes, 1, padding, f); + } + + fclose(f); + free(ref_px); + free(out_px); + return true; +} + +void emulator_teardown() { + // Delete the screen (which deletes all child widgets) + // Don't call lv_deinit - keep LVGL alive across tests + if (main_screen != NULL) { + lv_obj_delete(main_screen); + } + + // Reset all global widget pointers (children are already deleted with main_screen) + main_screen = NULL; + battery_bar = NULL; + battery_label = NULL; + voltage_left_label = NULL; + voltage_right_label = NULL; + for (int i = 0; i < 4; i++) power_char_labels[i] = NULL; + power_unit_label = NULL; + power_bar = NULL; + perf_mode_label = NULL; + armed_time_label = NULL; + for (int i = 0; i < 7; i++) altitude_char_labels[i] = NULL; + batt_temp_label = NULL; + esc_temp_label = NULL; + motor_temp_label = NULL; + arm_indicator = NULL; + batt_letter_label = NULL; + esc_letter_label = NULL; + motor_letter_label = NULL; + batt_temp_bg = NULL; + esc_temp_bg = NULL; + motor_temp_bg = NULL; + cruise_icon_img = NULL; + charging_icon_img = NULL; + arm_fail_warning_icon_img = NULL; + for (int i = 0; i < 13; i++) climb_rate_divider_lines[i] = NULL; + for (int i = 0; i < 12; i++) climb_rate_fill_sections[i] = NULL; + critical_border = NULL; + + // Reset alert UI pointers (declared in lvgl_alerts.h) + extern lv_obj_t* warning_counter_circle; + extern lv_obj_t* warning_counter_label; + extern lv_obj_t* critical_counter_circle; + extern lv_obj_t* critical_counter_label; + extern lv_obj_t* alert_text_label; + extern lv_obj_t* critical_text_label; + extern lv_timer_t* alert_cycle_timer; + warning_counter_circle = NULL; + warning_counter_label = NULL; + critical_counter_circle = NULL; + critical_counter_label = NULL; + alert_text_label = NULL; + critical_text_label = NULL; + alert_cycle_timer = NULL; + + // Reset flash timer state + extern lv_timer_t* cruise_flash_timer; + extern lv_timer_t* arm_fail_flash_timer; + extern lv_timer_t* critical_border_flash_timer; + extern bool isFlashingCruiseIcon; + extern bool isFlashingArmFailIcon; + extern bool isFlashingCriticalBorder; + cruise_flash_timer = NULL; + arm_fail_flash_timer = NULL; + critical_border_flash_timer = NULL; + isFlashingCruiseIcon = false; + isFlashingArmFailIcon = false; + isFlashingCriticalBorder = false; + + memset(framebuffer, 0, sizeof(framebuffer)); +} diff --git a/test/test_screenshots/emulator_display.h b/test/test_screenshots/emulator_display.h new file mode 100644 index 00000000..4708f2f6 --- /dev/null +++ b/test/test_screenshots/emulator_display.h @@ -0,0 +1,38 @@ +#ifndef TEST_SCREENSHOTS_EMULATOR_DISPLAY_H_ +#define TEST_SCREENSHOTS_EMULATOR_DISPLAY_H_ + +#include +#include + +// Display dimensions (must match real hardware) +#define SCREEN_WIDTH 160 +#define SCREEN_HEIGHT 128 + +// Framebuffer access +uint16_t* emulator_get_framebuffer(); + +// Initialize LVGL and create a framebuffer-backed display +// Returns the created display object +lv_display_t* emulator_init_display(bool darkMode); + +// Force a full render of the current LVGL screen into the framebuffer +void emulator_render_frame(); + +// Save the current framebuffer as a 24-bit BMP file +// Returns true on success +bool emulator_save_bmp(const char* filename); + +// Compare two BMP files pixel-by-pixel. +// Returns the number of differing pixels (0 = identical). +int emulator_compare_bmp(const char* file_a, const char* file_b); + +// Save a 3-panel side-by-side diff BMP: [reference | output | diff-highlighted]. +// Differing pixels are shown as magenta in the diff panel; matching pixels are dimmed. +// Returns true on success. +bool emulator_save_diff_bmp(const char* ref_path, const char* out_path, + const char* diff_path); + +// Clean up LVGL state between tests +void emulator_teardown(); + +#endif // TEST_SCREENSHOTS_EMULATOR_DISPLAY_H_ diff --git a/test/test_screenshots/emulator_stubs.cpp b/test/test_screenshots/emulator_stubs.cpp new file mode 100644 index 00000000..c4fce593 --- /dev/null +++ b/test/test_screenshots/emulator_stubs.cpp @@ -0,0 +1,133 @@ +// Stub implementations for functions referenced by the LVGL UI code +// that depend on hardware or other subsystems not available natively. + +#include +#include +#include +#include +#include "sp140/structs.h" +#include "sp140/simple_monitor.h" +#include "sp140/alert_display.h" +#include "sp140/vibration_pwm.h" +#include "sp140/esp32s3-config.h" +#include "sp140/ble.h" + +// --- Hardware config --- +HardwareConfig s3_config = {}; + +// --- BLE globals --- +NimBLECharacteristic* pThrottleCharacteristic = nullptr; +NimBLECharacteristic* pDeviceStateCharacteristic = nullptr; +NimBLEServer* pServer = nullptr; +volatile uint16_t connectedHandle = 0; +volatile bool deviceConnected = false; + +// --- Globals from globals.h --- +unsigned long cruisedAtMillis = 0; +int cruisedPotVal = 0; +float watts = 0; +float wattHoursUsed = 0; +STR_DEVICE_DATA_140_V1 deviceData = {}; +STR_ESC_TELEMETRY_140 escTelemetryData = {}; +UnifiedBatteryData unifiedBatteryData = {}; +STR_BMS_TELEMETRY_140 bmsTelemetryData = {}; +bool bmsCanInitialized = false; +bool escTwaiInitialized = false; +bool bmpPresent = false; + +// --- Monitor globals --- +MultiLogger multiLogger; +std::vector monitors; +SerialLogger serialLogger; +bool monitoringEnabled = false; + +void SerialLogger::log(SensorID, AlertLevel, float) {} +void SerialLogger::log(SensorID, AlertLevel, bool) {} + +// --- Alert display stubs --- +QueueHandle_t alertEventQueue = nullptr; +QueueHandle_t alertCarouselQueue = nullptr; +QueueHandle_t alertUIQueue = nullptr; + +void initAlertDisplay() {} +void sendAlertEvent(SensorID, AlertLevel) {} + +// --- Vibration stubs --- +TaskHandle_t vibeTaskHandle = nullptr; +QueueHandle_t vibeQueue = nullptr; + +bool initVibeMotor() { return true; } +void pulseVibeMotor() {} +bool runVibePattern(const unsigned int[], int) { return true; } +void executeVibePattern(VibePattern) {} +void customVibePattern(const uint8_t[], const uint16_t[], int) {} +void pulseVibration(uint16_t, uint8_t) {} +void stopVibration() {} + +// --- Sensor ID string functions --- +const char* sensorIDToString(SensorID id) { + switch (id) { + case SensorID::ESC_MOS_Temp: return "ESC MOS Temp"; + case SensorID::ESC_MCU_Temp: return "ESC MCU Temp"; + case SensorID::ESC_CAP_Temp: return "ESC CAP Temp"; + case SensorID::Motor_Temp: return "Motor Temp"; + case SensorID::BMS_MOS_Temp: return "BMS MOS Temp"; + case SensorID::BMS_T1_Temp: return "BMS T1"; + case SensorID::BMS_SOC: return "BMS SOC"; + case SensorID::CPU_Temp: return "CPU Temp"; + default: return "Unknown"; + } +} + +const char* sensorIDToAbbreviation(SensorID id) { + switch (id) { + case SensorID::ESC_MOS_Temp: return "E.MOS"; + case SensorID::ESC_MCU_Temp: return "E.MCU"; + case SensorID::ESC_CAP_Temp: return "E.CAP"; + case SensorID::Motor_Temp: return "MOT"; + case SensorID::BMS_MOS_Temp: return "B.MOS"; + case SensorID::BMS_Balance_Temp: return "B.BAL"; + case SensorID::BMS_T1_Temp: return "B.T1"; + case SensorID::BMS_T2_Temp: return "B.T2"; + case SensorID::BMS_T3_Temp: return "B.T3"; + case SensorID::BMS_T4_Temp: return "B.T4"; + case SensorID::BMS_High_Cell_Voltage: return "HiCell"; + case SensorID::BMS_Low_Cell_Voltage: return "LoCell"; + case SensorID::BMS_SOC: return "SOC"; + case SensorID::BMS_Total_Voltage: return "BatV"; + case SensorID::BMS_Voltage_Differential: return "Vdiff"; + case SensorID::Baro_Temp: return "BARO"; + case SensorID::CPU_Temp: return "CPU"; + default: return "???"; + } +} + +const char* sensorIDToAbbreviationWithLevel(SensorID id, AlertLevel level) { + // For the emulator, just return the basic abbreviation with a suffix + static char buf[16]; + const char* abbr = sensorIDToAbbreviation(id); + switch (level) { + case AlertLevel::WARN_HIGH: snprintf(buf, sizeof(buf), "%s Hi", abbr); break; + case AlertLevel::WARN_LOW: snprintf(buf, sizeof(buf), "%s Lo", abbr); break; + case AlertLevel::CRIT_HIGH: snprintf(buf, sizeof(buf), "%s HI", abbr); break; + case AlertLevel::CRIT_LOW: snprintf(buf, sizeof(buf), "%s LO", abbr); break; + default: snprintf(buf, sizeof(buf), "%s", abbr); break; + } + return buf; +} + +SensorCategory getSensorCategory(SensorID) { return SensorCategory::ESC; } +void initSimpleMonitor() {} +void checkAllSensors() {} +void checkAllSensorsWithData(const STR_ESC_TELEMETRY_140&, const STR_BMS_TELEMETRY_140&) {} +void addESCMonitors() {} +void addBMSMonitors() {} +void addAltimeterMonitors() {} +void addInternalMonitors() {} +void enableMonitoring() {} + +// --- BMS stubs --- +BMS_CAN* bms_can = nullptr; +bool initBMSCAN(SPIClass*) { return false; } +void updateBMSData() {} +void printBMSData() {} diff --git a/test/test_screenshots/output/.gitkeep b/test/test_screenshots/output/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/test/test_screenshots/reference/main_armed_cruising_dark.bmp b/test/test_screenshots/reference/main_armed_cruising_dark.bmp new file mode 100644 index 00000000..ea7fe2e8 Binary files /dev/null and b/test/test_screenshots/reference/main_armed_cruising_dark.bmp differ diff --git a/test/test_screenshots/reference/main_armed_cruising_light.bmp b/test/test_screenshots/reference/main_armed_cruising_light.bmp new file mode 100644 index 00000000..088236e8 Binary files /dev/null and b/test/test_screenshots/reference/main_armed_cruising_light.bmp differ diff --git a/test/test_screenshots/reference/main_armed_flying_dark.bmp b/test/test_screenshots/reference/main_armed_flying_dark.bmp new file mode 100644 index 00000000..07c56949 Binary files /dev/null and b/test/test_screenshots/reference/main_armed_flying_dark.bmp differ diff --git a/test/test_screenshots/reference/main_armed_flying_light.bmp b/test/test_screenshots/reference/main_armed_flying_light.bmp new file mode 100644 index 00000000..804fd91d Binary files /dev/null and b/test/test_screenshots/reference/main_armed_flying_light.bmp differ diff --git a/test/test_screenshots/reference/main_charging_dark.bmp b/test/test_screenshots/reference/main_charging_dark.bmp new file mode 100644 index 00000000..5e2674fe Binary files /dev/null and b/test/test_screenshots/reference/main_charging_dark.bmp differ diff --git a/test/test_screenshots/reference/main_charging_light.bmp b/test/test_screenshots/reference/main_charging_light.bmp new file mode 100644 index 00000000..3a878544 Binary files /dev/null and b/test/test_screenshots/reference/main_charging_light.bmp differ diff --git a/test/test_screenshots/reference/main_critical_alerts_dark.bmp b/test/test_screenshots/reference/main_critical_alerts_dark.bmp new file mode 100644 index 00000000..33695e44 Binary files /dev/null and b/test/test_screenshots/reference/main_critical_alerts_dark.bmp differ diff --git a/test/test_screenshots/reference/main_critical_alerts_light.bmp b/test/test_screenshots/reference/main_critical_alerts_light.bmp new file mode 100644 index 00000000..ec377755 Binary files /dev/null and b/test/test_screenshots/reference/main_critical_alerts_light.bmp differ diff --git a/test/test_screenshots/reference/main_esc_disconnected_dark.bmp b/test/test_screenshots/reference/main_esc_disconnected_dark.bmp new file mode 100644 index 00000000..09e64a04 Binary files /dev/null and b/test/test_screenshots/reference/main_esc_disconnected_dark.bmp differ diff --git a/test/test_screenshots/reference/main_esc_disconnected_light.bmp b/test/test_screenshots/reference/main_esc_disconnected_light.bmp new file mode 100644 index 00000000..f7beae3a Binary files /dev/null and b/test/test_screenshots/reference/main_esc_disconnected_light.bmp differ diff --git a/test/test_screenshots/reference/main_full_battery_dark.bmp b/test/test_screenshots/reference/main_full_battery_dark.bmp new file mode 100644 index 00000000..bf64fc8a Binary files /dev/null and b/test/test_screenshots/reference/main_full_battery_dark.bmp differ diff --git a/test/test_screenshots/reference/main_full_battery_light.bmp b/test/test_screenshots/reference/main_full_battery_light.bmp new file mode 100644 index 00000000..afb6ee9a Binary files /dev/null and b/test/test_screenshots/reference/main_full_battery_light.bmp differ diff --git a/test/test_screenshots/reference/main_high_altitude_dark.bmp b/test/test_screenshots/reference/main_high_altitude_dark.bmp new file mode 100644 index 00000000..81a33a9e Binary files /dev/null and b/test/test_screenshots/reference/main_high_altitude_dark.bmp differ diff --git a/test/test_screenshots/reference/main_high_altitude_light.bmp b/test/test_screenshots/reference/main_high_altitude_light.bmp new file mode 100644 index 00000000..688219fe Binary files /dev/null and b/test/test_screenshots/reference/main_high_altitude_light.bmp differ diff --git a/test/test_screenshots/reference/main_high_power_dark.bmp b/test/test_screenshots/reference/main_high_power_dark.bmp new file mode 100644 index 00000000..d3fabab2 Binary files /dev/null and b/test/test_screenshots/reference/main_high_power_dark.bmp differ diff --git a/test/test_screenshots/reference/main_high_power_light.bmp b/test/test_screenshots/reference/main_high_power_light.bmp new file mode 100644 index 00000000..e65c803a Binary files /dev/null and b/test/test_screenshots/reference/main_high_power_light.bmp differ diff --git a/test/test_screenshots/reference/main_idle_dark.bmp b/test/test_screenshots/reference/main_idle_dark.bmp new file mode 100644 index 00000000..7c0df5b7 Binary files /dev/null and b/test/test_screenshots/reference/main_idle_dark.bmp differ diff --git a/test/test_screenshots/reference/main_idle_light.bmp b/test/test_screenshots/reference/main_idle_light.bmp new file mode 100644 index 00000000..185982e7 Binary files /dev/null and b/test/test_screenshots/reference/main_idle_light.bmp differ diff --git a/test/test_screenshots/reference/main_low_battery_dark.bmp b/test/test_screenshots/reference/main_low_battery_dark.bmp new file mode 100644 index 00000000..2dda2e8e Binary files /dev/null and b/test/test_screenshots/reference/main_low_battery_dark.bmp differ diff --git a/test/test_screenshots/reference/main_low_battery_light.bmp b/test/test_screenshots/reference/main_low_battery_light.bmp new file mode 100644 index 00000000..dd4dfeb1 Binary files /dev/null and b/test/test_screenshots/reference/main_low_battery_light.bmp differ diff --git a/test/test_screenshots/reference/main_temp_critical_dark.bmp b/test/test_screenshots/reference/main_temp_critical_dark.bmp new file mode 100644 index 00000000..1f85a943 Binary files /dev/null and b/test/test_screenshots/reference/main_temp_critical_dark.bmp differ diff --git a/test/test_screenshots/reference/main_temp_critical_light.bmp b/test/test_screenshots/reference/main_temp_critical_light.bmp new file mode 100644 index 00000000..5b4b411e Binary files /dev/null and b/test/test_screenshots/reference/main_temp_critical_light.bmp differ diff --git a/test/test_screenshots/reference/main_temp_warnings_dark.bmp b/test/test_screenshots/reference/main_temp_warnings_dark.bmp new file mode 100644 index 00000000..3e07cdff Binary files /dev/null and b/test/test_screenshots/reference/main_temp_warnings_dark.bmp differ diff --git a/test/test_screenshots/reference/main_temp_warnings_light.bmp b/test/test_screenshots/reference/main_temp_warnings_light.bmp new file mode 100644 index 00000000..6904f7d6 Binary files /dev/null and b/test/test_screenshots/reference/main_temp_warnings_light.bmp differ diff --git a/test/test_screenshots/reference/main_warning_alerts_dark.bmp b/test/test_screenshots/reference/main_warning_alerts_dark.bmp new file mode 100644 index 00000000..e241b3ab Binary files /dev/null and b/test/test_screenshots/reference/main_warning_alerts_dark.bmp differ diff --git a/test/test_screenshots/reference/main_warning_alerts_light.bmp b/test/test_screenshots/reference/main_warning_alerts_light.bmp new file mode 100644 index 00000000..98f9bdea Binary files /dev/null and b/test/test_screenshots/reference/main_warning_alerts_light.bmp differ diff --git a/test/test_screenshots/reference/splash_dark.bmp b/test/test_screenshots/reference/splash_dark.bmp new file mode 100644 index 00000000..001d6d8a Binary files /dev/null and b/test/test_screenshots/reference/splash_dark.bmp differ diff --git a/test/test_screenshots/reference/splash_light.bmp b/test/test_screenshots/reference/splash_light.bmp new file mode 100644 index 00000000..85ad8df0 Binary files /dev/null and b/test/test_screenshots/reference/splash_light.bmp differ diff --git a/test/test_screenshots/test_screenshots.cpp b/test/test_screenshots/test_screenshots.cpp new file mode 100644 index 00000000..684895e8 --- /dev/null +++ b/test/test_screenshots/test_screenshots.cpp @@ -0,0 +1,631 @@ +#include +#include +#include +#include +#include + +#include "emulator_display.h" +#include "sp140/lvgl/lvgl_main_screen.h" +#include "sp140/lvgl/lvgl_updates.h" +#include "sp140/lvgl/lvgl_alerts.h" +#include "sp140/structs.h" +#include "version.h" + +// Helper: path to reference screenshots (relative to project root) +static const char* REFERENCE_DIR = "test/test_screenshots/reference"; +static const char* OUTPUT_DIR = "test/test_screenshots/output"; + +static bool file_exists(const char* path) { + struct stat st; + return stat(path, &st) == 0; +} + +static void ensure_output_dir() { + mkdir(OUTPUT_DIR, 0755); +} + +// Create default device data for testing +static STR_DEVICE_DATA_140_V1 make_default_device_data(bool darkMode = false) { + STR_DEVICE_DATA_140_V1 dd = {}; + dd.version_major = 8; + dd.version_minor = 0; + dd.armed_time = 125; // 2h05m + dd.screen_rotation = 1; + dd.sea_pressure = 1013.25f; + dd.metric_temp = true; + dd.metric_alt = true; + dd.performance_mode = 1; + dd.theme = darkMode ? 1 : 0; + dd.revision = 3; + dd.timezone_offset = 0; + return dd; +} + +// Create typical in-flight ESC telemetry +static STR_ESC_TELEMETRY_140 make_esc_connected() { + STR_ESC_TELEMETRY_140 esc = {}; + esc.escState = TelemetryState::CONNECTED; + esc.volts = 88.5f; + esc.amps = 45.2f; + esc.mos_temp = 52.0f; + esc.cap_temp = 48.0f; + esc.mcu_temp = 42.0f; + esc.motor_temp = 65.0f; + esc.eRPM = 12000.0f; + esc.phase_current = 60.0f; + return esc; +} + +// Create typical BMS telemetry +static STR_BMS_TELEMETRY_140 make_bms_connected() { + STR_BMS_TELEMETRY_140 bms = {}; + bms.bmsState = TelemetryState::CONNECTED; + bms.soc = 72.0f; + bms.battery_voltage = 88.5f; + bms.battery_current = 45.2f; + bms.power = 4.0f; + bms.highest_cell_voltage = 3.95f; + bms.lowest_cell_voltage = 3.90f; + bms.highest_temperature = 35.0f; + bms.lowest_temperature = 28.0f; + bms.voltage_differential = 0.05f; + bms.is_charging = false; + bms.is_charge_mos = true; + bms.is_discharge_mos = true; + bms.charge_wire_connected = false; + bms.battery_ready = true; + bms.mos_temperature = 32.0f; + bms.balance_temperature = 30.0f; + bms.t1_temperature = 35.0f; + bms.t2_temperature = 33.0f; + bms.t3_temperature = NAN; + bms.t4_temperature = NAN; + return bms; +} + +static UnifiedBatteryData make_unified_battery(float soc, float volts, float power) { + UnifiedBatteryData ubd = {}; + ubd.soc = soc; + ubd.volts = volts; + ubd.power = power; + ubd.amps = (volts > 0) ? power * 1000.0f / volts : 0; + return ubd; +} + +// ============================================================ +// Test fixture +// ============================================================ +class ScreenshotTest : public ::testing::Test { + protected: + void SetUp() override { + ensure_output_dir(); + } + + void TearDown() override { + emulator_teardown(); + } + + // Set up display + main screen, render with given data, save screenshot + void render_and_save(const char* name, bool darkMode, + const STR_DEVICE_DATA_140_V1& dd, + const STR_ESC_TELEMETRY_140& esc, + const STR_BMS_TELEMETRY_140& bms, + const UnifiedBatteryData& ubd, + float altitude, bool armed, bool cruising, + unsigned int armedStartMillis = 0) { + emulator_init_display(darkMode); + setupMainScreen(darkMode); + + // Apply data + updateLvglMainScreen(dd, esc, bms, ubd, altitude, armed, cruising, armedStartMillis); + + // Render + emulator_render_frame(); + + // Save output + char out_path[256]; + snprintf(out_path, sizeof(out_path), "%s/%s.bmp", OUTPUT_DIR, name); + ASSERT_TRUE(emulator_save_bmp(out_path)) << "Failed to save " << out_path; + + // Compare with reference if it exists + char ref_path[256]; + snprintf(ref_path, sizeof(ref_path), "%s/%s.bmp", REFERENCE_DIR, name); + if (file_exists(ref_path)) { + int diff = emulator_compare_bmp(ref_path, out_path); + if (diff > 0) { + char diff_path[256]; + snprintf(diff_path, sizeof(diff_path), "%s/%s_diff.bmp", OUTPUT_DIR, name); + emulator_save_diff_bmp(ref_path, out_path, diff_path); + EXPECT_EQ(0, diff) + << "Screenshot regression: " << name << " has " << diff << " differing pixels\n" + << " Reference: " << ref_path << "\n" + << " Output: " << out_path << "\n" + << " Diff: " << diff_path << " (magenta = changed pixels)\n" + << " View all: open " << OUTPUT_DIR; + } + } else { + // No reference yet - copy output as new reference + printf(" [INFO] No reference for '%s' - generating initial reference\n", name); + FILE* src = fopen(out_path, "rb"); + FILE* dst = fopen(ref_path, "wb"); + if (src && dst) { + char buf[4096]; + size_t n; + while ((n = fread(buf, 1, sizeof(buf), src)) > 0) { + fwrite(buf, 1, n, dst); + } + } + if (src) fclose(src); + if (dst) fclose(dst); + } + } +}; + +// ============================================================ +// Test cases - each generates a screenshot of a different UI state +// ============================================================ + +TEST_F(ScreenshotTest, MainScreen_Idle_Light) { + auto dd = make_default_device_data(false); + auto esc = make_esc_connected(); + auto bms = make_bms_connected(); + auto ubd = make_unified_battery(72.0f, 88.5f, 0.0f); + + render_and_save("main_idle_light", false, dd, esc, bms, ubd, + 0.0f, false, false); +} + +TEST_F(ScreenshotTest, MainScreen_Idle_Dark) { + auto dd = make_default_device_data(true); + auto esc = make_esc_connected(); + auto bms = make_bms_connected(); + auto ubd = make_unified_battery(72.0f, 88.5f, 0.0f); + + render_and_save("main_idle_dark", true, dd, esc, bms, ubd, + 0.0f, false, false); +} + +TEST_F(ScreenshotTest, MainScreen_Armed_Flying_Light) { + auto dd = make_default_device_data(false); + auto esc = make_esc_connected(); + auto bms = make_bms_connected(); + auto ubd = make_unified_battery(65.0f, 86.0f, 8.5f); + + render_and_save("main_armed_flying_light", false, dd, esc, bms, ubd, + 450.3f, true, false, millis() - 180000); +} + +TEST_F(ScreenshotTest, MainScreen_Armed_Flying_Dark) { + auto dd = make_default_device_data(true); + auto esc = make_esc_connected(); + auto bms = make_bms_connected(); + auto ubd = make_unified_battery(65.0f, 86.0f, 8.5f); + + render_and_save("main_armed_flying_dark", true, dd, esc, bms, ubd, + 450.3f, true, false, millis() - 180000); +} + +TEST_F(ScreenshotTest, MainScreen_Armed_Cruising_Light) { + auto dd = make_default_device_data(false); + auto esc = make_esc_connected(); + auto bms = make_bms_connected(); + auto ubd = make_unified_battery(58.0f, 84.0f, 6.2f); + + render_and_save("main_armed_cruising_light", false, dd, esc, bms, ubd, + 820.7f, true, true, millis() - 600000); +} + +TEST_F(ScreenshotTest, MainScreen_Armed_Cruising_Dark) { + auto dd = make_default_device_data(true); + auto esc = make_esc_connected(); + auto bms = make_bms_connected(); + auto ubd = make_unified_battery(58.0f, 84.0f, 6.2f); + + render_and_save("main_armed_cruising_dark", true, dd, esc, bms, ubd, + 820.7f, true, true, millis() - 600000); +} + +TEST_F(ScreenshotTest, MainScreen_LowBattery_Light) { + auto dd = make_default_device_data(false); + auto esc = make_esc_connected(); + auto bms = make_bms_connected(); + bms.soc = 8.0f; + bms.lowest_cell_voltage = 3.15f; + auto ubd = make_unified_battery(8.0f, 72.0f, 2.0f); + + render_and_save("main_low_battery_light", false, dd, esc, bms, ubd, + 200.0f, true, false, millis() - 300000); +} + +TEST_F(ScreenshotTest, MainScreen_LowBattery_Dark) { + auto dd = make_default_device_data(true); + auto esc = make_esc_connected(); + auto bms = make_bms_connected(); + bms.soc = 8.0f; + bms.lowest_cell_voltage = 3.15f; + auto ubd = make_unified_battery(8.0f, 72.0f, 2.0f); + + render_and_save("main_low_battery_dark", true, dd, esc, bms, ubd, + 200.0f, true, false, millis() - 300000); +} + +TEST_F(ScreenshotTest, MainScreen_HighAltitude_Light) { + auto dd = make_default_device_data(false); + dd.metric_alt = true; + auto esc = make_esc_connected(); + auto bms = make_bms_connected(); + auto ubd = make_unified_battery(45.0f, 82.0f, 4.5f); + + render_and_save("main_high_altitude_light", false, dd, esc, bms, ubd, + 2456.8f, true, false, millis() - 900000); +} + +TEST_F(ScreenshotTest, MainScreen_HighAltitude_Dark) { + auto dd = make_default_device_data(true); + dd.metric_alt = true; + auto esc = make_esc_connected(); + auto bms = make_bms_connected(); + auto ubd = make_unified_battery(45.0f, 82.0f, 4.5f); + + render_and_save("main_high_altitude_dark", true, dd, esc, bms, ubd, + 2456.8f, true, false, millis() - 900000); +} + +TEST_F(ScreenshotTest, MainScreen_HighPower_Light) { + auto dd = make_default_device_data(false); + dd.performance_mode = 2; + auto esc = make_esc_connected(); + esc.mos_temp = 85.0f; + esc.motor_temp = 95.0f; + auto bms = make_bms_connected(); + bms.highest_temperature = 48.0f; + auto ubd = make_unified_battery(50.0f, 85.0f, 18.5f); + + render_and_save("main_high_power_light", false, dd, esc, bms, ubd, + 300.0f, true, false, millis() - 60000); +} + +TEST_F(ScreenshotTest, MainScreen_HighPower_Dark) { + auto dd = make_default_device_data(true); + dd.performance_mode = 2; + auto esc = make_esc_connected(); + esc.mos_temp = 85.0f; + esc.motor_temp = 95.0f; + auto bms = make_bms_connected(); + bms.highest_temperature = 48.0f; + auto ubd = make_unified_battery(50.0f, 85.0f, 18.5f); + + render_and_save("main_high_power_dark", true, dd, esc, bms, ubd, + 300.0f, true, false, millis() - 60000); +} + +TEST_F(ScreenshotTest, MainScreen_Charging_Light) { + auto dd = make_default_device_data(false); + auto esc = STR_ESC_TELEMETRY_140{}; + esc.escState = TelemetryState::NOT_CONNECTED; + auto bms = make_bms_connected(); + bms.is_charging = true; + bms.soc = 85.0f; + auto ubd = make_unified_battery(85.0f, 96.0f, -0.5f); + + render_and_save("main_charging_light", false, dd, esc, bms, ubd, + 0.0f, false, false); +} + +TEST_F(ScreenshotTest, MainScreen_Charging_Dark) { + auto dd = make_default_device_data(true); + auto esc = STR_ESC_TELEMETRY_140{}; + esc.escState = TelemetryState::NOT_CONNECTED; + auto bms = make_bms_connected(); + bms.is_charging = true; + bms.soc = 85.0f; + auto ubd = make_unified_battery(85.0f, 96.0f, -0.5f); + + render_and_save("main_charging_dark", true, dd, esc, bms, ubd, + 0.0f, false, false); +} + +TEST_F(ScreenshotTest, MainScreen_ESCDisconnected_Light) { + auto dd = make_default_device_data(false); + auto esc = STR_ESC_TELEMETRY_140{}; + esc.escState = TelemetryState::NOT_CONNECTED; + auto bms = make_bms_connected(); + auto ubd = make_unified_battery(72.0f, 88.5f, 0.0f); + + render_and_save("main_esc_disconnected_light", false, dd, esc, bms, ubd, + 0.0f, false, false); +} + +TEST_F(ScreenshotTest, MainScreen_ESCDisconnected_Dark) { + auto dd = make_default_device_data(true); + auto esc = STR_ESC_TELEMETRY_140{}; + esc.escState = TelemetryState::NOT_CONNECTED; + auto bms = make_bms_connected(); + auto ubd = make_unified_battery(72.0f, 88.5f, 0.0f); + + render_and_save("main_esc_disconnected_dark", true, dd, esc, bms, ubd, + 0.0f, false, false); +} + +TEST_F(ScreenshotTest, MainScreen_FullBattery_Light) { + auto dd = make_default_device_data(false); + auto esc = make_esc_connected(); + auto bms = make_bms_connected(); + bms.soc = 100.0f; + bms.highest_cell_voltage = 4.20f; + bms.lowest_cell_voltage = 4.18f; + auto ubd = make_unified_battery(100.0f, 100.8f, 0.0f); + + render_and_save("main_full_battery_light", false, dd, esc, bms, ubd, + 0.0f, false, false); +} + +TEST_F(ScreenshotTest, MainScreen_FullBattery_Dark) { + auto dd = make_default_device_data(true); + auto esc = make_esc_connected(); + auto bms = make_bms_connected(); + bms.soc = 100.0f; + bms.highest_cell_voltage = 4.20f; + bms.lowest_cell_voltage = 4.18f; + auto ubd = make_unified_battery(100.0f, 100.8f, 0.0f); + + render_and_save("main_full_battery_dark", true, dd, esc, bms, ubd, + 0.0f, false, false); +} + +// ============================================================ +// Warning & alert tests +// ============================================================ + +// ESC temp in warning range (>=90), motor temp in warning range (>=105) +TEST_F(ScreenshotTest, MainScreen_TempWarnings_Light) { + auto dd = make_default_device_data(false); + auto esc = make_esc_connected(); + esc.mos_temp = 95.0f; // Above escMosTempThresholds.warnHigh (90) + esc.motor_temp = 108.0f; // Above motorTempThresholds.warnHigh (105) + auto bms = make_bms_connected(); + bms.highest_temperature = 52.0f; // Above bmsCellTempThresholds.warnHigh (50) + bms.t1_temperature = 52.0f; + auto ubd = make_unified_battery(60.0f, 85.0f, 6.0f); + + render_and_save("main_temp_warnings_light", false, dd, esc, bms, ubd, + 300.0f, true, false, millis() - 120000); +} + +TEST_F(ScreenshotTest, MainScreen_TempWarnings_Dark) { + auto dd = make_default_device_data(true); + auto esc = make_esc_connected(); + esc.mos_temp = 95.0f; // Above escMosTempThresholds.warnHigh (90) + esc.motor_temp = 108.0f; // Above motorTempThresholds.warnHigh (105) + auto bms = make_bms_connected(); + bms.highest_temperature = 52.0f; // Above bmsCellTempThresholds.warnHigh (50) + bms.t1_temperature = 52.0f; + auto ubd = make_unified_battery(60.0f, 85.0f, 6.0f); + + render_and_save("main_temp_warnings_dark", true, dd, esc, bms, ubd, + 300.0f, true, false, millis() - 120000); +} + +// ESC temp in critical range (>=110), motor temp critical (>=115), battery critical (>=56) +TEST_F(ScreenshotTest, MainScreen_TempCritical_Light) { + auto dd = make_default_device_data(false); + auto esc = make_esc_connected(); + esc.mos_temp = 115.0f; // Above escMosTempThresholds.critHigh (110) + esc.motor_temp = 120.0f; // Above motorTempThresholds.critHigh (115) + auto bms = make_bms_connected(); + bms.highest_temperature = 58.0f; // Above bmsCellTempThresholds.critHigh (56) + bms.t1_temperature = 58.0f; + auto ubd = make_unified_battery(40.0f, 80.0f, 12.0f); + + render_and_save("main_temp_critical_light", false, dd, esc, bms, ubd, + 250.0f, true, false, millis() - 180000); +} + +TEST_F(ScreenshotTest, MainScreen_TempCritical_Dark) { + auto dd = make_default_device_data(true); + auto esc = make_esc_connected(); + esc.mos_temp = 115.0f; // Above escMosTempThresholds.critHigh (110) + esc.motor_temp = 120.0f; // Above motorTempThresholds.critHigh (115) + auto bms = make_bms_connected(); + bms.highest_temperature = 58.0f; // Above bmsCellTempThresholds.critHigh (56) + bms.t1_temperature = 58.0f; + auto ubd = make_unified_battery(40.0f, 80.0f, 12.0f); + + render_and_save("main_temp_critical_dark", true, dd, esc, bms, ubd, + 250.0f, true, false, millis() - 180000); +} + +// Helper: save and compare for manual (non-render_and_save) tests +static void save_and_compare(const char* name) { + char out_path[256], ref_path[256]; + snprintf(out_path, sizeof(out_path), "%s/%s.bmp", OUTPUT_DIR, name); + snprintf(ref_path, sizeof(ref_path), "%s/%s.bmp", REFERENCE_DIR, name); + ASSERT_TRUE(emulator_save_bmp(out_path)) << "Failed to save " << out_path; + + if (file_exists(ref_path)) { + int diff = emulator_compare_bmp(ref_path, out_path); + if (diff > 0) { + char diff_path[256]; + snprintf(diff_path, sizeof(diff_path), "%s/%s_diff.bmp", OUTPUT_DIR, name); + emulator_save_diff_bmp(ref_path, out_path, diff_path); + EXPECT_EQ(0, diff) << "Screenshot regression: " << name << " has " << diff << " differing pixels"; + } + } else { + printf(" [INFO] No reference for '%s' - generating initial reference\n", name); + FILE* src = fopen(out_path, "rb"); + FILE* dst = fopen(ref_path, "wb"); + if (src && dst) { char buf[4096]; size_t n; while ((n = fread(buf, 1, sizeof(buf), src)) > 0) fwrite(buf, 1, n, dst); } + if (src) fclose(src); + if (dst) fclose(dst); + } +} + +// Warning alert counters + text visible (driven by alert display system) +TEST_F(ScreenshotTest, MainScreen_WarningAlerts_Light) { + auto dd = make_default_device_data(false); + auto esc = make_esc_connected(); + esc.mos_temp = 92.0f; // Warning-range temp + auto bms = make_bms_connected(); + auto ubd = make_unified_battery(55.0f, 84.0f, 5.0f); + + emulator_init_display(false); + setupMainScreen(false); + updateLvglMainScreen(dd, esc, bms, ubd, 400.0f, true, false, millis() - 60000); + + AlertCounts counts = {.warningCount = 2, .criticalCount = 0}; + updateAlertCounterDisplay(counts); + lv_showAlertTextWithLevel(SensorID::ESC_MOS_Temp, AlertLevel::WARN_HIGH, false); + + emulator_render_frame(); + save_and_compare("main_warning_alerts_light"); +} + +TEST_F(ScreenshotTest, MainScreen_WarningAlerts_Dark) { + auto dd = make_default_device_data(true); + auto esc = make_esc_connected(); + esc.mos_temp = 92.0f; // Warning-range temp + auto bms = make_bms_connected(); + auto ubd = make_unified_battery(55.0f, 84.0f, 5.0f); + + emulator_init_display(true); + setupMainScreen(true); + updateLvglMainScreen(dd, esc, bms, ubd, 400.0f, true, false, millis() - 60000); + + AlertCounts counts = {.warningCount = 2, .criticalCount = 0}; + updateAlertCounterDisplay(counts); + lv_showAlertTextWithLevel(SensorID::ESC_MOS_Temp, AlertLevel::WARN_HIGH, false); + + emulator_render_frame(); + save_and_compare("main_warning_alerts_dark"); +} + +// Critical alert counters + text visible (both warning and critical) +TEST_F(ScreenshotTest, MainScreen_CriticalAlerts_Light) { + auto dd = make_default_device_data(false); + auto esc = make_esc_connected(); + esc.mos_temp = 115.0f; + esc.motor_temp = 120.0f; + auto bms = make_bms_connected(); + bms.highest_temperature = 58.0f; + bms.t1_temperature = 58.0f; + auto ubd = make_unified_battery(35.0f, 78.0f, 14.0f); + + emulator_init_display(false); + setupMainScreen(false); + updateLvglMainScreen(dd, esc, bms, ubd, 200.0f, true, false, millis() - 300000); + + AlertCounts counts = {.warningCount = 1, .criticalCount = 3}; + updateAlertCounterDisplay(counts); + lv_showAlertTextWithLevel(SensorID::Motor_Temp, AlertLevel::CRIT_HIGH, true); + lv_showAlertTextWithLevel(SensorID::ESC_MOS_Temp, AlertLevel::WARN_HIGH, false); + + if (critical_border != NULL) { + lv_obj_set_style_border_opa(critical_border, LV_OPA_100, LV_PART_MAIN); + } + + emulator_render_frame(); + save_and_compare("main_critical_alerts_light"); +} + +TEST_F(ScreenshotTest, MainScreen_CriticalAlerts_Dark) { + auto dd = make_default_device_data(true); + auto esc = make_esc_connected(); + esc.mos_temp = 115.0f; + esc.motor_temp = 120.0f; + auto bms = make_bms_connected(); + bms.highest_temperature = 58.0f; + bms.t1_temperature = 58.0f; + auto ubd = make_unified_battery(35.0f, 78.0f, 14.0f); + + emulator_init_display(true); + setupMainScreen(true); + updateLvglMainScreen(dd, esc, bms, ubd, 200.0f, true, false, millis() - 300000); + + AlertCounts counts = {.warningCount = 1, .criticalCount = 3}; + updateAlertCounterDisplay(counts); + lv_showAlertTextWithLevel(SensorID::Motor_Temp, AlertLevel::CRIT_HIGH, true); + lv_showAlertTextWithLevel(SensorID::ESC_MOS_Temp, AlertLevel::WARN_HIGH, false); + + if (critical_border != NULL) { + lv_obj_set_style_border_opa(critical_border, LV_OPA_100, LV_PART_MAIN); + } + + emulator_render_frame(); + save_and_compare("main_critical_alerts_dark"); +} + +// ============================================================ +// Splash screen tests (recreated from lvgl_core.cpp displayLvglSplash) +// Uses VERSION_MAJOR/VERSION_MINOR from version.h so references +// auto-update on rebuild after a version bump — just run +// build_and_run.sh --update-references after changing the version. +// ============================================================ + +// Helper to render a splash screen and save/compare +static void render_splash(const char* name, bool darkMode) { + lv_obj_t* splash_screen = lv_obj_create(NULL); + lv_screen_load(splash_screen); + lv_obj_remove_flag(splash_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(splash_screen, + darkMode ? lv_color_black() : lv_color_white(), + LV_PART_MAIN); + + lv_obj_t* title_label = lv_label_create(splash_screen); + lv_label_set_text(title_label, "OpenPPG"); + lv_obj_set_style_text_font(title_label, &lv_font_montserrat_28, 0); + lv_obj_set_style_text_color(title_label, + darkMode ? lv_color_white() : lv_color_black(), 0); + lv_obj_align(title_label, LV_ALIGN_TOP_MID, 0, 15); + + lv_obj_t* version_label = lv_label_create(splash_screen); + char version_str[10]; + snprintf(version_str, sizeof(version_str), "v%d.%d", VERSION_MAJOR, VERSION_MINOR); + lv_label_set_text(version_label, version_str); + lv_obj_set_style_text_font(version_label, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(version_label, + darkMode ? lv_color_white() : lv_color_black(), 0); + lv_obj_align(version_label, LV_ALIGN_CENTER, 0, 0); + + lv_obj_t* time_label = lv_label_create(splash_screen); + lv_label_set_text(time_label, "02:05"); + lv_obj_set_style_text_font(time_label, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(time_label, + darkMode ? lv_color_white() : lv_color_black(), 0); + lv_obj_align(time_label, LV_ALIGN_BOTTOM_MID, 0, -20); + + emulator_render_frame(); + + char out_path[256], ref_path[256]; + snprintf(out_path, sizeof(out_path), "%s/%s.bmp", OUTPUT_DIR, name); + snprintf(ref_path, sizeof(ref_path), "%s/%s.bmp", REFERENCE_DIR, name); + emulator_save_bmp(out_path); + + if (file_exists(ref_path)) { + int diff = emulator_compare_bmp(ref_path, out_path); + if (diff > 0) { + char diff_path[256]; + snprintf(diff_path, sizeof(diff_path), "%s/%s_diff.bmp", OUTPUT_DIR, name); + emulator_save_diff_bmp(ref_path, out_path, diff_path); + EXPECT_EQ(0, diff) << "Screenshot regression: " << name << " has " << diff << " differing pixels"; + } + } else { + printf(" [INFO] No reference for '%s' - generating initial reference\n", name); + FILE* src = fopen(out_path, "rb"); + FILE* dst = fopen(ref_path, "wb"); + if (src && dst) { char buf[4096]; size_t n; while ((n = fread(buf, 1, sizeof(buf), src)) > 0) fwrite(buf, 1, n, dst); } + if (src) fclose(src); + if (dst) fclose(dst); + } + + lv_obj_delete(splash_screen); +} + +TEST_F(ScreenshotTest, SplashScreen_Light) { + emulator_init_display(false); + render_splash("splash_light", false); +} + +TEST_F(ScreenshotTest, SplashScreen_Dark) { + emulator_init_display(true); + render_splash("splash_dark", true); +}