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.
This commit is contained in:
2026-04-29 00:18:39 +02:00
parent 6be660fac6
commit e750894847
46 changed files with 251 additions and 0 deletions
+19
View File
@@ -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)
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+64
View File
@@ -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
+29
View File
@@ -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
+139
View File
@@ -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);
}