a76760edba
Funciones reutilizables creadas esta sesion para el sistema self-hosted de contactos/calendario (Xandikos) y la app osint_web: - grupo dav (infra): split_vcards, split_vevents_to_vcalendars, extract_or_make_uid, carddav_put_vcard, caldav_put_event, dav_list_resources, dav_get_resource, dav_list_calendars - pipelines: import_vcf_to_carddav, import_ics_to_caldav - obsidian: build_obsidian_graph (grafo agregado del vault)
217 lines
8.1 KiB
Python
217 lines
8.1 KiB
Python
"""Tests para build_obsidian_graph.
|
|
|
|
Construyen mini-vaults temporales con notas en personas/ y organizaciones/,
|
|
wikilinks entre ellas (incluido uno roto y uno con acentos) y comprueban el
|
|
grafo resultante: numero de nodos/aristas, resolucion de wikilinks acentuados,
|
|
marcado de dangling y robustez ante enlaces rotos.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
|
|
from obsidian.build_obsidian_graph import build_obsidian_graph
|
|
|
|
|
|
def _write(vault: str, rel_path: str, content: str) -> None:
|
|
"""Escribe una nota .md en el mini-vault, creando carpetas si hace falta."""
|
|
full = os.path.join(vault, rel_path)
|
|
os.makedirs(os.path.dirname(full), exist_ok=True)
|
|
with open(full, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
|
|
|
|
def _build_sample_vault(vault: str) -> None:
|
|
"""Crea un vault de prueba con 4 notas reales + 1 wikilink dangling.
|
|
|
|
Grafo esperado (aristas dirigidas):
|
|
ana -> bruno (## Relaciones, kind relacion)
|
|
ana -> maria-del-mar (## Relaciones, kind relacion, acento)
|
|
ana -> acme-sl (## Documentos, kind documento)
|
|
bruno -> persona-fantasma (## Relaciones, NO existe -> dangling)
|
|
acme-sl-> ana (cuerpo suelto, kind wikilink)
|
|
"""
|
|
# .obsidian/ debe ignorarse por completo.
|
|
_write(vault, ".obsidian/app.json", "{}")
|
|
|
|
_write(
|
|
vault,
|
|
"personas/ana.md",
|
|
"---\n"
|
|
"tipo: persona\n"
|
|
"nombre: Ana Gómez\n"
|
|
"---\n\n"
|
|
"## Relaciones\n"
|
|
"- [[bruno]] — amigo\n"
|
|
"- [[María del Mar]] — vecina\n\n"
|
|
"## Documentos\n"
|
|
"- [[organizaciones/acme-sl|Acme SL]]\n",
|
|
)
|
|
_write(
|
|
vault,
|
|
"personas/bruno.md",
|
|
"---\n"
|
|
"tipo: persona\n"
|
|
"nombre: Bruno Ruiz\n"
|
|
"---\n\n"
|
|
"## Relaciones\n"
|
|
"- [[Persona Fantasma]] — desconocido\n",
|
|
)
|
|
# Nota con acento en el nombre de archivo cuyo slug es maria-del-mar.
|
|
_write(
|
|
vault,
|
|
"personas/maria-del-mar.md",
|
|
"---\n"
|
|
"tipo: persona\n"
|
|
"nombre: María del Mar\n"
|
|
"---\n\n"
|
|
"## Notas\n"
|
|
"Sin enlaces.\n",
|
|
)
|
|
_write(
|
|
vault,
|
|
"organizaciones/acme-sl.md",
|
|
"---\n"
|
|
"tipo: organizacion\n"
|
|
"nombre: Acme SL\n"
|
|
"---\n\n"
|
|
"Cliente de [[ana]].\n",
|
|
)
|
|
|
|
|
|
def test_golden_graph_node_and_edge_counts():
|
|
"""Golden: el grafo del mini-vault tiene los nodos y aristas esperados."""
|
|
with tempfile.TemporaryDirectory() as vault:
|
|
_build_sample_vault(vault)
|
|
graph = build_obsidian_graph(vault, include_dangling=True)
|
|
|
|
real = [n for n in graph["nodes"] if not n.get("dangling")]
|
|
dangling = [n for n in graph["nodes"] if n.get("dangling")]
|
|
|
|
# 4 notas reales + 1 nodo fantasma (persona-fantasma).
|
|
assert len(real) == 4, [n["id"] for n in real]
|
|
assert len(dangling) == 1, [n["id"] for n in dangling]
|
|
|
|
# 5 aristas: ana->bruno, ana->maria-del-mar, ana->acme-sl,
|
|
# bruno->persona-fantasma, acme-sl->ana.
|
|
assert len(graph["edges"]) == 5, graph["edges"]
|
|
|
|
ids = {n["id"] for n in real}
|
|
assert ids == {"ana", "bruno", "maria-del-mar", "acme-sl"}, ids
|
|
|
|
# El tipo y el label salen del frontmatter.
|
|
ana = next(n for n in real if n["id"] == "ana")
|
|
assert ana["tipo"] == "persona"
|
|
assert ana["label"] == "Ana Gómez"
|
|
assert ana["frontmatter"]["nombre"] == "Ana Gómez"
|
|
|
|
|
|
def test_edge_resolves_wikilink_with_accents():
|
|
"""Edge: [[María del Mar]] resuelve al nodo slug maria-del-mar."""
|
|
with tempfile.TemporaryDirectory() as vault:
|
|
_build_sample_vault(vault)
|
|
graph = build_obsidian_graph(vault, include_dangling=True)
|
|
|
|
edge = next(
|
|
(e for e in graph["edges"] if e["source"] == "ana" and e["target"] == "maria-del-mar"),
|
|
None,
|
|
)
|
|
assert edge is not None, graph["edges"]
|
|
assert edge["kind"] == "relacion", edge
|
|
|
|
|
|
def test_edge_kind_from_section():
|
|
"""Edge: el kind de la arista se deduce de la seccion ## donde aparece."""
|
|
with tempfile.TemporaryDirectory() as vault:
|
|
_build_sample_vault(vault)
|
|
graph = build_obsidian_graph(vault, include_dangling=True)
|
|
|
|
kinds = {(e["source"], e["target"]): e["kind"] for e in graph["edges"]}
|
|
assert kinds[("ana", "bruno")] == "relacion", kinds
|
|
assert kinds[("ana", "acme-sl")] == "documento", kinds
|
|
# acme-sl -> ana esta fuera de cualquier seccion -> wikilink por defecto.
|
|
assert kinds[("acme-sl", "ana")] == "wikilink", kinds
|
|
|
|
|
|
def test_edge_dangling_marked_and_excluded():
|
|
"""Edge: dangling=True crea nodo fantasma; dangling=False lo descarta."""
|
|
with tempfile.TemporaryDirectory() as vault:
|
|
_build_sample_vault(vault)
|
|
|
|
with_dangling = build_obsidian_graph(vault, include_dangling=True)
|
|
ghost = next(
|
|
(n for n in with_dangling["nodes"] if n["id"] == "persona-fantasma"),
|
|
None,
|
|
)
|
|
assert ghost is not None and ghost.get("dangling") is True, with_dangling["nodes"]
|
|
assert ghost["tipo"] == "desconocido", ghost
|
|
# La arista hacia el fantasma existe.
|
|
assert any(
|
|
e["target"] == "persona-fantasma" for e in with_dangling["edges"]
|
|
), with_dangling["edges"]
|
|
|
|
without_dangling = build_obsidian_graph(vault, include_dangling=False)
|
|
assert all(
|
|
n["id"] != "persona-fantasma" for n in without_dangling["nodes"]
|
|
), without_dangling["nodes"]
|
|
# La arista rota se descarta junto con el nodo fantasma.
|
|
assert all(
|
|
e["target"] != "persona-fantasma" for e in without_dangling["edges"]
|
|
), without_dangling["edges"]
|
|
# Pasamos de 5 a 4 aristas (se elimina bruno->persona-fantasma).
|
|
assert len(without_dangling["edges"]) == 4, without_dangling["edges"]
|
|
|
|
|
|
def test_edge_tipo_falls_back_to_folder():
|
|
"""Edge: sin campo 'tipo' en frontmatter, el tipo sale de la carpeta."""
|
|
with tempfile.TemporaryDirectory() as vault:
|
|
_write(vault, "personas/sin-tipo.md", "---\nnombre: Sin Tipo\n---\n\nCuerpo.\n")
|
|
_write(vault, "organizaciones/org-sin-tipo.md", "Sin frontmatter.\n")
|
|
|
|
graph = build_obsidian_graph(vault)
|
|
by_id = {n["id"]: n for n in graph["nodes"]}
|
|
assert by_id["sin-tipo"]["tipo"] == "persona", by_id["sin-tipo"]
|
|
assert by_id["org-sin-tipo"]["tipo"] == "organizacion", by_id["org-sin-tipo"]
|
|
# Sin frontmatter ni nombre, el label cae al slug.
|
|
assert by_id["org-sin-tipo"]["label"] == "org-sin-tipo", by_id["org-sin-tipo"]
|
|
|
|
|
|
def test_error_path_broken_wikilink_no_crash():
|
|
"""Error path: un wikilink sintacticamente roto no tumba el grafo."""
|
|
with tempfile.TemporaryDirectory() as vault:
|
|
# Wikilink sin cerrar, doble corchete suelto y wikilink vacio.
|
|
_write(
|
|
vault,
|
|
"personas/raro.md",
|
|
"---\ntipo: persona\nnombre: Raro\n---\n\n"
|
|
"Texto con [[ roto y [[ ]] vacio y [[bien]] valido.\n",
|
|
)
|
|
_write(vault, "personas/bien.md", "---\ntipo: persona\nnombre: Bien\n---\n\nOK\n")
|
|
|
|
graph = build_obsidian_graph(vault, include_dangling=True)
|
|
# No crash; el unico enlace valido se resuelve a 'bien'.
|
|
edges = [(e["source"], e["target"]) for e in graph["edges"]]
|
|
assert ("raro", "bien") in edges, edges
|
|
# El wikilink vacio no genera arista ni nodo fantasma vacio.
|
|
assert all(n["id"] for n in graph["nodes"]), graph["nodes"]
|
|
|
|
|
|
def test_error_path_missing_vault_raises():
|
|
"""Error path: un vault inexistente lanza FileNotFoundError, no 500 mudo."""
|
|
raised = False
|
|
try:
|
|
build_obsidian_graph("/no/existe/vault/osint")
|
|
except FileNotFoundError:
|
|
raised = True
|
|
assert raised, "build_obsidian_graph deberia lanzar FileNotFoundError"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
test_golden_graph_node_and_edge_counts()
|
|
test_edge_resolves_wikilink_with_accents()
|
|
test_edge_kind_from_section()
|
|
test_edge_dangling_marked_and_excluded()
|
|
test_edge_tipo_falls_back_to_folder()
|
|
test_error_path_broken_wikilink_no_crash()
|
|
test_error_path_missing_vault_raises()
|
|
print("build_obsidian_graph tests OK")
|