diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/fixtures.py | 63 | ||||
| -rw-r--r-- | tests/test_cli.py | 131 | ||||
| -rw-r--r-- | tests/test_memory.py | 231 | ||||
| -rw-r--r-- | tests/test_terminal.py | 76 | ||||
| -rw-r--r-- | tests/test_webpage.py | 20 |
5 files changed, 512 insertions, 9 deletions
diff --git a/tests/fixtures.py b/tests/fixtures.py index 9b55c4b..52d5447 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -68,3 +68,66 @@ def simple_tree(root): user("hola", at=ts(0, hour=8), cwd="/home/u/otro"), ]) return root + + +# ──────────────────────────────── memorias ──────────────────────────────── + +def write_memory(root, project_dir, name, body="cuerpo", desc=None, + kind="project", origin=None, frontmatter=True): + """Escribe <proyecto>/memory/<name>.md y devuelve su ruta.""" + d = os.path.join(root, project_dir, "memory") + os.makedirs(d, exist_ok=True) + path = os.path.join(d, name + ".md") + + partes = [] + if frontmatter: + campos = ["---", f"name: {name}"] + if desc is not None: + campos.append(f"description: {desc}") + campos += ["metadata:", " node_type: memory", f" type: {kind}"] + if origin: + campos.append(f" originSessionId: {origin}") + campos.append("---") + partes.append("\n".join(campos)) + partes.append(body) + + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(partes) + "\n") + return path + + +def write_index(root, project_dir, names, extra=()): + """Escribe el MEMORY.md que enlaza esos nombres.""" + d = os.path.join(root, project_dir, "memory") + os.makedirs(d, exist_ok=True) + lines = ["# Memory Index", ""] + for n in list(names) + list(extra): + lines.append(f"- [{n}]({n}.md) — pista de {n}") + path = os.path.join(d, "MEMORY.md") + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + return path + + +def memory_tree(root): + """Memorias variadas: indexada, sin listar, y un proyecto sin índice.""" + write_memory(root, "-home-u-proj", "deploy-docker", + body="Se despliega con `make up`.\nVer [[roles-db]] y [[no-existe]].", + desc="Cómo se despliega el proyecto", + origin="aaaaaaaa-0000-0000-0000-000000000001") + write_memory(root, "-home-u-proj", "roles-db", body="Roles de la base.", + desc="Roles", kind="reference") + write_memory(root, "-home-u-proj", "suelta", body="No está en el índice.", + desc="Huérfana") + # El índice lista dos reales y una que ya no existe. + write_index(root, "-home-u-proj", ["deploy-docker", "roles-db"], + extra=["borrada-hace-rato"]) + + # Otro proyecto con memoria pero sin MEMORY.md. + write_memory(root, "-home-u-otro", "sin-indice", body="Nadie me indexa.", + desc="Sin índice", kind="user", + origin="ffffffff-0000-0000-0000-00000000000f") + + # Un memory/ vacío no cuenta como proyecto con memoria. + os.makedirs(os.path.join(root, "-home-u-vacio", "memory"), exist_ok=True) + return root diff --git a/tests/test_cli.py b/tests/test_cli.py index 91be284..94aeac1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,7 +9,9 @@ from contextlib import redirect_stderr, redirect_stdout from claude_sesiones import cli -from .fixtures import simple_tree, ts, user, write_session +from .fixtures import ( + memory_tree, simple_tree, ts, user, write_memory, write_session, +) PAYLOAD_RE = re.compile( r'<script id="payload" type="application/json">(.*?)</script>', re.S) @@ -82,8 +84,9 @@ class TestExportar(CliCase): code, out, _ = self.run_cli("--json") data = json.loads(out) self.assertEqual(code, 0) - self.assertEqual(len(data), 3) - self.assertNotIn("project_dir", data[0]) + self.assertEqual(len(data["s"]), 3) + self.assertEqual(data["m"], []) + self.assertNotIn("project_dir", data["s"][0]) def test_html(self): simple_tree(self.root) @@ -93,7 +96,9 @@ class TestExportar(CliCase): self.assertIn("3 sesiones", err) with open(out_path, encoding="utf-8") as f: html = f.read() - self.assertEqual(len(json.loads(PAYLOAD_RE.findall(html)[0])), 3) + payload = json.loads(PAYLOAD_RE.findall(html)[0]) + self.assertEqual(len(payload["s"]), 3) + self.assertEqual(payload["m"], []) def test_html_no_toca_stdout(self): # El resumen va a stderr para que `--html /dev/stdout` siga sirviendo. @@ -207,5 +212,123 @@ class TestParser(unittest.TestCase): self.assertEqual(args.query, ["dos", "palabras"]) +class TestMemoria(CliCase): + def test_tabla(self): + simple_tree(self.root) + memory_tree(self.root) + code, out, _ = self.run_cli("-m") + self.assertEqual(code, 0) + self.assertIn("deploy-docker", out) + self.assertIn("4 memorias", out) + + def test_sin_memorias_avisa(self): + simple_tree(self.root) + code, _, err = self.run_cli("-m") + self.assertEqual(code, 1) + self.assertIn("memorias", err) + + def test_filtra_por_tipo(self): + simple_tree(self.root) + memory_tree(self.root) + code, out, _ = self.run_cli("-m", "--type", "reference") + self.assertEqual(code, 0) + self.assertIn("roles-db", out) + self.assertNotIn("deploy-docker", out) + + def test_la_query_busca_en_el_cuerpo(self): + simple_tree(self.root) + memory_tree(self.root) + code, out, _ = self.run_cli("-m", "make up") + self.assertEqual(code, 0) + self.assertIn("deploy-docker", out) + self.assertNotIn("roles-db", out) + + def test_show_por_nombre(self): + simple_tree(self.root) + memory_tree(self.root) + code, out, _ = self.run_cli("-m", "-s", "deploy", "--no-pager") + self.assertEqual(code, 0) + self.assertIn("Se despliega con", out) + self.assertIn("deploy-docker", out) + + def test_show_avisa_si_no_esta_indexada(self): + simple_tree(self.root) + memory_tree(self.root) + _, out, _ = self.run_cli("-m", "-s", "suelta", "--no-pager") + self.assertIn("MEMORY.md", out) + + def test_check_lista_los_problemas(self): + simple_tree(self.root) + memory_tree(self.root) + code, out, _ = self.run_cli("-m", "--check") + self.assertEqual(code, 1) # hay cosas para mirar + self.assertIn("sin MEMORY.md", out) + self.assertIn("no-existe", out) + + def test_check_limpio_sale_cero(self): + simple_tree(self.root) + write_memory(self.root, "-home-u-proj", "sola", body="sin enlaces") + from .fixtures import write_index + write_index(self.root, "-home-u-proj", ["sola"]) + code, out, _ = self.run_cli("-m", "--check") + self.assertEqual(code, 0) + self.assertIn("Todo en orden", out) + + def test_borrado_en_seco_no_toca_nada(self): + simple_tree(self.root) + memory_tree(self.root) + path = os.path.join(self.root, "-home-u-proj", "memory", + "deploy-docker.md") + code, out, _ = self.run_cli("-m", "-D", "deploy", "--dry-run") + self.assertEqual(code, 0) + self.assertIn("no se tocó nada", out) + self.assertTrue(os.path.exists(path)) + + def test_borra_y_desindexa(self): + simple_tree(self.root) + memory_tree(self.root) + path = os.path.join(self.root, "-home-u-proj", "memory", + "deploy-docker.md") + code, out, _ = self.run_cli("-m", "-D", "deploy", "-y") + self.assertEqual(code, 0) + self.assertFalse(os.path.exists(path)) + with open(os.path.join(self.root, "-home-u-proj", "memory", + "MEMORY.md"), encoding="utf-8") as f: + index = f.read() + self.assertNotIn("deploy-docker.md", index) + self.assertIn("roles-db.md", index) + self.assertIn("sacadas del índice", out) + + def test_borrado_sin_confirmar_cancela(self): + simple_tree(self.root) + memory_tree(self.root) + path = os.path.join(self.root, "-home-u-proj", "memory", + "deploy-docker.md") + with unittest.mock.patch.object(cli, "confirm", return_value=False): + code, _, err = self.run_cli("-m", "-D", "deploy") + self.assertEqual(code, 1) + self.assertIn("Cancelado", err) + self.assertTrue(os.path.exists(path)) + + def test_referencia_inexistente(self): + simple_tree(self.root) + memory_tree(self.root) + code, _, err = self.run_cli("-m", "-s", "no-existe-nada") + self.assertEqual(code, 2) + self.assertIn("ninguna memoria", err) + + def test_html_embebe_las_memorias(self): + simple_tree(self.root) + memory_tree(self.root) + out_path = os.path.join(self.home, "s.html") + code, _, err = self.run_cli("--html", out_path) + self.assertEqual(code, 0) + self.assertIn("4 memorias", err) + with open(out_path, encoding="utf-8") as f: + payload = json.loads(PAYLOAD_RE.findall(f.read())[0]) + self.assertEqual(len(payload["m"]), 4) + self.assertIn("deploy-docker", {m["name"] for m in payload["m"]}) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..4076718 --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,231 @@ +import os +import tempfile +import unittest + +from claude_sesiones import memory, sessions + +from .fixtures import ( + memory_tree, simple_tree, write_index, write_memory, +) + + +class MemoryCase(unittest.TestCase): + """Cada test corre contra un ~/.claude/projects de mentira.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.root = os.path.join(self._tmp.name, "projects") + os.makedirs(self.root) + + def cargar(self): + """Sesiones + memorias del árbol, como las ve la CLI.""" + ss = sessions.load_sessions(root=self.root, use_cache=False) + return ss, memory.load_memories(ss, root=self.root) + + +class TestParseo(MemoryCase): + def test_lee_frontmatter_y_cuerpo(self): + path = write_memory(self.root, "-home-u-proj", "una", + body="el cuerpo", desc="qué es", kind="feedback", + origin="abc123") + m = memory.read_memory(path, "-home-u-proj") + self.assertEqual(m["name"], "una") + self.assertEqual(m["desc"], "qué es") + self.assertEqual(m["ty"], "feedback") + self.assertEqual(m["src"], "abc123") + self.assertEqual(m["body"], "el cuerpo") + + def test_descripcion_entrecomillada_pierde_los_escapes(self): + # Claude escribe la descripción como string YAML cuando trae comillas. + path = write_memory(self.root, "-home-u-proj", "q", + desc=r'"la máquina \"legion\" y algo"') + self.assertEqual(memory.read_memory(path, "-home-u-proj")["desc"], + 'la máquina "legion" y algo') + + def test_sin_frontmatter_cae_al_nombre_del_archivo(self): + path = write_memory(self.root, "-home-u-proj", "pelada", + body="solo texto", frontmatter=False) + m = memory.read_memory(path, "-home-u-proj") + self.assertEqual(m["name"], "pelada") + self.assertEqual(m["ty"], "—") + self.assertEqual(m["body"], "solo texto") + + def test_junta_los_enlaces_sin_repetir(self): + path = write_memory(self.root, "-home-u-proj", "l", + body="[[uno]] y [[dos]] y otra vez [[uno]]") + self.assertEqual(memory.read_memory(path, "-home-u-proj")["ln"], + ["dos", "uno"]) + + +class TestCarga(MemoryCase): + def test_resuelve_la_ruta_del_proyecto_desde_las_sesiones(self): + simple_tree(self.root) + memory_tree(self.root) + _, mems = self.cargar() + deploy = next(m for m in mems if m["name"] == "deploy-docker") + self.assertEqual(deploy["p"], "/home/u/proj") + + def test_sin_sesiones_deja_el_nombre_codificado(self): + # No se puede invertir: "/" y "." se codifican los dos como "-". + memory_tree(self.root) + _, mems = self.cargar() + self.assertEqual( + next(m for m in mems if m["name"] == "deploy-docker")["p"], + "-home-u-proj") + + def test_ignora_los_directorios_memory_vacios(self): + memory_tree(self.root) + _, mems = self.cargar() + self.assertNotIn("-home-u-vacio", {m["project_dir"] for m in mems}) + + def test_marca_lo_que_esta_en_el_indice(self): + memory_tree(self.root) + _, mems = self.cargar() + por_nombre = {m["name"]: m for m in mems} + self.assertTrue(por_nombre["deploy-docker"]["ix"]) + self.assertFalse(por_nombre["suelta"]["ix"]) + self.assertTrue(por_nombre["suelta"]["hix"]) + self.assertFalse(por_nombre["sin-indice"]["hix"]) + + def test_ordena_por_fecha_descendente(self): + memory_tree(self.root) + _, mems = self.cargar() + fechas = [m["l"] for m in mems] + self.assertEqual(fechas, sorted(fechas, reverse=True)) + + def test_public_records_saca_las_claves_internas(self): + memory_tree(self.root) + _, mems = self.cargar() + for m in memory.public_records(mems): + self.assertNotIn("project_dir", m) + # El original no se toca. + self.assertIn("project_dir", mems[0]) + + +class TestFiltros(MemoryCase): + def setUp(self): + super().setUp() + simple_tree(self.root) + memory_tree(self.root) + _, self.mems = self.cargar() + + def test_por_tipo(self): + r = memory.apply_filters(self.mems, kind="reference") + self.assertEqual([m["name"] for m in r], ["roles-db"]) + + def test_por_proyecto(self): + r = memory.apply_filters(self.mems, project="/home/u/proj") + self.assertNotIn("sin-indice", [m["name"] for m in r]) + + def test_la_query_entra_al_cuerpo(self): + r = memory.apply_filters(self.mems, query="make up") + self.assertEqual([m["name"] for m in r], ["deploy-docker"]) + + def test_la_query_tambien_mira_la_descripcion(self): + r = memory.apply_filters(self.mems, query="huérfana") + self.assertEqual([m["name"] for m in r], ["suelta"]) + + +class TestPick(MemoryCase): + def setUp(self): + super().setUp() + memory_tree(self.root) + _, self.mems = self.cargar() + + def test_por_indice(self): + self.assertEqual(memory.pick(self.mems, "1"), self.mems[0]) + + def test_indice_fuera_de_rango(self): + with self.assertRaises(sessions.SessionError): + memory.pick(self.mems, "99") + + def test_por_prefijo(self): + self.assertEqual(memory.pick(self.mems, "deploy")["name"], "deploy-docker") + + def test_cae_a_subcadena(self): + self.assertEqual(memory.pick(self.mems, "docker")["name"], "deploy-docker") + + def test_sin_coincidencias(self): + with self.assertRaises(sessions.SessionError): + memory.pick(self.mems, "nada-que-ver") + + def test_ambiguo(self): + write_memory(self.root, "-home-u-proj", "deploy-otro") + _, mems = self.cargar() + with self.assertRaises(sessions.SessionError) as ctx: + memory.pick(mems, "deploy") + self.assertIn("ambiguo", str(ctx.exception)) + + +class TestAuditoria(MemoryCase): + def setUp(self): + super().setUp() + simple_tree(self.root) + memory_tree(self.root) + self.ss, self.mems = self.cargar() + self.report = memory.audit(self.mems, self.ss, root=self.root) + + def test_proyecto_sin_indice(self): + self.assertEqual([m["name"] for m in self.report["sin_indice"]], + ["sin-indice"]) + + def test_memoria_fuera_del_indice(self): + self.assertEqual([m["name"] for m in self.report["sin_listar"]], + ["suelta"]) + + def test_entrada_del_indice_sin_archivo(self): + self.assertEqual([n for _, n in self.report["indice_fantasma"]], + ["borrada-hace-rato"]) + + def test_enlace_roto(self): + rotos = [link for _, link in self.report["enlaces_rotos"]] + self.assertEqual(rotos, ["no-existe"]) # [[roles-db]] sí resuelve + + def test_sesion_de_origen_perdida(self): + # deploy-docker apunta a una sesión que existe; sin-indice no. + self.assertEqual([m["name"] for m in self.report["origen_perdido"]], + ["sin-indice"]) + + def test_arbol_consistente_no_reporta_nada(self): + limpio = os.path.join(self._tmp.name, "limpio") + os.makedirs(limpio) + write_memory(limpio, "-p", "sola", body="sin enlaces") + write_index(limpio, "-p", ["sola"]) + mems = memory.load_memories([], root=limpio) + self.assertEqual(memory.audit_total(memory.audit(mems, [], root=limpio)), 0) + + +class TestBorrado(MemoryCase): + def setUp(self): + super().setUp() + memory_tree(self.root) + _, self.mems = self.cargar() + + def por_nombre(self, name): + return next(m for m in self.mems if m["name"] == name) + + def test_borra_el_archivo_y_lo_desindexa(self): + m = self.por_nombre("deploy-docker") + self.assertTrue(memory.delete(m, root=self.root)) + self.assertFalse(os.path.exists(memory.memory_path(m, self.root))) + self.assertNotIn("deploy-docker", + memory.read_index("-home-u-proj", self.root)) + + def test_desindexar_no_toca_las_otras_lineas(self): + memory.unindex(self.por_nombre("deploy-docker"), root=self.root) + self.assertIn("roles-db", memory.read_index("-home-u-proj", self.root)) + + def test_borrar_una_que_no_estaba_indexada(self): + m = self.por_nombre("suelta") + self.assertFalse(memory.delete(m, root=self.root)) + self.assertFalse(os.path.exists(memory.memory_path(m, self.root))) + + def test_borrar_sin_memory_md(self): + m = self.por_nombre("sin-indice") + self.assertFalse(memory.delete(m, root=self.root)) + self.assertFalse(os.path.exists(memory.memory_path(m, self.root))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_terminal.py b/tests/test_terminal.py index fdb9fc8..9f57935 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -138,5 +138,81 @@ class TestSalida(unittest.TestCase): "cd /a b && claude --resume xyz") +class TestMemorias(unittest.TestCase): + def memoria(self, **kw): + m = { + "name": "deploy-docker", "file": "deploy-docker.md", + "p": "/home/u/proj", "desc": "Cómo se despliega", "ty": "project", + "src": "abcdef01-2345-6789-abcd-ef0123456789", + "body": "Se despliega con `make up`.", "ln": ["roles-db"], + "k": 1.2, "l": ago(1), "ix": True, "hix": True, + } + m.update(kw) + return m + + def test_tabla_sin_color_no_tiene_ansi(self): + buf = io.StringIO() + T.print_memories([self.memoria()], T.Style(False), NOW, buf, width=120) + out = buf.getvalue() + self.assertNotIn("\x1b", out) + self.assertEqual(len(out.strip().split("\n")), 2) + self.assertIn("deploy-docker", out) + self.assertIn("project", out) + self.assertIn("Cómo se despliega", out) + + def test_tabla_angosta_esconde_columnas(self): + buf = io.StringIO() + T.print_memories([self.memoria()], T.Style(False), NOW, buf, width=70) + out = buf.getvalue() + self.assertIn("deploy-docker", out) + self.assertNotIn("Cómo se despliega", out) + + def test_marca_las_que_no_estan_en_el_indice(self): + buf = io.StringIO() + T.print_memories([self.memoria(ix=False)], T.Style(False), NOW, buf, + width=120) + self.assertIn("*", buf.getvalue()) + + def test_lectura_trae_cuerpo_enlaces_y_origen(self): + buf = io.StringIO() + T.print_memory(self.memoria(), T.Style(False), buf, path="/x/y.md") + out = buf.getvalue() + self.assertIn("Se despliega con", out) + self.assertIn("roles-db", out) + self.assertIn("abcdef01", out) + self.assertIn("/x/y.md", out) + + def test_lectura_avisa_si_no_esta_indexada(self): + buf = io.StringIO() + T.print_memory(self.memoria(ix=False), T.Style(False), buf) + self.assertIn("no figura en MEMORY.md", buf.getvalue()) + + def test_lectura_avisa_si_el_proyecto_no_tiene_indice(self): + buf = io.StringIO() + T.print_memory(self.memoria(ix=False, hix=False), T.Style(False), buf) + self.assertIn("no tiene MEMORY.md", buf.getvalue()) + + def test_auditoria_vacia_no_imprime_nada(self): + buf = io.StringIO() + total = T.print_audit({}, T.Style(False), buf) + self.assertEqual(total, 0) + self.assertEqual(buf.getvalue(), "") + + def test_auditoria_cuenta_todos_los_bloques(self): + buf = io.StringIO() + m = self.memoria() + total = T.print_audit({ + "sin_indice": [m], + "sin_listar": [m], + "indice_fantasma": [("-p", "fantasma")], + "enlaces_rotos": [(m, "roto")], + "origen_perdido": [m], + }, T.Style(False), buf) + out = buf.getvalue() + self.assertEqual(total, 5) + self.assertIn("fantasma", out) + self.assertIn("[[roto]]", out) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_webpage.py b/tests/test_webpage.py index e738458..b768073 100644 --- a/tests/test_webpage.py +++ b/tests/test_webpage.py @@ -14,12 +14,20 @@ REGISTRO = {"id": "abc", "p": "/proj", "u": 1, "c": [{"r": "u", "x": "hola"}]} class TestPayload(unittest.TestCase): def test_escapa_el_cierre_de_etiqueta(self): - raw = webpage.encode_payload([{"x": "mirá este </script> de acá"}]) + raw = webpage.encode_payload({"s": [{"x": "mirá este </script> de acá"}]}) self.assertNotIn("</", raw) - self.assertEqual(json.loads(raw)[0]["x"], "mirá este </script> de acá") + self.assertEqual(json.loads(raw)["s"][0]["x"], "mirá este </script> de acá") def test_no_escapa_a_ascii(self): - self.assertIn("ñ", webpage.encode_payload([{"x": "año"}])) + self.assertIn("ñ", webpage.encode_payload({"s": [{"x": "año"}]})) + + def test_separa_sesiones_de_memorias(self): + payload = webpage.build_payload([REGISTRO], [{"name": "algo"}]) + self.assertEqual(payload["s"], [REGISTRO]) + self.assertEqual(payload["m"], [{"name": "algo"}]) + + def test_sin_memorias_igual_trae_la_clave(self): + self.assertEqual(webpage.build_payload([REGISTRO])["m"], []) class TestRender(unittest.TestCase): @@ -45,7 +53,8 @@ class TestRender(unittest.TestCase): bloques = PAYLOAD_RE.findall(html) self.assertEqual(len(bloques), 1) vuelta = json.loads(bloques[0]) - self.assertEqual(vuelta[0]["c"][0]["x"], "poné </script><img> y __DATA__") + self.assertEqual(vuelta["s"][0]["c"][0]["x"], + "poné </script><img> y __DATA__") class TestTemplate(unittest.TestCase): @@ -83,7 +92,8 @@ class TestWrite(unittest.TestCase): stats = webpage.write( [REGISTRO, dict(REGISTRO, id="def", p="/otro", u=2)], out) self.assertEqual(stats, {"sesiones": 2, "proyectos": 2, - "mensajes": 3, "bloques": 2}) + "mensajes": 3, "bloques": 2, + "memorias": 0}) with open(out, encoding="utf-8") as f: self.assertEqual(len(PAYLOAD_RE.findall(f.read())), 1) |