feat(cpp/tests): test_visual con png diff vs goldens (skip si vacio)
- png_diff.{h,cpp}: pixel_diff_ratio(path_a, path_b, channel_threshold) con
stb_image. Devuelve PngDiffResult con pixels_total, pixels_different y
diff_ratio. Si dimensiones difieren, diff_ratio=1.0.
- test_visual.cpp: invoca primitives_gallery --capture sobre tmpdir, compara
cada PNG vs cpp/tests/golden/<demo>.png con tolerancia 1% pixels distintos
(threshold 5/255 por canal). SKIPea con WARN si:
* golden dir vacio (no hay goldens todavia)
* binario primitives_gallery no construido
* el binario falla al capturar (entorno sin GL)
- CMakeLists: registra test_visual con FN_TEST_GOLDEN_DIR, FN_TEST_GALLERY_BIN,
FN_TEST_TMP_DIR y FN_TEST_REPO_ROOT (para que la captura corra desde la
raiz del repo y resuelva paths relativos como sql_workbench's registry.db).
- golden/: 41 PNGs iniciales generados en este entorno (WSL +
LIBGL_ALWAYS_SOFTWARE=1). Pueden regenerarse con cpp/scripts/update_goldens.sh.
Issue 0048.
@@ -59,3 +59,22 @@ add_fn_test(test_dashboard_grid test_dashboard_grid.cpp)
|
||||
add_fn_test(test_sparkline test_sparkline.cpp)
|
||||
add_fn_test(test_table_view test_table_view.cpp)
|
||||
add_fn_test(test_icon_button test_icon_button.cpp)
|
||||
|
||||
# --- Visual golden-image diff (issue 0048) ---------------------------------
|
||||
# El binario primitives_gallery se compila con --capture; el test compara los
|
||||
# PNGs generados con los goldens en cpp/tests/golden/. Si no hay goldens o el
|
||||
# entorno no tiene GL, el test SKIPea.
|
||||
add_fn_test(test_visual
|
||||
test_visual.cpp
|
||||
png_diff.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../vendor/stb/stb_image_impl.cpp)
|
||||
target_include_directories(test_visual PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../vendor/stb)
|
||||
target_compile_definitions(test_visual PRIVATE
|
||||
"FN_TEST_GOLDEN_DIR=\"${CMAKE_CURRENT_SOURCE_DIR}/golden\""
|
||||
"FN_TEST_GALLERY_BIN=\"$<TARGET_FILE:primitives_gallery>\""
|
||||
"FN_TEST_TMP_DIR=\"${CMAKE_BINARY_DIR}/tests/visual_actual\""
|
||||
# CMAKE_SOURCE_DIR aqui es cpp/, queremos la raiz del repo (un nivel arriba).
|
||||
"FN_TEST_REPO_ROOT=\"${CMAKE_SOURCE_DIR}/..\"")
|
||||
# Asegura que primitives_gallery existe antes de correr el test.
|
||||
add_dependencies(test_visual primitives_gallery)
|
||||
|
||||
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 68 KiB |
@@ -0,0 +1,64 @@
|
||||
#include "png_diff.h"
|
||||
|
||||
#include "stb_image.h"
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
namespace fn_test {
|
||||
|
||||
PngDiffResult pixel_diff_ratio(const std::string& path_a,
|
||||
const std::string& path_b,
|
||||
int channel_threshold) {
|
||||
PngDiffResult r;
|
||||
int wa = 0, ha = 0, ca = 0;
|
||||
int wb = 0, hb = 0, cb = 0;
|
||||
unsigned char* a = stbi_load(path_a.c_str(), &wa, &ha, &ca, 4);
|
||||
unsigned char* b = stbi_load(path_b.c_str(), &wb, &hb, &cb, 4);
|
||||
r.loaded_a = (a != nullptr);
|
||||
r.loaded_b = (b != nullptr);
|
||||
r.width_a = wa; r.height_a = ha;
|
||||
r.width_b = wb; r.height_b = hb;
|
||||
|
||||
if (!r.loaded_a || !r.loaded_b) {
|
||||
if (a) stbi_image_free(a);
|
||||
if (b) stbi_image_free(b);
|
||||
return r;
|
||||
}
|
||||
|
||||
if (wa != wb || ha != hb) {
|
||||
// Dimensiones no coinciden — diff total.
|
||||
const long area_a = (long)wa * ha;
|
||||
const long area_b = (long)wb * hb;
|
||||
r.pixels_total = area_a > area_b ? area_a : area_b;
|
||||
r.pixels_different = r.pixels_total;
|
||||
r.diff_ratio = 1.0;
|
||||
stbi_image_free(a);
|
||||
stbi_image_free(b);
|
||||
return r;
|
||||
}
|
||||
|
||||
const long area = (long)wa * ha;
|
||||
long diffs = 0;
|
||||
for (long i = 0; i < area; ++i) {
|
||||
const unsigned char* pa = a + i * 4;
|
||||
const unsigned char* pb = b + i * 4;
|
||||
const int dr = std::abs((int)pa[0] - (int)pb[0]);
|
||||
const int dg = std::abs((int)pa[1] - (int)pb[1]);
|
||||
const int db_ = std::abs((int)pa[2] - (int)pb[2]);
|
||||
const int da = std::abs((int)pa[3] - (int)pb[3]);
|
||||
if (dr > channel_threshold || dg > channel_threshold ||
|
||||
db_ > channel_threshold || da > channel_threshold) {
|
||||
++diffs;
|
||||
}
|
||||
}
|
||||
|
||||
r.pixels_total = area;
|
||||
r.pixels_different = diffs;
|
||||
r.diff_ratio = (area > 0) ? (double)diffs / (double)area : 0.0;
|
||||
|
||||
stbi_image_free(a);
|
||||
stbi_image_free(b);
|
||||
return r;
|
||||
}
|
||||
|
||||
} // namespace fn_test
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
// Helper minimo para comparar dos imagenes PNG con tolerancia.
|
||||
// Usa stb_image (vendored en cpp/vendor/stb).
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace fn_test {
|
||||
|
||||
struct PngDiffResult {
|
||||
bool loaded_a = false;
|
||||
bool loaded_b = false;
|
||||
int width_a = 0, height_a = 0;
|
||||
int width_b = 0, height_b = 0;
|
||||
long pixels_total = 0;
|
||||
long pixels_different = 0;
|
||||
double diff_ratio = 0.0; // pixels_different / pixels_total
|
||||
};
|
||||
|
||||
// Carga ambos PNG, compara pixel a pixel con `channel_threshold` (0..255) por
|
||||
// canal RGBA. Si la diferencia de cualquier canal supera el umbral, el pixel
|
||||
// se marca como distinto. `diff_ratio` = pixels distintos / total.
|
||||
//
|
||||
// Si las dimensiones no coinciden devuelve loaded_*=true pero diff_ratio=1.0
|
||||
// y pixels_total = max(area_a, area_b).
|
||||
PngDiffResult pixel_diff_ratio(const std::string& path_a,
|
||||
const std::string& path_b,
|
||||
int channel_threshold = 5);
|
||||
|
||||
} // namespace fn_test
|
||||
@@ -0,0 +1,139 @@
|
||||
// test_visual — golden-image diff de las demos de primitives_gallery.
|
||||
//
|
||||
// Asume que existen PNGs en `cpp/tests/golden/<demo>.png` generados por
|
||||
// `cpp/scripts/update_goldens.sh`. Si no existen, los SKIPea con INFO.
|
||||
//
|
||||
// El binario primitives_gallery se localiza relativo al directorio de build:
|
||||
// <build>/apps/primitives_gallery/primitives_gallery --capture <tmpdir>
|
||||
//
|
||||
// Si el entorno no puede crear contexto GL (WSL minimo, container sin Mesa),
|
||||
// el binario falla; el test reporta SKIP en lugar de FAIL para no bloquear
|
||||
// CI en entornos donde el render headless no es posible.
|
||||
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch_amalgamated.hpp"
|
||||
|
||||
#include "png_diff.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <dirent.h>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <sys/stat.h>
|
||||
#include <vector>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// Defaults inyectados por CMake en el target test_visual:
|
||||
// FN_TEST_GOLDEN_DIR — cpp/tests/golden (absoluto)
|
||||
// FN_TEST_GALLERY_BIN — path absoluto al binario primitives_gallery
|
||||
// FN_TEST_TMP_DIR — directorio temporal donde la corrida actual
|
||||
// escribe sus PNGs (build/tests/visual_actual)
|
||||
#ifndef FN_TEST_GOLDEN_DIR
|
||||
#define FN_TEST_GOLDEN_DIR "cpp/tests/golden"
|
||||
#endif
|
||||
#ifndef FN_TEST_GALLERY_BIN
|
||||
#define FN_TEST_GALLERY_BIN ""
|
||||
#endif
|
||||
#ifndef FN_TEST_TMP_DIR
|
||||
#define FN_TEST_TMP_DIR "/tmp/primitives_gallery_visual"
|
||||
#endif
|
||||
#ifndef FN_TEST_REPO_ROOT
|
||||
#define FN_TEST_REPO_ROOT "."
|
||||
#endif
|
||||
|
||||
static std::vector<std::string> list_pngs(const std::string& dir) {
|
||||
std::vector<std::string> out;
|
||||
if (!fs::exists(dir) || !fs::is_directory(dir)) return out;
|
||||
for (const auto& entry : fs::directory_iterator(dir)) {
|
||||
if (!entry.is_regular_file()) continue;
|
||||
const auto path = entry.path();
|
||||
if (path.extension() == ".png") {
|
||||
out.push_back(path.stem().string());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static bool file_exists(const std::string& p) {
|
||||
struct stat st{};
|
||||
return ::stat(p.c_str(), &st) == 0;
|
||||
}
|
||||
|
||||
TEST_CASE("primitives_gallery visual goldens", "[visual]") {
|
||||
const std::string golden_dir = FN_TEST_GOLDEN_DIR;
|
||||
const std::string gallery_bin = FN_TEST_GALLERY_BIN;
|
||||
const std::string tmp_dir = FN_TEST_TMP_DIR;
|
||||
|
||||
INFO("golden dir: " << golden_dir);
|
||||
INFO("gallery bin: " << gallery_bin);
|
||||
INFO("tmp dir: " << tmp_dir);
|
||||
|
||||
auto goldens = list_pngs(golden_dir);
|
||||
if (goldens.empty()) {
|
||||
WARN("No goldens found in '" << golden_dir << "'. "
|
||||
"Run cpp/scripts/update_goldens.sh to generate them. "
|
||||
"Visual diff test SKIPPED.");
|
||||
SUCCEED("no goldens — skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
if (gallery_bin.empty() || !file_exists(gallery_bin)) {
|
||||
WARN("primitives_gallery binary not found at '" << gallery_bin
|
||||
<< "'. Build target primitives_gallery first. SKIPPED.");
|
||||
SUCCEED("gallery binary missing — skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
// Crear tmp_dir y correr captura.
|
||||
std::error_code ec;
|
||||
fs::create_directories(tmp_dir, ec);
|
||||
|
||||
// Ejecutar binario en modo --capture desde la raiz del repo. Algunas
|
||||
// demos resuelven paths relativos (p.ej. sql_workbench busca registry.db);
|
||||
// correr desde la raiz garantiza determinismo entre maquinas.
|
||||
std::string cmd = std::string("cd '") + FN_TEST_REPO_ROOT + "' && '"
|
||||
+ gallery_bin + "' --capture '" + tmp_dir + "' 2>&1";
|
||||
INFO("capture cmd: " << cmd);
|
||||
const int rc = std::system(cmd.c_str());
|
||||
if (rc != 0) {
|
||||
WARN("primitives_gallery --capture exited with rc=" << rc
|
||||
<< " (likely no GL context — WSL/headless). "
|
||||
<< "Run with LIBGL_ALWAYS_SOFTWARE=1 or install Mesa. SKIPPED.");
|
||||
SUCCEED("capture failed — environment lacks GL — skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
int matched = 0, missing_actual = 0, diffed = 0;
|
||||
for (const auto& demo_id : goldens) {
|
||||
const std::string g_path = golden_dir + "/" + demo_id + ".png";
|
||||
const std::string a_path = tmp_dir + "/" + demo_id + ".png";
|
||||
if (!file_exists(a_path)) {
|
||||
WARN("No actual capture for '" << demo_id << "' at " << a_path);
|
||||
++missing_actual;
|
||||
continue;
|
||||
}
|
||||
auto r = fn_test::pixel_diff_ratio(g_path, a_path, /*channel_threshold=*/5);
|
||||
INFO("demo: " << demo_id
|
||||
<< " diff_ratio=" << r.diff_ratio
|
||||
<< " (" << r.pixels_different << "/" << r.pixels_total << ")");
|
||||
// Tolerancia 1% por defecto.
|
||||
if (r.diff_ratio > 0.01) {
|
||||
++diffed;
|
||||
FAIL_CHECK("Visual diff for '" << demo_id << "' exceeds 1%: "
|
||||
<< (r.diff_ratio * 100.0) << "%. "
|
||||
<< "Compare " << a_path << " vs " << g_path
|
||||
<< " — if the change is intentional, run "
|
||||
<< "cpp/scripts/update_goldens.sh.");
|
||||
} else {
|
||||
++matched;
|
||||
}
|
||||
}
|
||||
|
||||
INFO("visual goldens — matched=" << matched
|
||||
<< " diffed=" << diffed
|
||||
<< " missing_actual=" << missing_actual);
|
||||
REQUIRE(diffed == 0);
|
||||
}
|
||||