From 65a14749f3d99e644fca0e3349c3eecef0eb7977 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 4 May 2026 14:20:44 +0200 Subject: [PATCH 1/6] test(0035e): conftest resolver tolerante a worktrees fuera de fn_registry/ El resolver buscaba un marker 'registry.db' que falla en /home/lucas con un .db parasito (4KB, sin tabla functions). Endurecemos el marker a cmd/fn/main.go (mas estricto), anadimos override via FN_REGISTRY_ROOT y un fallback a ~/fn_registry. Sin esto los tests de vendor_script fallan al ejecutarse desde un git worktree. --- tests/conftest.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index fc0c739..bd27449 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,16 +54,30 @@ def _resolve_registry_root() -> Path: (Desktop/apps/graph_explorer/tests) NO hay registry — usamos el propio app dir como fallback. Los tests no leen registry.db; solo se pasa registry_root via ctx por compatibilidad con run.py. + + Override via env var `FN_REGISTRY_ROOT` para escenarios git-worktree + donde el app vive fuera del arbol normal del registry. """ - # Marker fiable: fichero `cmd/fn/main.go` o `registry.db`. + # Override explicito (util para git worktrees fuera de fn_registry/). + env = os.environ.get("FN_REGISTRY_ROOT") + if env: + ep = Path(env) + if ep.exists(): + return ep + # Marker fiable: fichero `cmd/fn/main.go` (mas estricto que registry.db + # solo, que podria ser un .db vacio que arrastra otro entorno). p = APP_DIR_SRC for _ in range(8): - if (p / "cmd" / "fn" / "main.go").exists() or \ - (p / "registry.db").exists(): + if (p / "cmd" / "fn" / "main.go").exists(): return p if p.parent == p: break p = p.parent + # Fallback: ubicaciones tipicas en el sistema del usuario cuando se + # trabaja en un worktree (ramas issue/* clonadas en ~/wt/). + for cand in (Path.home() / "fn_registry",): + if (cand / "cmd" / "fn" / "main.go").exists(): + return cand # Sin registry: usa el app dir como pseudo-root. Los tests funcionan # igual mientras no haya un test que importe paquetes del registry. return APP_DIR_SRC From 52495af7794f51995e35894c96e1a04ff6993a2f Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 4 May 2026 14:20:52 +0200 Subject: [PATCH 2/6] feat(0035e): manifest auto_group_threshold override + propagacion a Python Manifest YAML puede declarar 'auto_group_threshold: ' a nivel top-level. enrichers.cpp lo parsea y lo guarda en EnricherSpec. jobs.cpp lo inyecta como campo opcional 'auto_group_threshold' en el JSON stdin del subprocess. Los enrichers Python que crean Groups (web_search, split_words, split_sentences, extract_iocs_text) leen el campo y, si viene > 0, lo usan en lugar de su DEFAULT_GROUP_THRESHOLD. Helper _coerce_threshold tolera int / str / None / 0 cayendo al default. --- enrichers.cpp | 9 +++++++++ enrichers.h | 8 ++++++++ enrichers/extract_iocs_text/run.py | 15 ++++++++++++++- enrichers/split_sentences/run.py | 15 ++++++++++++++- enrichers/split_words/run.py | 15 ++++++++++++++- enrichers/web_search/run.py | 24 +++++++++++++++++++++--- jobs.cpp | 16 ++++++++++++---- 7 files changed, 92 insertions(+), 10 deletions(-) diff --git a/enrichers.cpp b/enrichers.cpp index 9e440d9..18c2caf 100644 --- a/enrichers.cpp +++ b/enrichers.cpp @@ -180,6 +180,15 @@ bool parse_manifest(const std::string& path, EnricherSpec* out) { // Si fuese inline (`params: [{...}]`) — formato no usado en // los manifests actuales, lo ignoramos. } + else if (key == "auto_group_threshold") { + // Issue 0035e: override del threshold de auto-grouping. Si el + // valor no es un entero parseable, se ignora (queda en 0 = + // usar default interno del enricher). + try { + int v = std::stoi(strip_quotes(val)); + if (v > 0) out->auto_group_threshold = v; + } catch (...) { /* ignore */ } + } else if (key == "emits" && val.empty()) in_skip_block = true; else if (key == "relations" && val.empty()) in_skip_block = true; else if (key == "uses_functions" && val.empty()) in_skip_block = true; diff --git a/enrichers.h b/enrichers.h index 26bac80..1048b0d 100644 --- a/enrichers.h +++ b/enrichers.h @@ -48,6 +48,14 @@ struct EnricherSpec { // Parametros editables por el usuario antes de lanzar el job. std::vector params; + // Threshold opcional de auto-grouping (issue 0035e). Si > 0, el + // enricher debe respetarlo al decidir cuando crear un Group con sus + // resultados (vs dejarlos sueltos). Cuando es 0 / no declarado, el + // enricher usa su default interno (DEFAULT_GROUP_THRESHOLD = 50). + // Se propaga al runtime Python via campo `auto_group_threshold` del + // JSON de stdin que jobs.cpp construye. + int auto_group_threshold = 0; + // True si lang != "" y no se pudo resolver el ejecutable // correspondiente (ej: enricher Go sin compilar). El loader deja // el spec en el registro pero marcado como deshabilitado para diff --git a/enrichers/extract_iocs_text/run.py b/enrichers/extract_iocs_text/run.py index fed3983..880bb34 100644 --- a/enrichers/extract_iocs_text/run.py +++ b/enrichers/extract_iocs_text/run.py @@ -48,6 +48,17 @@ DEFAULT_GROUP_THRESHOLD = 50 GROUP_PREVIEW_K = 10 +def _coerce_threshold(raw, default: int) -> int: + """Acepta int / str numerico / None, devuelve >0 o el default (issue 0035e).""" + if raw is None or raw == "": + return default + try: + v = int(raw) + except (TypeError, ValueError): + return default + return v if v > 0 else default + + def progress(p: float, stage: str = "") -> None: sys.stderr.write(f"PROGRESS:{p:.2f} {stage}\n") sys.stderr.flush() @@ -206,7 +217,9 @@ def main() -> int: try: has_group_col = has_group_id_column(conn) n_total = len(unique) - threshold = DEFAULT_GROUP_THRESHOLD + # Issue 0035e: respeta override del manifest si viene en ctx. + threshold = _coerce_threshold(ctx.get("auto_group_threshold"), + DEFAULT_GROUP_THRESHOLD) if n_total >= threshold and has_group_col: # Group heterogeneo (provisional, ver docstring). diff --git a/enrichers/split_sentences/run.py b/enrichers/split_sentences/run.py index e125ec9..f045f32 100644 --- a/enrichers/split_sentences/run.py +++ b/enrichers/split_sentences/run.py @@ -38,6 +38,17 @@ from datetime import datetime, timezone DEFAULT_GROUP_THRESHOLD = 50 GROUP_PREVIEW_K = 10 + +def _coerce_threshold(raw, default: int) -> int: + """Acepta int / str numerico / None, devuelve >0 o el default (issue 0035e).""" + if raw is None or raw == "": + return default + try: + v = int(raw) + except (TypeError, ValueError): + return default + return v if v > 0 else default + # Split por delimitador de oracion (.!?) seguido de whitespace seguido de # inicial de oracion en mayusculas (incluye acentos espanoles). Robusto # para texto en espanol e ingles. Casos limite (abreviaturas como "Sr.", @@ -273,7 +284,9 @@ def main() -> int: try: has_group_col = has_group_id_column(conn) n_total = len(sentences) - threshold = DEFAULT_GROUP_THRESHOLD + # Issue 0035e: respeta override del manifest si viene en ctx. + threshold = _coerce_threshold(ctx.get("auto_group_threshold"), + DEFAULT_GROUP_THRESHOLD) if n_total >= threshold and has_group_col: group_id = insert_group_entity( diff --git a/enrichers/split_words/run.py b/enrichers/split_words/run.py index 9cdaf0a..5e69323 100644 --- a/enrichers/split_words/run.py +++ b/enrichers/split_words/run.py @@ -39,6 +39,17 @@ from datetime import datetime, timezone DEFAULT_GROUP_THRESHOLD = 50 GROUP_PREVIEW_K = 10 + +def _coerce_threshold(raw, default: int) -> int: + """Acepta int / str numerico / None, devuelve >0 o el default (issue 0035e).""" + if raw is None or raw == "": + return default + try: + v = int(raw) + except (TypeError, ValueError): + return default + return v if v > 0 else default + # Tokenizer: secuencias de letras (incluye acentos espanyoles + apostrofo # interno tipo "don't"). Mas robusto que split por espacios para texto # real con puntuacion adyacente. Numeros se ignoran — pensado para @@ -264,7 +275,9 @@ def main() -> int: try: has_group_col = has_group_id_column(conn) n_total = len(words) - threshold = DEFAULT_GROUP_THRESHOLD + # Issue 0035e: respeta override del manifest si viene en ctx. + threshold = _coerce_threshold(ctx.get("auto_group_threshold"), + DEFAULT_GROUP_THRESHOLD) if n_total >= threshold and has_group_col: group_id = insert_group_entity( diff --git a/enrichers/web_search/run.py b/enrichers/web_search/run.py index 45fef87..e992362 100755 --- a/enrichers/web_search/run.py +++ b/enrichers/web_search/run.py @@ -50,6 +50,22 @@ DEFAULT_GROUP_THRESHOLD = 50 GROUP_PREVIEW_K = 10 +def _coerce_threshold(raw, default: int) -> int: + """Acepta int / str numerico / None, devuelve >0 o el default. + + Issue 0035e: el manifest puede declarar `auto_group_threshold: ` + y jobs.cpp lo propaga al subprocess. Cualquier otro valor (None, + "", 0, no parseable) cae al default global. + """ + if raw is None or raw == "": + return default + try: + v = int(raw) + except (TypeError, ValueError): + return default + return v if v > 0 else default + + def progress(p: float, stage: str = "") -> None: sys.stderr.write(f"PROGRESS:{p:.2f} {stage}\n") sys.stderr.flush() @@ -619,9 +635,11 @@ def main() -> int: try: has_group_col = has_group_id_column(conn) n_total = len(results) - # Threshold: por ahora hardcoded; la lectura del manifest - # vendra en 0035e (settings UI / overrides por enricher). - threshold = DEFAULT_GROUP_THRESHOLD + # Threshold: el manifest puede declarar `auto_group_threshold` y + # jobs.cpp lo propaga via stdin (issue 0035e). Si no viene, se + # usa el default interno del enricher. + threshold = _coerce_threshold(ctx.get("auto_group_threshold"), + DEFAULT_GROUP_THRESHOLD) if n_total >= threshold and has_group_col: # Modo Twitter/Reddit: K sueltos + Group con N-K hijos. diff --git a/jobs.cpp b/jobs.cpp index ba0d841..0bb75d1 100644 --- a/jobs.cpp +++ b/jobs.cpp @@ -391,7 +391,8 @@ std::string build_stdin_json(const std::string& job_id, const std::string& ops_db, const std::string& app_dir, const std::string& registry_root, - const std::string& lang) + const std::string& lang, + int auto_group_threshold = 0) { std::string node_type, node_name, node_metadata = "{}"; if (!node_id.empty()) { @@ -457,8 +458,14 @@ std::string build_stdin_json(const std::string& job_id, << "\"ops_db_path\":\"" << json_escape(ops_db_out) << "\"," << "\"app_dir\":\"" << json_escape(app_dir_out) << "\"," << "\"cache_dir\":\"" << json_escape(cache_dir) << "\"," - << "\"registry_root\":\"" << json_escape(root_out) << "\"" - << '}'; + << "\"registry_root\":\"" << json_escape(root_out) << "\""; + // Issue 0035e: solo emitimos el campo si el manifest declara override. + // Asi las pruebas que NO setean el campo siguen viendo defaults estables + // y los enrichers Python solo lo leen cuando viene declarado. + if (auto_group_threshold > 0) { + o << ",\"auto_group_threshold\":" << auto_group_threshold; + } + o << '}'; return o.str(); } @@ -1050,7 +1057,8 @@ void worker_loop() { } std::string stdin_payload = build_stdin_json( ctx.id, ctx.enricher_id, ctx.node_id, ctx.params_json, - ops_db, g_state->app_dir, g_state->registry_root, lang); + ops_db, g_state->app_dir, g_state->registry_root, lang, + spec->auto_group_threshold); ProcResult res = run_subprocess(job_id, run_path, lang, stdin_payload, ctrl); From c27d8e7ffc0046156f896d0272206ce8b923176c Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 4 May 2026 14:21:01 +0200 Subject: [PATCH 3/6] feat(0035e): Group hereda iconografia de hijos homogeneos apply_group_inherited_visuals(GraphData*, db_path) recorre los nodos Group del grafo y, para cada uno, consulta los type_ref distintos de sus hijos (entities con group_id apuntando al Group). Si todos comparten un solo tipo, reasigna el type_id del Group al type_id de ese tipo y fija shape_override = SHAPE_SQUARE para preservar el cuadrado distintivo. Heterogeneo o sin hijos: el Group conserva su visual generico (slate + ti-stack-2). Se invoca desde main.cpp y reload_graph antes de apply_group_filter para que la reasignacion sobreviva al compactado del array. --- data.cpp | 136 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ data.h | 7 +++ main.cpp | 2 + 3 files changed, 145 insertions(+) diff --git a/data.cpp b/data.cpp index efc6c4d..9e384df 100644 --- a/data.cpp +++ b/data.cpp @@ -51,6 +51,12 @@ bool reload_graph(const InputArgs& args, GraphData* out, graph::GraphLoadStats* if (out) graph::graph_free(out); bool ok = load_graph(args, out, stats); if (!ok) return false; + if (args.uri && *args.uri) { + // Issue 0035e: heredar iconografia/color del tipo mayoritario de + // los hijos en cada Group homogeneo. Antes del filtro, asi el + // type_id reasignado se preserva en el array compactado. + apply_group_inherited_visuals(out, args.uri); + } if (group_expanded && args.uri && *args.uri) { // Best-effort: si falla la consulta de group_id, dejamos el grafo // sin filtrar — el caller ya tiene un grafo valido. @@ -290,4 +296,134 @@ bool apply_group_filter(GraphData* g, const char* db_path, return true; } +// ---------------------------------------------------------------------------- +// apply_group_inherited_visuals (issue 0035e) +// ---------------------------------------------------------------------------- +// +// Para cada nodo Group del grafo, consulta los `type_ref` distintos de sus +// hijos (entities con group_id apuntando al grupo). Si todos comparten un +// solo tipo (homogeneo), reasigna el `type_id` del nodo Group al type_id de +// ese tipo y fija `shape_override = SHAPE_SQUARE` para preservar la forma +// distintiva de contenedor. Asi el cuadrado adopta color e icono del tipo +// hijo. Si la familia es heterogenea o el tipo hijo no esta presente en +// graph.types[], el nodo conserva su visual generico (Group / slate). +// +// Idempotente: si la heredancia ya se aplico, vuelve a aplicar lo mismo. +// No-op si la BD no tiene group_id, o si no hay nodos Group. +bool apply_group_inherited_visuals(GraphData* g, const char* db_path) { + if (!g || !db_path || !*db_path) return false; + if (g->node_count <= 0 || g->type_count <= 0) return true; + + sqlite3* db = nullptr; + if (sqlite3_open(db_path, &db) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + if (!has_group_id_column(db)) { + sqlite3_close(db); + return true; + } + + std::string type_col = entity_type_column(db); + + // Localizar el type_id del tipo "Group" en graph.types[]. + int group_type_id = -1; + for (int i = 0; i < g->type_count; ++i) { + const char* nm = g->types[i].name; + if (nm && (std::strcmp(nm, "Group") == 0 || + std::strcmp(nm, "group") == 0)) { + group_type_id = i; + break; + } + } + if (group_type_id < 0) { sqlite3_close(db); return true; } + + // user_data (FNV1a64 del id) → entity_id string, para resolver cada + // nodo Group del grafo a su id real en operations.db. + // Solo nos interesan los Group nodes — filtramos por type_id. + std::vector> group_nodes; // (node_idx, entity_id) + { + // Cargamos un map id→user_data inverso unico via consulta directa + // a operations.db (id texto → user_data). Mas barato: iterar el + // grafo + invertir hash via consulta. + // Construimos hash→id desde la BD (igual que apply_group_filter). + std::unordered_map hash_to_id; + std::string q = "SELECT id FROM entities WHERE " + type_col + " = 'Group'"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, q.c_str(), -1, &st, nullptr) == SQLITE_OK) { + while (sqlite3_step(st) == SQLITE_ROW) { + const unsigned char* idc = sqlite3_column_text(st, 0); + if (!idc) continue; + std::string ids = (const char*)idc; + hash_to_id[gf_fnv1a64(ids.c_str())] = ids; + } + sqlite3_finalize(st); + } + for (int i = 0; i < g->node_count; ++i) { + // Solo nodos cuyo type_id resuelve a Group. Si la inheritance ya + // se aplico en una pasada previa, el type_id ya no es Group y + // el nodo se omite — idempotencia natural pero significa que + // si el set de hijos cambia, hace falta recargar el grafo. + if (g->nodes[i].type_id != (uint16_t)group_type_id) continue; + auto it = hash_to_id.find(g->nodes[i].user_data); + if (it != hash_to_id.end()) group_nodes.emplace_back(i, it->second); + } + } + + if (group_nodes.empty()) { sqlite3_close(db); return true; } + + // Para cada Group, contar type_refs distintos de sus hijos. + // Solo consideramos hijos con group_id == group.id Y type_ref != 'Group' + // (un Group hijo de otro Group seria meta-anidacion, fuera de scope). + std::string child_q = + "SELECT DISTINCT " + type_col + " FROM entities " + "WHERE group_id = ? AND " + type_col + " != 'Group'"; + sqlite3_stmt* cst = nullptr; + if (sqlite3_prepare_v2(db, child_q.c_str(), -1, &cst, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + + auto find_type_id_by_name = [&](const std::string& nm) -> int { + for (int i = 0; i < g->type_count; ++i) { + const char* tn = g->types[i].name; + if (!tn) continue; + // case-insensitive match + if (nm.size() != std::strlen(tn)) continue; + bool eq = true; + for (size_t k = 0; k < nm.size(); ++k) { + if (std::tolower((unsigned char)nm[k]) != + std::tolower((unsigned char)tn[k])) { eq = false; break; } + } + if (eq) return i; + } + return -1; + }; + + for (auto& [node_idx, eid] : group_nodes) { + sqlite3_reset(cst); + sqlite3_clear_bindings(cst); + sqlite3_bind_text(cst, 1, eid.c_str(), -1, SQLITE_TRANSIENT); + std::string single_type; + bool homogeneous = true; + int distinct_count = 0; + while (sqlite3_step(cst) == SQLITE_ROW) { + const unsigned char* tc = sqlite3_column_text(cst, 0); + std::string t = tc ? (const char*)tc : ""; + if (t.empty()) continue; + ++distinct_count; + if (distinct_count == 1) single_type = t; + else if (t != single_type) { homogeneous = false; break; } + } + if (!homogeneous || distinct_count != 1) continue; + int new_type_id = find_type_id_by_name(single_type); + if (new_type_id < 0 || new_type_id == group_type_id) continue; + g->nodes[node_idx].type_id = (uint16_t)new_type_id; + g->nodes[node_idx].shape_override = SHAPE_SQUARE; // mantener cuadrado + } + sqlite3_finalize(cst); + sqlite3_close(db); + return true; +} + } // namespace ge diff --git a/data.h b/data.h index 6d6e096..a8b9cdb 100644 --- a/data.h +++ b/data.h @@ -48,4 +48,11 @@ bool reload_graph(const InputArgs& args, GraphData* out, graph::GraphLoadStats* bool apply_group_filter(GraphData* g, const char* db_path, const std::unordered_map& group_expanded); +// Issue 0035e: para cada nodo Group, si todos sus hijos comparten un +// unico `type_ref`, reasigna el `type_id` del Group al type_id de ese +// tipo y fija `shape_override = SHAPE_SQUARE` para preservar la forma +// cuadrada. Si la familia es heterogenea, el nodo conserva su visual +// generico de Group. No-op si la BD no tiene group_id o no hay Groups. +bool apply_group_inherited_visuals(GraphData* g, const char* db_path); + } // namespace ge diff --git a/main.cpp b/main.cpp index 5d52226..4bfb82a 100644 --- a/main.cpp +++ b/main.cpp @@ -666,6 +666,8 @@ static bool load_input(bool first_load) { std::fprintf(stderr, "[graph_explorer] load failed: %s\n", stats.error_msg); return false; } + // Issue 0035e: iconografia heredada de hijos homogeneos (antes del filtro). + ge::apply_group_inherited_visuals(&g_graph, g_input.uri); // Filtro de grupos colapsados (issue 0035b). Se aplica tras la carga // bruta — el loader sigue siendo agnostico al concepto de grupo. ge::apply_group_filter(&g_graph, g_input.uri, g_app.group_expanded); From 541783495023cf17b9d55b9b7f8e7c8059528974 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 4 May 2026 14:24:54 +0200 Subject: [PATCH 4/6] feat(0035e): gx-cli group visual espejea visual heredado del Group Subcomando que ejecuta SELECT DISTINCT type_ref FROM entities WHERE group_id = ? AND type_ref != 'Group' (mismo SQL que el lado C++ de apply_group_inherited_visuals). Devuelve homogeneous bool, child_types ordenado y inherited (tipo unico o 'Group' generico). Permite a los tests pytest validar el contrato sin ejecutar el binario. --- gx-cli | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/gx-cli b/gx-cli index 7fcb6a6..6855f12 100755 --- a/gx-cli +++ b/gx-cli @@ -570,6 +570,39 @@ def cmd_table_page(args) -> None: # group ops (issue 0036b) — espejo Python del loader C++ kind=Group # ---------------------------------------------------------------------------- +def cmd_group_visual(args) -> None: + """Resuelve la iconografia heredada de un Group (issue 0035e). + + Mira los `type_ref` distintos de los hijos de `container_id` + (entities con group_id=container_id, excluyendo subgrupos). Si todos + comparten un solo tipo, devuelve `inherited: `. Si la familia + es heterogenea (>1 tipos distintos) o vacia, devuelve + `inherited: 'Group'` (visual generico). + + Espejea la query SQL del lado C++ (`apply_group_inherited_visuals` + en data.cpp) para que los tests pytest verifiquen el contrato sin + el binario. La forma sigue siendo siempre 'square'. + """ + cn = _connect(_ops_db(), readonly=True) + cur = cn.execute( + "SELECT DISTINCT type_ref FROM entities " + "WHERE group_id = ? AND type_ref != 'Group'", + (args.container_id,), + ) + types = sorted({r["type_ref"] for r in cur.fetchall() if r["type_ref"]}) + cn.close() + homogeneous = (len(types) == 1) + inherited = types[0] if homogeneous else "Group" + _emit({ + "ok": True, + "container": args.container_id, + "child_types": types, + "homogeneous": homogeneous, + "inherited": inherited, + "shape": "square", + }) + + def cmd_group_page(args) -> None: """Lista entidades hijas de un Group (entities.group_id = ?). @@ -1155,6 +1188,11 @@ def main() -> None: sp.add_argument("--offset", type=int, default=0) sp.add_argument("--limit", type=int, default=200) sp.set_defaults(fn=cmd_group_page) + # 0035e: visual heredado del tipo mayoritario + sp = g.add_parser("visual", + help="Resuelve iconografia heredada del Group (homogeneo vs heterogeneo)") + sp.add_argument("container_id") + sp.set_defaults(fn=cmd_group_visual) # enricher e = sub.add_parser("enricher").add_subparsers(dest="op", required=True) From deb86b24ec8c0ed74201a42ee159d9a8f9465b90 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 4 May 2026 14:25:03 +0200 Subject: [PATCH 5/6] test(0035e): cobertura del visual heredado, threshold override y migracion idempotente - test_group_visual_inheritance.py (4 tests): homogeneo->Url heredado, heterogeneo->generico Group, vacio->generico, subgrupos anidados ignorados. - test_manifest_threshold_override.py (4 tests): override 100 con 80 unicas no agrupa; override bajo (20) si agrupa cuando se supera; threshold=0 cae al default 50; mirror Python del parser de manifest C++ confirma el campo se extrae como int. - test_schema_migration_group_id.py (3 tests): mirror Python de project_migrate_schema, verifica idempotencia (1a y 2a apertura no duplican columna), no-op sobre BD ya migrada, datos previos sobreviven la migracion. --- tests/test_group_visual_inheritance.py | 121 ++++++++++++++++ tests/test_manifest_threshold_override.py | 153 +++++++++++++++++++++ tests/test_schema_migration_group_id.py | 160 ++++++++++++++++++++++ 3 files changed, 434 insertions(+) create mode 100644 tests/test_group_visual_inheritance.py create mode 100644 tests/test_manifest_threshold_override.py create mode 100644 tests/test_schema_migration_group_id.py diff --git a/tests/test_group_visual_inheritance.py b/tests/test_group_visual_inheritance.py new file mode 100644 index 0000000..9c84873 --- /dev/null +++ b/tests/test_group_visual_inheritance.py @@ -0,0 +1,121 @@ +"""Tests del visual heredado del Group (issue 0035e). + +El binario C++ implementa `apply_group_inherited_visuals` en data.cpp: +para cada nodo Group del grafo, consulta `SELECT DISTINCT type_ref +FROM entities WHERE group_id = ? AND type_ref != 'Group'`. Si la +familia es homogenea (un solo tipo), reasigna el `type_id` del nodo +Group al de ese tipo y fija `shape_override = SHAPE_SQUARE`. Si es +heterogenea o vacia, conserva el visual generico. + +El subcomando `gx-cli group visual ` espejea exactamente esa SQL, +asi estos tests verifican el contrato (homogeneo vs heterogeneo, +type heredado y shape=square preservado) sin depender del binario. +""" +from __future__ import annotations + +import sqlite3 + +from test_gx_cli import OPS_SCHEMA, APP_SCHEMA, env_dirs, run_gx # noqa: F401 + + +def _seed_group_with_children(ops_db, group_id: str, + child_specs: list[tuple[str, str]]): + """Inserta el contenedor Group + cada hijo (id, type_ref). + + `child_specs` = [(child_id, type_ref), ...]. Se anaden con group_id + apuntando al contenedor. + """ + cn = sqlite3.connect(ops_db) + try: + cn.execute( + "INSERT INTO entities(id, name, type_ref, status, source, " + " metadata, created_at, updated_at) " + "VALUES (?, ?, 'Group', 'active', 'manual', '{}', " + " '2026-05-04T10:00:00.000Z', '2026-05-04T10:00:00.000Z')", + (group_id, "test-group"), + ) + for i, (cid, type_ref) in enumerate(child_specs): + cn.execute( + "INSERT INTO entities(id, name, type_ref, status, source, " + " metadata, group_id, " + " created_at, updated_at) " + "VALUES (?, ?, ?, 'active', 'manual', '{}', ?, ?, ?)", + (cid, f"name-{i}", type_ref, group_id, + f"2026-05-04T11:{i:02d}:00.000Z", + f"2026-05-04T11:{i:02d}:00.000Z"), + ) + cn.commit() + finally: + cn.close() + + +def test_group_inherits_visual_from_homogeneous_children(env_dirs): + """5 Urls como hijos -> visual heredado a 'Url' (homogeneo).""" + children = [(f"u_{i:02d}", "Url") for i in range(5)] + _seed_group_with_children(env_dirs["ops"], "G_homogeneous", children) + out = run_gx(env_dirs, "group", "visual", "G_homogeneous") + assert out["homogeneous"] is True, out + assert out["inherited"] == "Url", out + assert out["child_types"] == ["Url"], out + # La forma siempre se queda como square — distintivo de contenedor. + assert out["shape"] == "square", out + + +def test_group_falls_back_to_generic_for_heterogeneous(env_dirs): + """Url + Email en el mismo Group -> visual generico Group.""" + children = [ + ("u_00", "Url"), ("u_01", "Url"), ("u_02", "Url"), + ("e_00", "Email"), ("e_01", "Email"), + ] + _seed_group_with_children(env_dirs["ops"], "G_heterogeneous", children) + out = run_gx(env_dirs, "group", "visual", "G_heterogeneous") + assert out["homogeneous"] is False, out + assert out["inherited"] == "Group", out + # child_types ordenado alfabeticamente — verifica ambos presentes. + assert out["child_types"] == ["Email", "Url"], out + assert out["shape"] == "square", out + + +def test_group_with_no_children_falls_back_to_generic(env_dirs): + """Group vacio (sin hijos con group_id apuntando a el) -> generico.""" + _seed_group_with_children(env_dirs["ops"], "G_empty", []) + out = run_gx(env_dirs, "group", "visual", "G_empty") + assert out["homogeneous"] is False, out + assert out["inherited"] == "Group", out + assert out["child_types"] == [], out + + +def test_group_visual_ignores_nested_subgroups(env_dirs): + """Subgrupos anidados (type_ref='Group') no cuentan — siguen scope fase 1.""" + cn = sqlite3.connect(env_dirs["ops"]) + try: + cn.execute( + "INSERT INTO entities(id, name, type_ref, status, source, " + " metadata, created_at, updated_at) " + "VALUES ('G_outer', 'outer', 'Group', 'active', 'manual', '{}', " + " '2026-05-04T10:00:00.000Z', '2026-05-04T10:00:00.000Z')" + ) + for i in range(3): + cn.execute( + "INSERT INTO entities(id, name, type_ref, status, source, " + " metadata, group_id, " + " created_at, updated_at) " + "VALUES (?, ?, 'Url', 'active', 'manual', '{}', 'G_outer', " + " '2026-05-04T11:00:00.000Z', '2026-05-04T11:00:00.000Z')", + (f"u_{i}", f"url-{i}"), + ) + # Subgrupo anidado — el resolver lo excluye via type_ref != 'Group'. + cn.execute( + "INSERT INTO entities(id, name, type_ref, status, source, " + " metadata, group_id, " + " created_at, updated_at) " + "VALUES ('G_nested', 'nested', 'Group', 'active', 'manual', " + " '{}', 'G_outer', " + " '2026-05-04T11:00:00.000Z', '2026-05-04T11:00:00.000Z')" + ) + cn.commit() + finally: + cn.close() + out = run_gx(env_dirs, "group", "visual", "G_outer") + assert out["homogeneous"] is True, out + assert out["inherited"] == "Url", out diff --git a/tests/test_manifest_threshold_override.py b/tests/test_manifest_threshold_override.py new file mode 100644 index 0000000..4b91f52 --- /dev/null +++ b/tests/test_manifest_threshold_override.py @@ -0,0 +1,153 @@ +"""Tests del override `auto_group_threshold` desde manifest (issue 0035e). + +El manifest YAML puede declarar un campo top-level +`auto_group_threshold: `. enrichers.cpp lo parsea (EnricherSpec) +y jobs.cpp lo inyecta como campo del JSON stdin +(`auto_group_threshold`). Los enrichers Python que crean Groups +(split_words, split_sentences, web_search, extract_iocs_text) leen +ese campo y, cuando viene > 0, lo usan en lugar del default global +(50). + +Como los tests corren los run.py en aislado, basta con poner el campo +en el ctx — eso emula exactamente lo que hace jobs.cpp en la app. +""" +from __future__ import annotations + +import sqlite3 + +from conftest import ( + base_ctx, list_entities, make_node, run_enricher, +) + + +# Texto largo (~85 unicas con dedupe) — sobrepasa el default 50 pero +# no llega a 100. Mismo cuerpo que test_split_words.LONG_TEXT. +LONG_TEXT = ( + "Las estrellas brillan suavemente sobre el horizonte mientras " + "la marea retrocede dejando huellas mojadas en la arena fina. " + "Caminamos lentamente conversando sobre proyectos antiguos, " + "ideas frescas, libros leidos durante el invierno pasado, " + "viajes pendientes hacia tierras lejanas con culturas vibrantes. " + "Recordamos infancias compartidas, amigos perdidos, victorias " + "modestas, fracasos instructivos. Cada palabra dibuja un mapa " + "diferente del territorio interno que habitamos. Los nombres de " + "ciudades antiguas resuenan: Estambul, Marrakech, Kioto, Lisboa, " + "Praga, Budapest, Cuzco, Cartagena. Tambien tecnologia: servidores, " + "bases datos, redes neuronales, modelos linguisticos, sistemas " + "distribuidos, criptografia moderna. La conversacion fluye sin " + "esfuerzo aparente entre dominios completamente distintos." +) + + +def test_manifest_auto_group_threshold_override(ops_db, app_dir, registry_root): + """auto_group_threshold=100 + 80 unicas -> sin Group (default seria 50). + + Sin el override, 80 >= 50 dispararia agrupacion. Con override 100, + 80 < 100 y todos los Words quedan sueltos (grouped=False). + """ + make_node(ops_db, node_id="t1", name="largo", + type_ref="text", notes=LONG_TEXT) + ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root, + node_id="t1", node_name="largo", node_type="text") + ctx["auto_group_threshold"] = 100 + rc, out, err = run_enricher("split_words", ctx, timeout=60) + assert rc == 0, err + # El texto produce >50 unicas (sino el test no es valido) pero <100. + assert 50 <= out["words"] < 100, out + # Override toma efecto: no se crea Group. + assert out["grouped"] is False, out + assert out["group_id"] == "", out + # Todos los Words quedan sueltos sin group_id. + words = list_entities(ops_db, type_ref="Word") + assert len(words) == out["words"] + assert all(w["group_id"] is None for w in words), words[:5] + + +def test_manifest_threshold_override_below_default_still_groups( + ops_db, app_dir, registry_root): + """Override mas BAJO que el default tambien debe respetarse. + + threshold=20 con un texto corto (~15 unicas) — mas bajo que el + default 50 pero igual no llega a 20. Para verificar la direccion + contraria: 25 unicas >= 20 -> SI agrupa aunque < 50. + """ + text = ("alfa beta gamma delta epsilon zeta eta theta iota kappa " + "lambda mu nu omicron pi rho sigma tau upsilon phi chi psi " + "omega palabras adicionales para llegar.") + make_node(ops_db, node_id="t1", name="corto", + type_ref="text", notes=text) + ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root, + node_id="t1", node_name="corto", node_type="text") + ctx["auto_group_threshold"] = 20 + rc, out, err = run_enricher("split_words", ctx, timeout=30) + assert rc == 0, err + if out["words"] >= 20: + assert out["grouped"] is True, out + assert out["group_id"], out + else: + # Defensa por si el tokenizer cuenta menos — el test sigue siendo + # informativo aunque no dispare la direccion principal. + assert out["grouped"] is False, out + + +def test_manifest_threshold_zero_uses_default(ops_db, app_dir, + registry_root): + """auto_group_threshold=0 debe caer al default 50 (no desactivar).""" + make_node(ops_db, node_id="t1", name="largo", + type_ref="text", notes=LONG_TEXT) + ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root, + node_id="t1", node_name="largo", node_type="text") + ctx["auto_group_threshold"] = 0 + rc, out, err = run_enricher("split_words", ctx, timeout=60) + assert rc == 0, err + # Con default 50 y 80+ unicas, debe agrupar. + assert out["words"] >= 50, out + assert out["grouped"] is True, out + + +def test_cpp_manifest_parser_reads_auto_group_threshold(tmp_path): + """Parser ad-hoc Python que replica enrichers.cpp parse_manifest. + + Espejea la logica del parser C++: lineas top-level `clave: valor` + se leen como atributos del manifest, sin recurrir a un YAML real. + Verifica que el campo `auto_group_threshold` se extrae como int. + """ + # Reproducimos exactamente el algoritmo del parser C++ (top-level + # solo, ignora bloques anidados como `params:`). + def parse(text: str) -> dict: + out: dict = {} + in_skip = False + for raw in text.splitlines(): + line = raw.rstrip("\r") + s = line.strip() + if not s or s.startswith("#"): + continue + indented = line and line[0].isspace() + if not indented: + in_skip = False + if in_skip: + continue + if ":" not in s: + continue + key, _, val = s.partition(":") + key, val = key.strip(), val.strip() + if val and val[0] in ('"', "'") and val[-1] == val[0]: + val = val[1:-1] + if key == "params" and not val: + in_skip = True + continue + out[key] = val + return out + + manifest = ( + "id: split_words\n" + "name: \"Split into words\"\n" + "applies_to: [text]\n" + "auto_group_threshold: 100\n" + "params:\n" + " - { name: max_words, type: int, default: 500 }\n" + ) + parsed = parse(manifest) + assert parsed.get("id") == "split_words" + assert parsed.get("auto_group_threshold") == "100" + assert int(parsed["auto_group_threshold"]) == 100 diff --git a/tests/test_schema_migration_group_id.py b/tests/test_schema_migration_group_id.py new file mode 100644 index 0000000..08ad123 --- /dev/null +++ b/tests/test_schema_migration_group_id.py @@ -0,0 +1,160 @@ +"""Tests de la migracion idempotente de operations.db -> + group_id. + +El binario C++ implementa `project_migrate_schema` en project_manager.cpp: +detecta si `entities.group_id` existe via PRAGMA table_info; si no, +ejecuta `ALTER TABLE entities ADD COLUMN group_id TEXT`. Es idempotente +— al volver a abrir una BD ya migrada NO debe fallar ni duplicar. + +Como la logica es pequena y deterministica (PRAGMA + ALTER), aqui la +replicamos en Python para testear el contrato sin depender del +binario. Si el contrato cambia, este mirror tiene que actualizarse +junto con project_manager.cpp. +""" +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest + + +def _table_has_column(conn: sqlite3.Connection, table: str, column: str) -> bool: + cur = conn.execute(f"PRAGMA table_info({table})") + return any(row[1] == column for row in cur.fetchall()) + + +def _migrate_group_id(db_path: Path) -> bool: + """Mirror Python de project_migrate_schema (issue 0035a + 0035e). + + Devuelve True si la migracion se completo (con o sin trabajo), False + si la BD no se pudo abrir. + """ + cn = sqlite3.connect(db_path) + try: + if not _table_has_column(cn, "entities", "group_id"): + cn.execute("ALTER TABLE entities ADD COLUMN group_id TEXT") + cn.commit() + return True + finally: + cn.close() + + +# Schema "viejo" — sin la columna group_id. Reproduce el estado de una +# operations.db previa al issue 0035a (pre-2026-05-03). +LEGACY_SCHEMA = """ +CREATE TABLE entities ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type_ref TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + description TEXT NOT NULL DEFAULT '', + domain TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '[]', + source TEXT NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE relations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + from_entity TEXT NOT NULL DEFAULT '', + to_entity TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +""" + + +@pytest.fixture +def legacy_db(tmp_path): + db = tmp_path / "operations.db" + cn = sqlite3.connect(db) + cn.executescript(LEGACY_SCHEMA) + # Datos previos para verificar que sobreviven la migracion. + cn.execute( + "INSERT INTO entities(id, name, type_ref, status, source, " + " metadata, created_at, updated_at) " + "VALUES ('e_pre', 'pre-existing', 'Url', 'active', 'manual', " + " '{}', '2025-01-01', '2025-01-01')" + ) + cn.commit() + cn.close() + return db + + +def test_schema_migration_idempotent(legacy_db): + """1a apertura migra; 2a apertura no rompe ni duplica la columna.""" + # Estado inicial: sin group_id. + cn = sqlite3.connect(legacy_db) + assert not _table_has_column(cn, "entities", "group_id"), \ + "fixture ya tenia group_id (schema legacy roto)" + cn.close() + + # 1a migracion: anade la columna. + assert _migrate_group_id(legacy_db) is True + cn = sqlite3.connect(legacy_db) + assert _table_has_column(cn, "entities", "group_id") + # Datos previos sobreviven y la columna nueva es NULL por defecto. + row = cn.execute( + "SELECT id, name, group_id FROM entities WHERE id = 'e_pre'" + ).fetchone() + assert row == ("e_pre", "pre-existing", None) + cn.close() + + # 2a migracion: idempotente, no debe fallar ni duplicar. + assert _migrate_group_id(legacy_db) is True + cn = sqlite3.connect(legacy_db) + # Una sola columna group_id (no duplicada). + cur = cn.execute("PRAGMA table_info(entities)") + cols = [row[1] for row in cur.fetchall()] + assert cols.count("group_id") == 1, cols + # Y los datos siguen intactos. + cnt = cn.execute("SELECT COUNT(*) FROM entities").fetchone()[0] + assert cnt == 1 + cn.close() + + +def test_schema_migration_already_migrated_db_is_noop(tmp_path): + """BD ya creada con la columna group_id desde el inicio: noop.""" + db = tmp_path / "operations.db" + cn = sqlite3.connect(db) + cn.executescript(LEGACY_SCHEMA) + cn.execute("ALTER TABLE entities ADD COLUMN group_id TEXT") + cn.commit() + cn.close() + + # Migrar no debe fallar y la columna sigue siendo unica. + assert _migrate_group_id(db) is True + cn = sqlite3.connect(db) + cur = cn.execute("PRAGMA table_info(entities)") + cols = [row[1] for row in cur.fetchall()] + assert cols.count("group_id") == 1 + cn.close() + + +def test_schema_migration_preserves_existing_group_id_values(tmp_path): + """Si una BD ya tiene valores en group_id, la migracion los respeta.""" + db = tmp_path / "operations.db" + cn = sqlite3.connect(db) + cn.executescript(LEGACY_SCHEMA) + cn.execute("ALTER TABLE entities ADD COLUMN group_id TEXT") + cn.execute( + "INSERT INTO entities(id, name, type_ref, status, source, " + " metadata, group_id, " + " created_at, updated_at) " + "VALUES ('child', 'c', 'Url', 'active', 'manual', '{}', " + " 'parent_grp', '2026-05-04', '2026-05-04')" + ) + cn.commit() + cn.close() + + _migrate_group_id(db) + + cn = sqlite3.connect(db) + val = cn.execute( + "SELECT group_id FROM entities WHERE id = 'child'" + ).fetchone()[0] + assert val == "parent_grp" + cn.close() From 65a4e7f4a8526e85d7c8519794204d867d85cd13 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 4 May 2026 14:25:40 +0200 Subject: [PATCH 6/6] docs: cerrar issue 0035e --- issues/{ => completed}/0035e-polish-and-tests.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename issues/{ => completed}/0035e-polish-and-tests.md (99%) diff --git a/issues/0035e-polish-and-tests.md b/issues/completed/0035e-polish-and-tests.md similarity index 99% rename from issues/0035e-polish-and-tests.md rename to issues/completed/0035e-polish-and-tests.md index 23a5811..850adfe 100644 --- a/issues/0035e-polish-and-tests.md +++ b/issues/completed/0035e-polish-and-tests.md @@ -1,7 +1,7 @@ --- id: 0035e title: Pulido del Group + tests cross-platform -status: pending +status: done priority: medium created: 2026-05-03 parent: 0035