Merge issue 0035e — polish del Group + tests cross-platform
- Iconografia heredada del tipo mayoritario (homogeneo) o slate generico - Threshold via manifest auto_group_threshold propagado a Python - 11 tests pytest nuevos (visual inheritance, threshold override, migration) - gx-cli group visual <id> mirror del SQL - conftest.py endurecido: marker estricto + FN_REGISTRY_ROOT override Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<std::pair<int, std::string>> 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<uint64_t, std::string> 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
|
||||
|
||||
@@ -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<std::string, bool>& 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -48,6 +48,14 @@ struct EnricherSpec {
|
||||
// Parametros editables por el usuario antes de lanzar el job.
|
||||
std::vector<EnricherParam> 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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: <int>`
|
||||
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.
|
||||
|
||||
@@ -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: <type>`. 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)
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+17
-3
@@ -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
|
||||
|
||||
@@ -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 <id>` 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
|
||||
@@ -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: <int>`. 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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user