// Tests para fn_ring::compute_ring_layout (cpp/functions/core/compute_ring_layout). // Pure: sin ImGui context, sin I/O. // Issue 0109b — skill_tree ring layout. #define CATCH_CONFIG_MAIN #include "catch_amalgamated.hpp" #include "core/compute_ring_layout.h" #include #include #include #include using namespace fn_ring; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- static const LayoutConfig kDefault; // default 5 rings, 18 sectors static LayoutOutput find_by_id(const std::vector& out, const std::string& id) { for (auto& o : out) { if (o.id == id) return o; } // not found — return sentinel with ring=-99 LayoutOutput bad; bad.id = id; bad.ring = -99; return bad; } static float dist2(float x, float y) { return std::sqrt(x * x + y * y); } // --------------------------------------------------------------------------- // Test 1: empty input → empty output // --------------------------------------------------------------------------- TEST_CASE("empty_input_empty_output") { auto out = compute_ring_layout({}, kDefault); REQUIRE(out.empty()); } // --------------------------------------------------------------------------- // Test 2: un solo nodo cae en centro de su banda radial + centro de su sector // --------------------------------------------------------------------------- TEST_CASE("single_node_centered_in_bin") { std::vector nodes = {{"n1", "completado", "core", 1.0f}}; // "completado" → ring 0, ring_radii default: [0, 150) // r_inner efectivo = 30 (ring0 avoidance), r_outer = 150 // r_lo = 30 + 14 = 44, r_hi = 150 - 14 = 136, center = (44+136)/2 = 90 auto out = compute_ring_layout(nodes, kDefault); REQUIRE(out.size() == 1); auto o = out[0]; REQUIRE(o.ring == 0); REQUIRE(o.sector == 17); // "core" no esta en DomainOrder vacia → sector n_sectors-1 = 17 float r = dist2(o.x, o.y); // Centro de banda: [44, 136] -> 90.0 REQUIRE(std::abs(r - 90.0f) < 0.01f); } // --------------------------------------------------------------------------- // Test 3: dos nodos en mismo bin → radios distintos, uniformemente distribuidos // --------------------------------------------------------------------------- TEST_CASE("two_nodes_same_bin_radial_distribution") { std::vector nodes = { {"a", "completado", "alpha", 0.5f}, {"b", "completado", "alpha", 0.5f}, }; DomainOrder order = {"alpha"}; auto out = compute_ring_layout(nodes, kDefault, {}, order); REQUIRE(out.size() == 2); auto oa = find_by_id(out, "a"); auto ob = find_by_id(out, "b"); REQUIRE(oa.ring == 0); REQUIRE(ob.ring == 0); REQUIRE(oa.sector == 0); REQUIRE(ob.sector == 0); float ra = dist2(oa.x, oa.y); float rb = dist2(ob.x, ob.y); // Radios deben ser distintos REQUIRE(std::abs(ra - rb) > 0.01f); // Ambos deben estar en la banda [44, 136] float r_lo = 44.0f; // 30 + 14 float r_hi = 136.0f; // 150 - 14 REQUIRE(ra >= r_lo - 0.01f); REQUIRE(ra <= r_hi + 0.01f); REQUIRE(rb >= r_lo - 0.01f); REQUIRE(rb <= r_hi + 0.01f); // N=2: r_0 = 44 + 0.5*(136-44)/2 = 44+23 = 67; r_1 = 44+1.5*46 = 113 // (aprox, sin jitter porque bin tiene 2 nodos y capacidad radial = band/18 ~ 5) // No verificamos valores exactos porque el jitter angular puede activarse, // pero la diferencia de radios debe ser aprox (r_hi-r_lo)/N = 46 float expected_step = (r_hi - r_lo) / 2.0f; // 46 REQUIRE(std::abs(std::abs(ra - rb) - expected_step) < 0.5f); } // --------------------------------------------------------------------------- // Test 4: default status map mapea correctamente // --------------------------------------------------------------------------- TEST_CASE("default_status_map") { std::vector nodes = { {"c1", "completado", "d", 0.0f}, {"c2", "completed", "d", 0.0f}, {"c3", "in-progress", "d", 0.0f}, {"c4", "pendiente", "d", 0.0f}, {"c5", "deferred", "d", 0.0f}, {"c6", "locked", "d", 0.0f}, {"c7", "unlocked", "d", 0.0f}, {"c8", "bloqueado", "d", 0.0f}, }; auto out = compute_ring_layout(nodes, kDefault); REQUIRE(find_by_id(out, "c1").ring == 0); REQUIRE(find_by_id(out, "c2").ring == 0); REQUIRE(find_by_id(out, "c3").ring == 1); REQUIRE(find_by_id(out, "c4").ring == 3); REQUIRE(find_by_id(out, "c5").ring == 4); REQUIRE(find_by_id(out, "c6").ring == 3); REQUIRE(find_by_id(out, "c7").ring == 2); REQUIRE(find_by_id(out, "c8").ring == 4); } // --------------------------------------------------------------------------- // Test 5: status no mapeado → ring == -1, x/y == 0 // --------------------------------------------------------------------------- TEST_CASE("unmapped_status_returns_ring_minus_one") { std::vector nodes = {{"x1", "unknown_status", "core", 0.5f}}; auto out = compute_ring_layout(nodes, kDefault); REQUIRE(out.size() == 1); auto o = out[0]; REQUIRE(o.ring == -1); REQUIRE(o.x == 0.0f); REQUIRE(o.y == 0.0f); } // --------------------------------------------------------------------------- // Test 6: domain fuera del orden → sector n_sectors-1 // --------------------------------------------------------------------------- TEST_CASE("domain_not_in_order_falls_back_to_last_sector") { DomainOrder order = {"alpha", "beta", "gamma"}; std::vector nodes = {{"n1", "completado", "unknown_domain", 0.5f}}; LayoutConfig cfg = kDefault; cfg.n_sectors = 5; auto out = compute_ring_layout(nodes, cfg, {}, order); REQUIRE(out.size() == 1); REQUIRE(out[0].sector == 4); // n_sectors-1 = 4 } // --------------------------------------------------------------------------- // Test 7: determinismo — dos llamadas identicas producen el mismo output // --------------------------------------------------------------------------- TEST_CASE("deterministic_repeated_call") { std::vector nodes = { {"0001", "completado", "core", 1.0f}, {"0002", "pendiente", "infra", 0.5f}, {"0003", "in-progress", "finance", 0.8f}, {"0004", "deferred", "core", 0.1f}, {"0005", "completado", "infra", 0.9f}, }; auto out1 = compute_ring_layout(nodes, kDefault); auto out2 = compute_ring_layout(nodes, kDefault); REQUIRE(out1.size() == out2.size()); for (size_t i = 0; i < out1.size(); ++i) { REQUIRE(out1[i].id == out2[i].id); REQUIRE(out1[i].x == out2[i].x); REQUIRE(out1[i].y == out2[i].y); REQUIRE(out1[i].ring == out2[i].ring); REQUIRE(out1[i].sector == out2[i].sector); REQUIRE(out1[i].rank_in_bin == out2[i].rank_in_bin); } } // --------------------------------------------------------------------------- // Test 8: ring 0 con radio interno 0 → nodos colocados con r >= 30 // --------------------------------------------------------------------------- TEST_CASE("ring_zero_avoids_origin") { // ring_radii[0] == 0 por defecto std::vector nodes = { {"n1", "completado", "d1", 1.0f}, {"n2", "completed", "d2", 0.9f}, }; auto out = compute_ring_layout(nodes, kDefault); for (auto& o : out) { if (o.ring == 0) { float r = dist2(o.x, o.y); REQUIRE(r >= 30.0f - 0.01f); // kRing0InnerMin = 30 REQUIRE(r > 0.5f); // definitivamente no en el origen } } } // --------------------------------------------------------------------------- // Test 9: sector wrap-around — sector 17 (ultimo) con 18 sectores // theta ≈ 2*PI*(17+0.5)/18 = 2*PI*17.5/18 ≈ 6.109 rad // --------------------------------------------------------------------------- TEST_CASE("sector_wrap_around_last_sector") { // Forzamos que el nodo caiga en sector 17 pasando domain_order sin "misc" std::vector nodes = {{"n1", "completado", "misc_domain", 1.0f}}; LayoutConfig cfg; cfg.n_sectors = 18; cfg.ring_radii = {0.0f, 150.0f, 280.0f, 450.0f, 650.0f, 850.0f}; cfg.start_angle = 0.0f; cfg.bin_padding = 14.0f; DomainOrder order; // vacia → "misc_domain" cae en sector n_sectors-1 = 17 auto out = compute_ring_layout(nodes, cfg, {}, order); REQUIRE(out.size() == 1); REQUIRE(out[0].sector == 17); // theta = start_angle + (17 + 0.5) * (2*PI / 18) = 17.5 / 18 * 2*PI const float pi = 3.14159265358979323846f; const float theta = (17.5f / 18.0f) * 2.0f * pi; // ≈ 6.1087 rad // Radio del nodo: ring 0, r_inner=30, r_outer=150, padding=14 → [44,136] → 90 const float r = 90.0f; float expected_x = r * std::cos(theta); float expected_y = r * std::sin(theta); REQUIRE(std::abs(out[0].x - expected_x) < 0.05f); REQUIRE(std::abs(out[0].y - expected_y) < 0.05f); } // --------------------------------------------------------------------------- // Test 10: golden snapshot — 30 nodos, mezcla de status/domain // Verifica primer y ultimo nodo con tolerancia 0.001 // --------------------------------------------------------------------------- TEST_CASE("golden_snapshot_30_nodes") { // Input fijo: 30 nodos con mix de status/domain/recency const std::vector nodes = { {"0001", "completado", "core", 1.00f}, {"0002", "completado", "infra", 0.95f}, {"0003", "in-progress", "finance", 0.90f}, {"0004", "pendiente", "core", 0.85f}, {"0005", "deferred", "datascience", 0.80f}, {"0006", "completado", "core", 0.75f}, {"0007", "locked", "infra", 0.70f}, {"0008", "unlocked", "finance", 0.65f}, {"0009", "pendiente", "core", 0.60f}, {"0010", "in-progress", "datascience", 0.55f}, {"0011", "completado", "infra", 0.50f}, {"0012", "bloqueado", "core", 0.45f}, {"0013", "deferred", "finance", 0.40f}, {"0014", "pendiente", "infra", 0.35f}, {"0015", "completado", "datascience", 0.30f}, {"0016", "unknown_status", "core", 0.25f}, // descartado {"0017", "in-progress", "infra", 0.20f}, {"0018", "pendiente_unlocked", "finance", 0.15f}, {"0019", "completed", "datascience", 0.10f}, {"0020", "locked", "core", 0.05f}, {"0021", "completado", "core", 1.00f}, {"0022", "completado", "infra", 0.92f}, {"0023", "pendiente", "finance", 0.88f}, {"0024", "in-progress", "core", 0.84f}, {"0025", "deferred", "infra", 0.80f}, {"0026", "unlocked", "datascience", 0.76f}, {"0027", "completado", "finance", 0.72f}, {"0028", "locked", "datascience", 0.68f}, {"0029", "bloqueado", "core", 0.64f}, {"0030", "pendiente", "finance", 0.60f}, }; DomainOrder order = {"core", "infra", "finance", "datascience"}; LayoutConfig cfg; cfg.n_sectors = 18; cfg.ring_radii = {0.0f, 150.0f, 280.0f, 450.0f, 650.0f, 850.0f}; cfg.start_angle = 0.0f; cfg.bin_padding = 14.0f; cfg.center_x = 0.0f; cfg.center_y = 0.0f; auto out = compute_ring_layout(nodes, cfg, {}, order); REQUIRE(out.size() == 30); // Nodo "0016" tiene status desconocido → debe ser descartado auto o16 = find_by_id(out, "0016"); REQUIRE(o16.ring == -1); REQUIRE(o16.x == 0.0f); REQUIRE(o16.y == 0.0f); // Verificar "0001": completado→ring0, core→sector0 auto o1 = find_by_id(out, "0001"); REQUIRE(o1.ring == 0); REQUIRE(o1.sector == 0); // theta = 0 + (0 + 0.5) * (2*PI / 18) = PI/18 ≈ 0.17453 rad // En el bin (ring=0, sector=0) hay "0001", "0006", "0021", "0024" // ordenados por (recency desc): recency 1.00, 0.75, 1.00 (0021), 0.84 // -> "0001"(1.0), "0021"(1.0, id mayor), "0024"(0.84), "0006"(0.75) // rank "0001" = 0 si su id < "0021": "0001" < "0021" → rank 0 REQUIRE(o1.rank_in_bin == 0); // El radio y angulo exactos dependen del jitter deterministico. // Verificamos que esta dentro de la banda del ring 0: [44, 136] float r1 = dist2(o1.x, o1.y); REQUIRE(r1 >= 44.0f - 0.01f); REQUIRE(r1 <= 136.0f + 0.01f); // Verificar "0030": pendiente→ring3, finance→sector2 auto o30 = find_by_id(out, "0030"); REQUIRE(o30.ring == 3); REQUIRE(o30.sector == 2); // ring 3: [450, 650), r_lo = 450+14 = 464, r_hi = 650-14 = 636 float r30 = dist2(o30.x, o30.y); REQUIRE(r30 >= 464.0f - 0.01f); REQUIRE(r30 <= 636.0f + 0.01f); // El output es deterministico: dos llamadas dan resultado identico auto out2 = compute_ring_layout(nodes, cfg, {}, order); for (size_t i = 0; i < out.size(); ++i) { REQUIRE(out[i].x == out2[i].x); REQUIRE(out[i].y == out2[i].y); } }