4b28ef6774
graph_labels_draw pinta etiquetas de nodos sobre el FBO del graph_renderer via ImDrawList. Politica configurable: always-on para selected/hovered/ pinned, top-N por size*(degree+1), culling por viewport AABB y min_node_pixel_size. Cap duro = max_visible + |always_*|. API: - graph_labels_draw(graph, viewport_state, policy, cb, user) - graph_labels_draw_at(...) — variante con rect explicito - graph_labels_select(...) — helper puro testeable - graph_compute_degrees(...) — O(E) Splitting en dos TUs: - graph_labels.cpp — funciones draw (depende de ImGui) - graph_labels_select.cpp — helpers puros para tests sin ImGui 12 tests en test_graph_labels (culling, max_visible cap, min_pixel_size, always_* gating por viewport, top-N por score, edge cases). Todos verdes. Integrado en demos_graph con UI: toggle Labels, sliders Max visible / Font / Min px, checkboxes Selected/Hovered/Pinned. Golden de graph_viewport regenerado. Cierra issue 0049j.
295 lines
9.9 KiB
C++
295 lines
9.9 KiB
C++
// Unit tests para graph_labels (issue 0049j). Cubre la logica pura de
|
|
// seleccion (graph_labels_select) y graph_compute_degrees:
|
|
// - culling por viewport (AABB)
|
|
// - cap por max_visible
|
|
// - min_node_pixel_size
|
|
// - always_for_selected requiere estar en viewport
|
|
// - empty graph no-op
|
|
//
|
|
// La parte de pintado (graph_labels_draw / graph_labels_draw_at) usa ImGui y
|
|
// se ejercita visualmente en primitives_gallery.
|
|
|
|
#define CATCH_CONFIG_MAIN
|
|
#include "catch_amalgamated.hpp"
|
|
|
|
#include "viz/graph_labels.h"
|
|
#include "viz/graph_types.h"
|
|
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
// Helper: construye un GraphData con N nodos en una rejilla de world coords
|
|
// donde el nodo i esta en (i*step, 0). Tamaño base = 5 px.
|
|
struct GridGraph {
|
|
std::vector<GraphNode> nodes;
|
|
std::vector<GraphEdge> edges;
|
|
GraphData data{};
|
|
|
|
void make(int N, float step = 10.0f, float size = 5.0f) {
|
|
nodes.clear();
|
|
edges.clear();
|
|
for (int i = 0; i < N; ++i) {
|
|
GraphNode n = graph_node((float)i * step, 0.0f);
|
|
n.size_override = size;
|
|
nodes.push_back(n);
|
|
}
|
|
sync();
|
|
}
|
|
|
|
void add_edge(uint32_t s, uint32_t t) {
|
|
edges.push_back(graph_edge(s, t));
|
|
sync();
|
|
}
|
|
|
|
void sync() {
|
|
data.nodes = nodes.data();
|
|
data.node_count = (int)nodes.size();
|
|
data.node_capacity = (int)nodes.capacity();
|
|
data.edges = edges.data();
|
|
data.edge_count = (int)edges.size();
|
|
data.edge_capacity = (int)edges.capacity();
|
|
data.types = nullptr; data.type_count = 0;
|
|
data.rel_types = nullptr; data.rel_type_count = 0;
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("graph_compute_degrees counts incident edges", "[viz][labels][degrees]") {
|
|
GridGraph g; g.make(4);
|
|
g.add_edge(0, 1);
|
|
g.add_edge(0, 2);
|
|
g.add_edge(1, 2);
|
|
|
|
int deg[4];
|
|
graph::graph_compute_degrees(g.data, deg);
|
|
REQUIRE(deg[0] == 2);
|
|
REQUIRE(deg[1] == 2);
|
|
REQUIRE(deg[2] == 2);
|
|
REQUIRE(deg[3] == 0);
|
|
}
|
|
|
|
TEST_CASE("graph_compute_degrees skips invisible edges", "[viz][labels][degrees]") {
|
|
GridGraph g; g.make(3);
|
|
g.add_edge(0, 1);
|
|
g.add_edge(0, 2);
|
|
g.edges[0].flags &= ~EF_VISIBLE; // hide first edge
|
|
|
|
int deg[3];
|
|
graph::graph_compute_degrees(g.data, deg);
|
|
REQUIRE(deg[0] == 1);
|
|
REQUIRE(deg[1] == 0);
|
|
REQUIRE(deg[2] == 1);
|
|
}
|
|
|
|
TEST_CASE("graph_labels_select respects max_visible cap", "[viz][labels][select]") {
|
|
// 100 nodos en linea, todos en viewport, todos por encima de min_pixel.
|
|
GridGraph g; g.make(100, 10.0f, 5.0f);
|
|
|
|
graph::LabelPolicy p;
|
|
p.max_visible = 7;
|
|
p.always_for_selected = false;
|
|
p.always_for_hovered = false;
|
|
p.always_for_pinned = false;
|
|
p.min_node_pixel_size = 0.0f; // no cull por tamano
|
|
|
|
int out[200];
|
|
// Camara centrada en x=500 (medio de la linea), zoom 5 (5 px / world unit)
|
|
// widget enorme para que todos esten en viewport.
|
|
int n = graph::graph_labels_select(g.data, p,
|
|
/*cam_x=*/500.0f, /*cam_y=*/0.0f,
|
|
/*zoom=*/5.0f,
|
|
/*w=*/100000.0f, /*h=*/100000.0f,
|
|
/*degrees=*/nullptr,
|
|
out, 200);
|
|
REQUIRE(n == 7);
|
|
}
|
|
|
|
TEST_CASE("graph_labels_select skips nodes off-viewport", "[viz][labels][select]") {
|
|
// 20 nodos en x = 0..190. Camara en x=0, zoom=1, widget 100x100.
|
|
// Visible AABB = x in [-50, 50], y in [-50, 50].
|
|
// Nodos visibles: i*10 in [-50,50] -> i in [0..5].
|
|
GridGraph g; g.make(20, 10.0f, 5.0f);
|
|
|
|
graph::LabelPolicy p;
|
|
p.max_visible = 100;
|
|
p.min_node_pixel_size = 0.0f;
|
|
p.always_for_selected = false;
|
|
p.always_for_hovered = false;
|
|
|
|
int out[100];
|
|
int n = graph::graph_labels_select(g.data, p,
|
|
0.0f, 0.0f, 1.0f,
|
|
100.0f, 100.0f,
|
|
nullptr, out, 100);
|
|
REQUIRE(n == 6); // i = 0..5
|
|
}
|
|
|
|
TEST_CASE("graph_labels_select drops nodes below min_node_pixel_size",
|
|
"[viz][labels][select]") {
|
|
// Nodos de tamano 1 world unit. zoom=1 -> 1 px en pantalla.
|
|
// min_node_pixel_size=12 -> ninguno pasa el filtro top-N.
|
|
GridGraph g; g.make(10, 5.0f, 1.0f);
|
|
|
|
graph::LabelPolicy p;
|
|
p.max_visible = 100;
|
|
p.min_node_pixel_size = 12.0f;
|
|
p.always_for_selected = false;
|
|
p.always_for_hovered = false;
|
|
|
|
int out[100];
|
|
int n = graph::graph_labels_select(g.data, p,
|
|
25.0f, 0.0f, 1.0f,
|
|
1000.0f, 1000.0f,
|
|
nullptr, out, 100);
|
|
REQUIRE(n == 0);
|
|
}
|
|
|
|
TEST_CASE("graph_labels_select always_for_selected: in-viewport on, off-viewport off",
|
|
"[viz][labels][select][selected]") {
|
|
GridGraph g; g.make(20, 10.0f, 1.0f);
|
|
// Nodo 0 (x=0, dentro del viewport) seleccionado y nodo 19 (x=190,
|
|
// fuera) seleccionado. Solo el primero debe entrar en out.
|
|
g.nodes[0].flags |= NF_SELECTED;
|
|
g.nodes[19].flags |= NF_SELECTED;
|
|
g.sync();
|
|
|
|
graph::LabelPolicy p;
|
|
p.max_visible = 0; // solo always_*
|
|
p.min_node_pixel_size = 999.0f; // cualquier top-N descartado
|
|
p.always_for_selected = true;
|
|
p.always_for_hovered = false;
|
|
p.always_for_pinned = false;
|
|
|
|
int out[10];
|
|
// viewport AABB en cam=0, zoom=1, w=h=100 -> x in [-50,50]
|
|
int n = graph::graph_labels_select(g.data, p,
|
|
0.0f, 0.0f, 1.0f,
|
|
100.0f, 100.0f,
|
|
nullptr, out, 10);
|
|
REQUIRE(n == 1);
|
|
REQUIRE(out[0] == 0);
|
|
}
|
|
|
|
TEST_CASE("graph_labels_select always_* skip min_pixel_size",
|
|
"[viz][labels][select][selected]") {
|
|
// Nodos de 1 px world. Sin always_*, max_visible=100, min_pixel=12 -> 0.
|
|
// Con always_for_hovered y un nodo HOVERED en viewport, debe dibujarse
|
|
// el HOVERED a pesar de ser pequenio.
|
|
GridGraph g; g.make(5, 5.0f, 1.0f);
|
|
g.nodes[0].flags |= NF_HOVERED;
|
|
g.sync();
|
|
|
|
graph::LabelPolicy p;
|
|
p.max_visible = 100;
|
|
p.min_node_pixel_size = 12.0f;
|
|
p.always_for_selected = false;
|
|
p.always_for_hovered = true;
|
|
p.always_for_pinned = false;
|
|
|
|
int out[10];
|
|
int n = graph::graph_labels_select(g.data, p,
|
|
10.0f, 0.0f, 1.0f,
|
|
1000.0f, 1000.0f,
|
|
nullptr, out, 10);
|
|
REQUIRE(n == 1);
|
|
REQUIRE(out[0] == 0);
|
|
}
|
|
|
|
TEST_CASE("graph_labels_select max_visible=0 + always returns only always",
|
|
"[viz][labels][select]") {
|
|
GridGraph g; g.make(10, 5.0f, 5.0f);
|
|
g.nodes[3].flags |= NF_PINNED;
|
|
g.sync();
|
|
|
|
graph::LabelPolicy p;
|
|
p.max_visible = 0;
|
|
p.min_node_pixel_size = 0.0f;
|
|
p.always_for_selected = false;
|
|
p.always_for_hovered = false;
|
|
p.always_for_pinned = true;
|
|
|
|
int out[10];
|
|
int n = graph::graph_labels_select(g.data, p,
|
|
25.0f, 0.0f, 1.0f,
|
|
1000.0f, 1000.0f,
|
|
nullptr, out, 10);
|
|
REQUIRE(n == 1);
|
|
REQUIRE(out[0] == 3);
|
|
}
|
|
|
|
TEST_CASE("graph_labels_select picks top-N by size*degree", "[viz][labels][select][topn]") {
|
|
// 5 nodos, todos en viewport. Tamano uniforme. Damos al nodo 2 mas grado
|
|
// (3 aristas) — debe entrar primero en top-N=1.
|
|
GridGraph g; g.make(5, 5.0f, 5.0f);
|
|
g.add_edge(2, 0);
|
|
g.add_edge(2, 1);
|
|
g.add_edge(2, 3);
|
|
|
|
int deg[5];
|
|
graph::graph_compute_degrees(g.data, deg);
|
|
|
|
graph::LabelPolicy p;
|
|
p.max_visible = 1;
|
|
p.min_node_pixel_size = 0.0f;
|
|
p.always_for_selected = false;
|
|
p.always_for_hovered = false;
|
|
|
|
int out[5];
|
|
int n = graph::graph_labels_select(g.data, p,
|
|
10.0f, 0.0f, 1.0f,
|
|
1000.0f, 1000.0f,
|
|
deg, out, 5);
|
|
REQUIRE(n == 1);
|
|
REQUIRE(out[0] == 2);
|
|
}
|
|
|
|
TEST_CASE("graph_labels_select skips NF_VISIBLE=0 nodes", "[viz][labels][select]") {
|
|
GridGraph g; g.make(5, 5.0f, 5.0f);
|
|
g.nodes[2].flags &= ~NF_VISIBLE;
|
|
g.sync();
|
|
|
|
graph::LabelPolicy p;
|
|
p.max_visible = 100;
|
|
p.min_node_pixel_size = 0.0f;
|
|
p.always_for_selected = false;
|
|
p.always_for_hovered = false;
|
|
|
|
int out[10];
|
|
int n = graph::graph_labels_select(g.data, p,
|
|
10.0f, 0.0f, 1.0f,
|
|
1000.0f, 1000.0f,
|
|
nullptr, out, 10);
|
|
REQUIRE(n == 4);
|
|
for (int k = 0; k < n; ++k) REQUIRE(out[k] != 2);
|
|
}
|
|
|
|
TEST_CASE("graph_labels_select empty graph no-op", "[viz][labels][select][empty]") {
|
|
GridGraph g; g.make(0);
|
|
graph::LabelPolicy p;
|
|
int out[10];
|
|
int n = graph::graph_labels_select(g.data, p,
|
|
0.0f, 0.0f, 1.0f,
|
|
100.0f, 100.0f,
|
|
nullptr, out, 10);
|
|
REQUIRE(n == 0);
|
|
}
|
|
|
|
TEST_CASE("graph_labels_select degenerate inputs return 0", "[viz][labels][select][edge]") {
|
|
GridGraph g; g.make(5);
|
|
graph::LabelPolicy p;
|
|
int out[10];
|
|
|
|
// zoom <= 0
|
|
REQUIRE(graph::graph_labels_select(g.data, p, 0,0, 0.0f, 100,100, nullptr, out, 10) == 0);
|
|
REQUIRE(graph::graph_labels_select(g.data, p, 0,0, -1.0f, 100,100, nullptr, out, 10) == 0);
|
|
// widget invalido
|
|
REQUIRE(graph::graph_labels_select(g.data, p, 0,0, 1.0f, 0.0f, 100, nullptr, out, 10) == 0);
|
|
REQUIRE(graph::graph_labels_select(g.data, p, 0,0, 1.0f, 100, 0.0f, nullptr, out, 10) == 0);
|
|
// out_capacity 0
|
|
REQUIRE(graph::graph_labels_select(g.data, p, 0,0, 1.0f, 100,100, nullptr, out, 0) == 0);
|
|
// out null
|
|
REQUIRE(graph::graph_labels_select(g.data, p, 0,0, 1.0f, 100,100, nullptr, nullptr, 10) == 0);
|
|
}
|