feat(0036b): NodeGroups admite kind=Group + loader entities

NodeGroupsWindowState gana un discriminador `kind` (Table | Group) y
un flag `focus_request` (lo consumira 0036c). Por defecto Table, asi
que el flujo historico (DuckDB rows tras expand de un nodo Table) no
cambia.

kind=Group lee directamente operations.db consultando
`entities WHERE group_id = container_id` con columnas fijas
(id, name, type_ref, status, updated_at) ordenadas por updated_at DESC.
Los nuevos loaders viven en node_groups.cpp:

  - node_groups_count_for_group  -> SELECT count(*) ...
  - node_groups_page_for_group   -> SELECT id,name,type_ref,status,
                                     updated_at ... LIMIT ? OFFSET ?

Para columnas, opcion (A) del issue: pre-popular meta.columns con la
lista fija al abrir kind=Group, asi el render se mantiene generico.
NodeGroupsRow.values guarda los 5 campos en ese orden y row.id es la
key natural (= entity_id de la fila — al ser ya entidad, no hace falta
promocionarla).

Render en views.cpp ramifica por kind:

  - Table: layout original [id_col + columns + promoted] con doble
    click -> promote/focus.
  - Group: layout [columns fijas] sin promoted. Doble click sobre la
    fila ya pone want_focus_entity = id (los flujos posteriores 0036c-e
    afinan UX). Right click ofrece "Focus in Inspector".

main.cpp dispatcha por kind al refrescar paginas y, al cerrar via X,
solo llama a node_groups_set_expanded para kind=Table (Group no usa
ese flag).

views_node_groups_windows_sync se hace kind-aware: solo reconcilia
entries kind=Table contra el set de Tables expandidas; no toca las
entries kind=Group (las gestiona views_node_groups_open).

Nueva API publica:

  views_node_groups_open(app, container_id, kind, ops_db)

Crea o reusa la entry, setea focus_request=true y para kind=Group
pre-popula meta.columns + intenta leer `name` del Group para el
titulo. Sin caller todavia — la consume 0036c.

Tests:
  - tests/test_node_groups_loader.py (6 tests) verifica el contrato
    SQL via gx-cli. Nuevo subcomando `gx-cli group page <id>` espejea
    el loader C++ exactamente (mismo SQL); tambien expuesto como tool
    MCP `group_page` para que Echo pueda inspeccionar Groups.

Resultado:
  - WSL: 89 -> 95 passed
  - Windows: 78+11 -> 84+11 passed
  - Build C++ Windows limpio, sin warnings nuevos.
  - Regresion kind=Table: comportamiento identico (mismo render,
    mismo loader DuckDB).

Refs: issues/0036b-kind-discriminator-and-group-loader.md
This commit is contained in:
2026-05-04 00:52:25 +02:00
parent 2a783187a3
commit d6e13fddc3
8 changed files with 613 additions and 65 deletions
+110
View File
@@ -0,0 +1,110 @@
"""Tests del loader NodeGroups kind=Group (issue 0036b).
El binario C++ implementa `node_groups_count_for_group` y
`node_groups_page_for_group` sobre operations.db. El subcomando
`gx-cli group page <container_id>` espejea exactamente esa query, asi
estos tests verifican el contrato SQL: count + columnas fijas
(id, name, type_ref, status, updated_at) + ORDER BY updated_at DESC.
Reusa la fixture `env_dirs` del modulo de tests del CLI.
"""
from __future__ import annotations
import sqlite3
import pytest
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, n_children: int):
"""Inserta un Group + n_children entidades hijas con group_id seteado.
Las hijas se crean con updated_at distintos (separados un milisegundo)
para que el ORDER BY updated_at DESC tenga orden estable y verificable.
"""
cn = sqlite3.connect(ops_db)
try:
# El Group contenedor — type_ref='Group' (definicion en 0035a).
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"),
)
# Hijas: Word entities con group_id apuntando al Group. Generamos
# updated_at descendente para que el orden ORDER BY DESC sea
# determinista (la primera insertada queda al final del ranking).
for i in range(n_children):
child_id = f"word_{i:02d}"
ts = f"2026-05-04T11:{i:02d}:00.000Z"
cn.execute(
"INSERT INTO entities(id, name, type_ref, status, source, "
" metadata, group_id, "
" created_at, updated_at) "
"VALUES (?, ?, 'Word', 'active', 'manual', '{}', ?, ?, ?)",
(child_id, f"word-{i}", group_id, ts, ts),
)
# Una entidad sin group_id para verificar que NO aparece en el page.
cn.execute(
"INSERT INTO entities(id, name, type_ref, status, source, "
" metadata, created_at, updated_at) "
"VALUES ('orphan_01', 'orphan', 'Word', 'active', 'manual', '{}', "
" '2026-05-04T09:00:00.000Z', '2026-05-04T09:00:00.000Z')"
)
cn.commit()
finally:
cn.close()
class TestGroupPage:
def test_count_matches_children(self, env_dirs):
_seed_group_with_children(env_dirs["ops"], "grp_alpha", n_children=5)
out = run_gx(env_dirs, "group", "page", "grp_alpha")
assert out["total"] == 5, out
assert len(out["rows"]) == 5
def test_columns_are_fixed_set(self, env_dirs):
_seed_group_with_children(env_dirs["ops"], "grp_alpha", n_children=3)
out = run_gx(env_dirs, "group", "page", "grp_alpha")
assert out["rows"], out
first = out["rows"][0]
assert set(first.keys()) == {"id", "name", "type_ref",
"status", "updated_at"}
def test_orphan_not_listed(self, env_dirs):
_seed_group_with_children(env_dirs["ops"], "grp_alpha", n_children=3)
out = run_gx(env_dirs, "group", "page", "grp_alpha")
ids = {r["id"] for r in out["rows"]}
assert "orphan_01" not in ids
assert ids == {"word_00", "word_01", "word_02"}
def test_order_by_updated_at_desc(self, env_dirs):
_seed_group_with_children(env_dirs["ops"], "grp_alpha", n_children=4)
out = run_gx(env_dirs, "group", "page", "grp_alpha")
ids_in_order = [r["id"] for r in out["rows"]]
# word_03 tiene el updated_at mas reciente (11:03), word_00 el mas
# antiguo (11:00). El ORDER BY ... DESC los pone en ese orden.
assert ids_in_order == ["word_03", "word_02", "word_01", "word_00"]
def test_pagination_offset_limit(self, env_dirs):
_seed_group_with_children(env_dirs["ops"], "grp_alpha", n_children=10)
page1 = run_gx(env_dirs, "group", "page", "grp_alpha",
"--limit", "3", "--offset", "0")
page2 = run_gx(env_dirs, "group", "page", "grp_alpha",
"--limit", "3", "--offset", "3")
assert page1["total"] == 10
assert page2["total"] == 10
ids1 = [r["id"] for r in page1["rows"]]
ids2 = [r["id"] for r in page2["rows"]]
# No deben solaparse y el orden global sigue DESC por updated_at.
assert set(ids1).isdisjoint(set(ids2))
assert ids1 == ["word_09", "word_08", "word_07"]
assert ids2 == ["word_06", "word_05", "word_04"]
def test_unknown_container_returns_empty(self, env_dirs):
# Sin seed — la BD esta vacia.
out = run_gx(env_dirs, "group", "page", "no_such_group")
assert out["total"] == 0
assert out["rows"] == []