"""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")