merge: issue/0048-cpp-visual-tests-ci-gate — implementación paralela

This commit is contained in:
2026-04-29 00:19:37 +02:00
56 changed files with 2304 additions and 1 deletions
+45
View File
@@ -110,3 +110,48 @@ Solo si la app es:
En cualquier otro caso, usar `fn::run_app`. Si `AppConfig` no expone algo que
necesitas, **abrir un issue para extender el shell**, no duplicar boilerplate.
## Tests visuales y CI gate (issue 0048)
`primitives_gallery` soporta un modo `--capture <output_dir>` que renderiza
cada demo en una ventana GLFW invisible y guarda un PNG por demo. Se usa
para tests visuales tipo golden-image:
```bash
# Regenerar goldens (cuando tu cambio es intencional):
cpp/scripts/update_goldens.sh
# Equivalente manual:
LIBGL_ALWAYS_SOFTWARE=1 \
cpp/build/apps/primitives_gallery/primitives_gallery \
--capture cpp/tests/golden
```
`cpp/tests/test_visual.cpp` corre la captura sobre un tmpdir y compara contra
`cpp/tests/golden/<demo>.png` con tolerancia 1% de pixels distintos
(threshold 5/255 por canal). Skipea si:
- `cpp/tests/golden/` esta vacio (no hay goldens todavia).
- El binario `primitives_gallery` no se construyo.
- El entorno no puede crear contexto GL (WSL minimo, container sin Mesa) —
el test reporta SKIP en lugar de FAIL.
Para diagnosticar un diff: revisar el PNG actual en
`cpp/build/tests/visual_actual/<demo>.png` vs el golden en
`cpp/tests/golden/<demo>.png`.
### CI gate `check_tested.sh`
`cpp/scripts/check_tested.sh [days]` (default `30`) consulta `registry.db` y
falla con codigo != 0 si alguna funcion C++ creada en los ultimos N dias no
tiene `tested: true` en su frontmatter. Esta hookeado al final de
`cpp/scripts/run_tests.sh`, por lo que el flujo CI (`./scripts/run_tests.sh`)
falla si se anade una funcion C++ nueva sin test asociado.
Para satisfacer el gate:
1. Crear `cpp/tests/test_<name>.cpp` (puede ser placeholder Catch2 si la
logica visual se cubre via `primitives_gallery`).
2. Anadirlo a `cpp/tests/CMakeLists.txt` con `add_fn_test(test_<name> ...)`.
3. Marcar `tested: true` + `test_file_path: cpp/tests/test_<name>.cpp` en el
frontmatter del `.md` de la funcion.
4. Correr `fn index` para refrescar `registry.db`.
@@ -1,5 +1,6 @@
add_imgui_app(primitives_gallery
main.cpp
capture.cpp
demo.cpp
demos_core.cpp
demos_viz.cpp
+169
View File
@@ -0,0 +1,169 @@
// Implementacion de gallery::run_capture — render offscreen + glReadPixels +
// PNG via stb_image_write. Ver capture.h.
#include "capture.h"
#include "imgui.h"
#include "imgui_impl_glfw.h"
#include "imgui_impl_opengl3.h"
#include "implot.h"
#include "implot3d.h"
#include "core/tokens.h"
#include "core/icon_font.h"
#include "core/app_settings.h"
#include "gfx/gl_loader.h"
#include <GLFW/glfw3.h>
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "stb_image_write.h"
#include <cstdio>
#include <vector>
namespace gallery {
static void glfw_capture_error(int error, const char* description) {
std::fprintf(stderr, "GLFW Error %d: %s\n", error, description);
}
// Flip vertical in-place: OpenGL origin = bottom-left, PNG = top-left.
static void flip_vertical_rgba(unsigned char* px, int w, int h) {
const int stride = w * 4;
std::vector<unsigned char> row(stride);
for (int y = 0; y < h / 2; ++y) {
unsigned char* a = px + y * stride;
unsigned char* b = px + (h - 1 - y) * stride;
std::copy(a, a + stride, row.begin());
std::copy(b, b + stride, a);
std::copy(row.begin(), row.end(), b);
}
}
bool run_capture(const CaptureConfig& cfg, const std::vector<CaptureItem>& items) {
glfwSetErrorCallback(&glfw_capture_error);
if (!glfwInit()) {
std::fprintf(stderr, "capture: glfwInit failed\n");
return false;
}
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);
#ifdef __APPLE__
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
#endif
GLFWwindow* window = glfwCreateWindow(
cfg.capture_w, cfg.capture_h, "capture", nullptr, nullptr);
if (!window) {
std::fprintf(stderr, "capture: glfwCreateWindow failed (no GL?)\n");
glfwTerminate();
return false;
}
glfwMakeContextCurrent(window);
glfwSwapInterval(0);
if (!fn::gfx::gl_loader_init()) {
std::fprintf(stderr, "capture: gl_loader_init failed\n");
glfwDestroyWindow(window);
glfwTerminate();
return false;
}
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImPlot::CreateContext();
ImPlot3D::CreateContext();
ImGuiIO& io = ImGui::GetIO();
io.IniFilename = nullptr; // no .ini side effects in capture mode.
io.DisplaySize = ImVec2((float)cfg.capture_w, (float)cfg.capture_h);
fn_ui::settings_load();
fn_ui::load_fonts_from_settings();
{
ImGuiStyle& style = ImGui::GetStyle();
style.FontSizeBase = fn_ui::settings().font_size_px;
style._NextFrameFontSizeBase = style.FontSizeBase;
}
fn_tokens::apply_dark_theme();
ImGui_ImplGlfw_InitForOpenGL(window, false);
ImGui_ImplOpenGL3_Init("#version 330");
bool ok_all = true;
std::vector<unsigned char> pixels((size_t)cfg.capture_w * cfg.capture_h * 4u);
for (const auto& item : items) {
// Warmup: rinde varios frames para que ImGui/ImPlot estabilicen layout
// (el primer frame frecuentemente carece de mediciones de tamaño).
for (int frame = 0; frame < cfg.warmup_frames + 1; ++frame) {
glfwPollEvents();
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplGlfw_NewFrame();
ImGui::NewFrame();
// Ventana fullscreen sobre el viewport con la demo activa,
// sin sidebar (queremos el render del primitivo lo mas limpio
// posible para el diff visual).
const ImGuiViewport* vp = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(vp->WorkPos);
ImGui::SetNextWindowSize(vp->WorkSize);
ImGui::Begin("##capture_root",
nullptr,
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_NoBringToFrontOnFocus |
ImGuiWindowFlags_NoSavedSettings);
if (item.fn) item.fn();
ImGui::End();
ImGui::Render();
int dw, dh;
glfwGetFramebufferSize(window, &dw, &dh);
glViewport(0, 0, dw, dh);
glClearColor(fn_tokens::colors::bg.x,
fn_tokens::colors::bg.y,
fn_tokens::colors::bg.z, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
glfwSwapBuffers(window);
}
// Read framebuffer (GL_RGBA / GL_UNSIGNED_BYTE).
glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(0, 0, cfg.capture_w, cfg.capture_h,
GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
flip_vertical_rgba(pixels.data(), cfg.capture_w, cfg.capture_h);
char path[1024];
std::snprintf(path, sizeof(path), "%s/%s.png",
cfg.output_dir.c_str(), item.id.c_str());
const int rc = stbi_write_png(
path, cfg.capture_w, cfg.capture_h, 4,
pixels.data(), cfg.capture_w * 4);
if (rc == 0) {
std::fprintf(stderr, "capture: stbi_write_png failed for %s\n", path);
ok_all = false;
} else {
std::fprintf(stdout, "captured: %s\n", path);
}
}
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplGlfw_Shutdown();
ImPlot3D::DestroyContext();
ImPlot::DestroyContext();
ImGui::DestroyContext();
glfwDestroyWindow(window);
glfwTerminate();
return ok_all;
}
} // namespace gallery
+34
View File
@@ -0,0 +1,34 @@
#pragma once
// Capture mode: renderiza cada demo de la gallery en una ventana GLFW
// invisible y guarda un PNG en `output_dir/<demo_id>.png` via stb_image_write.
//
// Diseñado para CI / golden-image diffing: ver `cpp/scripts/update_goldens.sh`
// y `cpp/tests/test_visual.cpp`.
//
// Importante:
// - Requiere un contexto OpenGL real. En entornos sin GPU (containers minimos)
// funciona con `LIBGL_ALWAYS_SOFTWARE=1` (Mesa/llvmpipe) o swiftshader.
// - Si el entorno (WSL sin GL) no puede crear un contexto GL valido, el
// binario sale con codigo != 0 sin generar PNGs.
#include <string>
#include <vector>
namespace gallery {
struct CaptureItem {
std::string id;
void (*fn)();
};
struct CaptureConfig {
std::string output_dir;
int warmup_frames = 3;
int capture_w = 800;
int capture_h = 600;
};
// Devuelve true si todo el set se capturo OK.
bool run_capture(const CaptureConfig& cfg, const std::vector<CaptureItem>& items);
} // namespace gallery
+31 -1
View File
@@ -17,10 +17,12 @@
#include "demos.h"
#include "demo.h"
#include "capture.h"
#include <cstdio>
#include <cstring>
#include <string>
#include <sys/stat.h>
#include <vector>
struct DemoEntry {
@@ -161,7 +163,35 @@ static void render() {
fn_ui::toast_render();
}
int main(int /*argc*/, char** /*argv*/) {
int main(int argc, char** argv) {
// Capture mode: `primitives_gallery --capture <output_dir>` corre cada
// demo en una ventana GLFW invisible y guarda PNG por demo. Para CI/golden.
for (int i = 1; i < argc; i++) {
if (std::strcmp(argv[i], "--capture") == 0) {
if (i + 1 >= argc) {
std::fprintf(stderr, "--capture requires an output dir argument\n");
return 2;
}
const char* out_dir = argv[i + 1];
// Best-effort mkdir (idempotente).
mkdir(out_dir, 0755);
std::vector<gallery::CaptureItem> items;
items.reserve(k_demo_count);
for (int j = 0; j < k_demo_count; j++) {
items.push_back({k_demos[j].id, k_demos[j].fn});
}
gallery::CaptureConfig cfg;
cfg.output_dir = out_dir;
cfg.warmup_frames = 3;
cfg.capture_w = 800;
cfg.capture_h = 600;
const bool ok = gallery::run_capture(cfg, items);
return ok ? 0 : 1;
}
}
return fn::run_app(
{.title = "fn_registry · Primitives Gallery",
.width = 1400,
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# CI gate (issue 0048): falla si una funcion C++ creada en los ultimos N dias
# no tiene `tested: true` en su frontmatter del .md.
#
# Uso:
# cpp/scripts/check_tested.sh [days] # default: 30
#
# Requiere que registry.db este actualizada (corre `fn index` antes).
set -euo pipefail
DAYS="${1:-30}"
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
DB="$ROOT/registry.db"
if [ ! -f "$DB" ]; then
echo "WARN: registry.db not found at $DB; run 'fn index' first."
exit 0
fi
UNTESTED=$(sqlite3 "$DB" \
"SELECT id FROM functions WHERE lang='cpp' AND tested=0 \
AND (created_at IS NULL OR created_at > datetime('now','-${DAYS} days'));")
if [ -n "$UNTESTED" ]; then
echo "FAIL: las siguientes funciones C++ recientes no tienen tested:true en .md:"
echo "$UNTESTED"
echo ""
echo "Anade un test (cpp/tests/test_<name>.cpp) y marca el frontmatter del .md con tested:true."
exit 1
fi
echo "OK: todas las funciones C++ recientes (<= ${DAYS}d) tienen tests."
+4
View File
@@ -5,3 +5,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cmake -S "$ROOT" -B "$ROOT/build" -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON
cmake --build "$ROOT/build" -j"$(nproc)"
ctest --test-dir "$ROOT/build" --output-on-failure
# CI gate (issue 0048): toda funcion C++ creada en los ultimos 30 dias debe
# tener tested:true en el .md. No-op si no hay registry.db.
"$ROOT/scripts/check_tested.sh" 30
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Regenera los golden screenshots para el test visual (issue 0048).
#
# Uso:
# cpp/scripts/update_goldens.sh
#
# Compila primitives_gallery, lo lanza con --capture sobre cpp/tests/golden/,
# y deja PNG por demo. Use LIBGL_ALWAYS_SOFTWARE=1 si no hay GPU/driver.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cmake -S "$ROOT" -B "$ROOT/build" -DCMAKE_BUILD_TYPE=Release
cmake --build "$ROOT/build" --target primitives_gallery -j"$(nproc)"
mkdir -p "$ROOT/tests/golden"
LIBGL_ALWAYS_SOFTWARE=1 \
"$ROOT/build/apps/primitives_gallery/primitives_gallery" \
--capture "$ROOT/tests/golden"
echo "Goldens regenerated: $ROOT/tests/golden/"
+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);
}
+1724
View File
File diff suppressed because it is too large Load Diff