// 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 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 nodes; std::vector 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); }