merge: issue/0048-cpp-visual-tests-ci-gate — implementación paralela
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
@@ -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
|
||||
|
||||
@@ -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/"
|
||||
@@ -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);
|
||||
}
|
||||