diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/__init__.py | 0 | ||||
| -rw-r--r-- | tests/fixtures.py | 70 | ||||
| -rw-r--r-- | tests/test_cli.py | 211 | ||||
| -rw-r--r-- | tests/test_sessions.py | 307 | ||||
| -rw-r--r-- | tests/test_terminal.py | 142 | ||||
| -rw-r--r-- | tests/test_webpage.py | 92 |
6 files changed, 822 insertions, 0 deletions
diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/__init__.py diff --git a/tests/fixtures.py b/tests/fixtures.py new file mode 100644 index 0000000..9b55c4b --- /dev/null +++ b/tests/fixtures.py @@ -0,0 +1,70 @@ +"""Sesiones .jsonl de mentira para los tests.""" + +import json +import os + +BASE_TS = "2025-08-14T10:00:00.000Z" + + +def ts(minute=0, hour=10, day=14): + return f"2025-08-{day:02d}T{hour:02d}:{minute:02d}:00.000Z" + + +def user(text, at=BASE_TS, **extra): + ev = { + "type": "user", + "timestamp": at, + "cwd": "/home/u/proj", + "gitBranch": "main", + "version": "1.0.0", + "message": {"role": "user", "content": [{"type": "text", "text": text}]}, + } + ev.update(extra) + return ev + + +def assistant(text=None, tools=(), at=BASE_TS, **extra): + content = [] + if text is not None: + content.append({"type": "text", "text": text}) + for name, args in tools: + content.append({"type": "tool_use", "name": name, "input": args}) + ev = { + "type": "assistant", + "timestamp": at, + "message": {"role": "assistant", "content": content}, + } + ev.update(extra) + return ev + + +def ai_title(title, at=BASE_TS): + return {"type": "ai-title", "aiTitle": title, "timestamp": at} + + +def write_session(root, project_dir, session_id, events): + """Escribe un .jsonl y devuelve su ruta.""" + d = os.path.join(root, project_dir) + os.makedirs(d, exist_ok=True) + path = os.path.join(d, session_id + ".jsonl") + with open(path, "w", encoding="utf-8") as f: + for ev in events: + f.write(json.dumps(ev, ensure_ascii=False) + "\n") + return path + + +def simple_tree(root): + """Un árbol chico y variado: una charla, una vacía y una sin cwd.""" + write_session(root, "-home-u-proj", "aaaaaaaa-0000-0000-0000-000000000001", [ + ai_title("Arreglar el build"), + user("¿por qué falla el build?", at=ts(0)), + assistant("Miro el log.", tools=[("Bash", {"command": "make"})], at=ts(3)), + user("gracias", at=ts(12)), + ]) + write_session(root, "-home-u-proj", "bbbbbbbb-0000-0000-0000-000000000002", [ + {"type": "system", "timestamp": ts(0, hour=9), "cwd": "/home/u/proj"}, + ]) + write_session(root, "-home-u-otro", "cccccccc-0000-0000-0000-000000000003", [ + user("hola", at=ts(0, hour=8), cwd="/home/u/otro"), + ]) + return root diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..91be284 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,211 @@ +import io +import json +import os +import re +import tempfile +import unittest +import unittest.mock +from contextlib import redirect_stderr, redirect_stdout + +from claude_sesiones import cli + +from .fixtures import simple_tree, ts, user, write_session + +PAYLOAD_RE = re.compile( + r'<script id="payload" type="application/json">(.*?)</script>', re.S) + + +class CliCase(unittest.TestCase): + """Cada test corre contra un ~/.claude y un caché de mentira.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.home = self._tmp.name + self.root = os.path.join(self.home, ".claude", "projects") + os.makedirs(self.root) + env = unittest.mock.patch.dict(os.environ, { + "CLAUDE_CONFIG_DIR": os.path.join(self.home, ".claude"), + "XDG_CACHE_HOME": os.path.join(self.home, "cache"), + "NO_COLOR": "1", + }) + env.start() + self.addCleanup(env.stop) + + def run_cli(self, *argv): + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = cli.main(list(argv)) + return code, out.getvalue(), err.getvalue() + + +class TestTabla(CliCase): + def test_lista_las_sesiones(self): + simple_tree(self.root) + code, out, _ = self.run_cli() + self.assertEqual(code, 0) + self.assertIn("Arreglar el build", out) + self.assertIn("3 sesiones · 2 proyectos", out) + + def test_filtra_por_texto(self): + simple_tree(self.root) + code, out, _ = self.run_cli("arreglar") + self.assertEqual(code, 0) + self.assertIn("1 de 3 sesiones", out) + + def test_limita_la_cantidad(self): + simple_tree(self.root) + _, out, _ = self.run_cli("-n", "1") + self.assertIn("1 de 3 sesiones", out) + + def test_un_filtro_sin_resultados_sale_con_1(self): + simple_tree(self.root) + code, _, err = self.run_cli("no-existe-esto") + self.assertEqual(code, 1) + self.assertIn("Ninguna sesión coincide", err) + + def test_sin_directorio_de_claude_sale_con_2(self): + with unittest.mock.patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "/no/existe"}): + code, _, err = self.run_cli() + self.assertEqual(code, 2) + self.assertIn("no existe", err) + + def test_sin_ninguna_sesion_sale_con_1(self): + code, _, err = self.run_cli() + self.assertEqual(code, 1) + self.assertIn("No hay ninguna sesión", err) + + +class TestExportar(CliCase): + def test_json(self): + simple_tree(self.root) + 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]) + + def test_html(self): + simple_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("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) + + def test_html_no_toca_stdout(self): + # El resumen va a stderr para que `--html /dev/stdout` siga sirviendo. + simple_tree(self.root) + _, out, _ = self.run_cli("--html", os.path.join(self.home, "s.html")) + self.assertEqual(out, "") + + +class TestLectura(CliCase): + def test_show_por_indice(self): + simple_tree(self.root) + code, out, _ = self.run_cli("-s", "1", "--no-pager") + self.assertEqual(code, 0) + self.assertIn("¿por qué falla el build?", out) + + def test_show_por_prefijo_de_uuid(self): + simple_tree(self.root) + _, out, _ = self.run_cli("-s", "cccccccc", "--no-pager") + self.assertIn("hola", out) + + def test_show_respeta_el_filtro_previo(self): + simple_tree(self.root) + _, out, _ = self.run_cli("-p", "/home/u/otro", "-s", "1", "--no-pager") + self.assertIn("hola", out) + + def test_una_referencia_que_no_existe_sale_con_2(self): + simple_tree(self.root) + code, _, err = self.run_cli("-s", "99") + self.assertEqual(code, 2) + self.assertIn("fuera de rango", err) + + def test_resume_imprime_el_comando(self): + simple_tree(self.root) + code, out, _ = self.run_cli("-r", "1") + self.assertEqual(code, 0) + self.assertEqual(out.strip(), + "cd /home/u/proj && claude --resume " + "aaaaaaaa-0000-0000-0000-000000000001") + + +class TestBorrado(CliCase): + def paths(self): + return sorted(os.listdir(os.path.join(self.root, "-home-u-proj"))) + + def test_dry_run_no_toca_nada(self): + simple_tree(self.root) + antes = self.paths() + code, out, _ = self.run_cli("--delete-empty", "--dry-run") + self.assertEqual(code, 0) + self.assertIn("no se tocó nada", out) + self.assertEqual(self.paths(), antes) + + def test_borra_las_vacias(self): + simple_tree(self.root) + code, out, _ = self.run_cli("--delete-empty", "-y") + self.assertEqual(code, 0) + self.assertIn("1 sesión borrada", out) + self.assertEqual(self.paths(), + ["aaaaaaaa-0000-0000-0000-000000000001.jsonl"]) + + def test_borra_una_puntual_por_prefijo(self): + simple_tree(self.root) + code, _, _ = self.run_cli("-D", "aaaaaaaa", "-y") + self.assertEqual(code, 0) + self.assertEqual(self.paths(), + ["bbbbbbbb-0000-0000-0000-000000000002.jsonl"]) + + def test_no_repite_si_la_pediste_dos_veces(self): + simple_tree(self.root) + code, out, _ = self.run_cli("-D", "aaaaaaaa", "1", "-y") + self.assertEqual(code, 0) + self.assertIn("1 sesión borrada", out) + + def test_la_borrada_no_vuelve_desde_el_cache(self): + simple_tree(self.root) + self.run_cli() # llena el caché + self.run_cli("--delete-empty", "-y") + _, out, _ = self.run_cli() + self.assertIn("2 sesiones", out) + self.assertNotIn("bbbbbbbb", out) + + def test_el_filtro_acota_lo_que_se_borra(self): + write_session(self.root, "-home-u-otro", "ffffffff-0000-0000-0000-000000000006", + [{"type": "system", "timestamp": ts(0)}]) + simple_tree(self.root) + code, out, _ = self.run_cli("-p", "/home/u/proj", "--delete-empty", "-y") + self.assertEqual(code, 0) + self.assertIn("1 sesión borrada", out) + self.assertTrue(os.path.exists(os.path.join( + self.root, "-home-u-otro", "ffffffff-0000-0000-0000-000000000006.jsonl"))) + + def test_sin_nada_para_borrar_avisa(self): + write_session(self.root, "-p", "aaaaaaaa-0000-0000-0000-000000000001", + [user("hola", at=ts(0))]) + code, _, err = self.run_cli("--delete-empty", "-y") + self.assertEqual(code, 0) + self.assertIn("No hay sesiones que borrar", err) + + +class TestParser(unittest.TestCase): + def test_html_sin_valor_usa_el_nombre_por_defecto(self): + args = cli.build_parser().parse_args(["--html"]) + self.assertEqual(args.html, cli.DEFAULT_HTML) + + def test_html_con_valor(self): + self.assertEqual(cli.build_parser().parse_args(["--html", "x.html"]).html, + "x.html") + + def test_la_query_junta_las_palabras(self): + args = cli.build_parser().parse_args(["dos", "palabras"]) + self.assertEqual(args.query, ["dos", "palabras"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sessions.py b/tests/test_sessions.py new file mode 100644 index 0000000..9522639 --- /dev/null +++ b/tests/test_sessions.py @@ -0,0 +1,307 @@ +import json +import os +import tempfile +import unittest +import unittest.mock + +from claude_sesiones import sessions as S + +from .fixtures import ai_title, assistant, simple_tree, ts, user, write_session + + +class TempRoot(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = os.path.join(self._tmp.name, "projects") + os.makedirs(self.root) + self.cache = os.path.join(self._tmp.name, "cache.json") + self.addCleanup(self._tmp.cleanup) + + def load(self, **kw): + kw.setdefault("cache_path", self.cache) + return S.load_sessions(root=self.root, **kw) + + +class TestReadSession(TempRoot): + def test_conversacion_basica(self): + path = write_session(self.root, "-p", "11111111-1111-1111-1111-111111111111", [ + user("¿por qué falla?", at=ts(0)), + assistant("Miro.", tools=[("Bash", {"command": "make test"})], at=ts(5)), + ]) + rec = S.read_session(path) + + self.assertEqual(rec["u"], 1) + self.assertEqual(rec["a"], 1) + self.assertEqual(rec["d"], 5) + self.assertEqual(rec["p"], "/home/u/proj") + self.assertEqual(rec["b"], "main") + self.assertFalse(rec["e"]) + self.assertFalse(rec["ai"]) + self.assertEqual(rec["t"], "¿por qué falla?") + self.assertEqual([m["r"] for m in rec["c"]], ["u", "a", "t"]) + self.assertEqual(rec["c"][2]["x"], "Bash: make test") + + def test_el_titulo_de_claude_le_gana_al_primer_mensaje(self): + path = write_session(self.root, "-p", "22222222-0000-0000-0000-000000000000", [ + user("arreglá esto", at=ts(0)), + ai_title("Primer intento"), + ai_title("Título final"), + ]) + rec = S.read_session(path) + self.assertEqual(rec["t"], "Título final") + self.assertTrue(rec["ai"]) + + def test_ignora_el_ruido_del_harness(self): + path = write_session(self.root, "-p", "33333333-0000-0000-0000-000000000000", [ + user("<command-name>/clear</command-name>", at=ts(0)), + user("<system-reminder>ojo</system-reminder>", at=ts(1)), + user("texto real <system-reminder>ojo</system-reminder>", at=ts(2)), + user("meta", at=ts(2), isMeta=True), + user("de un subagente", at=ts(3), isSidechain=True), + assistant("respuesta de subagente", at=ts(4), isSidechain=True), + ]) + rec = S.read_session(path) + self.assertEqual(rec["u"], 1) + self.assertEqual(rec["a"], 0) + self.assertEqual(rec["c"][0]["x"], "texto real") + + def test_tolera_una_linea_cortada_a_la_mitad(self): + path = write_session(self.root, "-p", "44444444-0000-0000-0000-000000000000", [ + user("primero", at=ts(0)), + ]) + with open(path, "a", encoding="utf-8") as f: + f.write('{"type": "user", "message": {"content": [{"type": "te') + rec = S.read_session(path) + self.assertEqual(rec["u"], 1) + + def test_sesion_sin_mensajes(self): + path = write_session(self.root, "-p", "55555555-0000-0000-0000-000000000000", [ + {"type": "system", "timestamp": ts(0), "cwd": "/home/u/proj"}, + ]) + rec = S.read_session(path) + self.assertTrue(rec["e"]) + self.assertIsNone(rec["t"]) + self.assertEqual(rec["c"], []) + + def test_un_solo_mensaje_enorme_es_claude_p(self): + largo = "x" * (S.NONINTERACTIVE_CHARS + 1) + path = write_session(self.root, "-p", "66666666-0000-0000-0000-000000000000", [ + user(largo, at=ts(0)), + ]) + self.assertTrue(S.read_session(path)["n"]) + + def test_una_charla_corta_no_es_claude_p(self): + path = write_session(self.root, "-p", "77777777-0000-0000-0000-000000000000", [ + user("hola", at=ts(0)), + ]) + self.assertFalse(S.read_session(path)["n"]) + + +class TestToolSummary(unittest.TestCase): + def test_usa_el_parametro_representativo(self): + self.assertEqual( + S.tool_summary({"name": "Read", "input": {"file_path": "/a/b.py", "limit": 5}}), + "Read: /a/b.py") + + def test_cae_al_primer_string_si_la_tool_es_desconocida(self): + self.assertEqual( + S.tool_summary({"name": "Rara", "input": {"n": 1, "q": "algo"}}), + "Rara: algo") + + def test_recorta_los_argumentos_largos(self): + out = S.tool_summary({"name": "Bash", "input": {"command": "a" * 500}}) + self.assertTrue(out.endswith("…")) + self.assertEqual(len(out), len("Bash: ") + S.TOOL_ARG_MAX + 1) + + def test_sin_argumentos_usables(self): + self.assertEqual(S.tool_summary({"name": "X", "input": {"n": 1}}), "X") + self.assertEqual(S.tool_summary({"name": "X", "input": "no es dict"}), "X") + + +class TestLoad(TempRoot): + def test_ordena_por_ultima_actividad(self): + simple_tree(self.root) + got = [s["id"][:8] for s in self.load()] + self.assertEqual(got, ["aaaaaaaa", "bbbbbbbb", "cccccccc"]) + + def test_deduce_la_ruta_de_otra_sesion_del_proyecto(self): + write_session(self.root, "-home-u-proj", "aaaaaaaa-0000-0000-0000-000000000001", + [user("con cwd", at=ts(0))]) + write_session(self.root, "-home-u-proj", "dddddddd-0000-0000-0000-000000000004", + [{"type": "system", "timestamp": ts(30)}]) + huerfana = next(s for s in self.load() if s["id"].startswith("dddddddd")) + self.assertEqual(huerfana["p"], "/home/u/proj") + self.assertTrue(huerfana["i"]) + + def test_sin_ninguna_ruta_conocida_queda_el_nombre_del_directorio(self): + write_session(self.root, "-sin-cwd", "eeeeeeee-0000-0000-0000-000000000005", + [{"type": "system", "timestamp": ts(0)}]) + s = self.load()[0] + self.assertEqual(s["p"], "-sin-cwd") + self.assertTrue(s["i"]) + + def test_ignora_los_jsonl_de_subagentes(self): + simple_tree(self.root) + sub = os.path.join(self.root, "-home-u-proj", "aaaaaaaa-0000-0000-0000-000000000001", "subagents") + os.makedirs(sub) + with open(os.path.join(sub, "agent-1.jsonl"), "w", encoding="utf-8") as f: + f.write(json.dumps(user("soy un subagente")) + "\n") + self.assertEqual(len(self.load()), 3) + + +class TestCache(TempRoot): + def test_reusa_lo_que_no_cambio(self): + simple_tree(self.root) + self.load() + + # Ensuciamos el caché a mano: si la segunda corrida devuelve el título + # falso es porque no volvió a leer el archivo. + with open(self.cache, encoding="utf-8") as f: + blob = json.load(f) + for entry in blob["entries"].values(): + entry["rec"]["t"] = "vino del caché" + with open(self.cache, "w", encoding="utf-8") as f: + json.dump(blob, f) + + self.assertEqual(self.load()[0]["t"], "vino del caché") + + def test_reparsea_si_el_archivo_cambio(self): + path = write_session(self.root, "-p", "99999999-0000-0000-0000-000000000009", + [user("original", at=ts(0))]) + self.load() + write_session(self.root, "-p", "99999999-0000-0000-0000-000000000009", + [user("cambiado", at=ts(0)), user("y otro", at=ts(1))]) + self.assertEqual(self.load()[0]["u"], 2) + self.assertTrue(os.path.exists(path)) + + def test_un_cache_de_otra_version_se_descarta(self): + simple_tree(self.root) + self.load() + with open(self.cache, encoding="utf-8") as f: + blob = json.load(f) + blob["v"] = S.CACHE_VERSION - 1 + for entry in blob["entries"].values(): + entry["rec"]["t"] = "no debería verse" + with open(self.cache, "w", encoding="utf-8") as f: + json.dump(blob, f) + + self.assertEqual(self.load()[0]["t"], "Arreglar el build") + + def test_un_cache_roto_no_rompe_nada(self): + simple_tree(self.root) + with open(self.cache, "w", encoding="utf-8") as f: + f.write("{esto no es json") + self.assertEqual(len(self.load()), 3) + + def test_no_cache_no_escribe_nada(self): + simple_tree(self.root) + self.load(use_cache=False) + self.assertFalse(os.path.exists(self.cache)) + + def test_el_cache_guarda_la_ruta_sin_deducir(self): + write_session(self.root, "-home-u-proj", "aaaaaaaa-0000-0000-0000-000000000001", + [user("con cwd", at=ts(0))]) + write_session(self.root, "-home-u-proj", "dddddddd-0000-0000-0000-000000000004", + [{"type": "system", "timestamp": ts(30)}]) + self.load() + with open(self.cache, encoding="utf-8") as f: + blob = json.load(f) + recs = {os.path.basename(k): v["rec"] for k, v in blob["entries"].items()} + self.assertIsNone(recs["dddddddd-0000-0000-0000-000000000004.jsonl"]["p"]) + + def test_drop_from_cache_saca_las_borradas(self): + simple_tree(self.root) + self.load() + with open(self.cache, encoding="utf-8") as f: + paths = list(json.load(f)["entries"]) + S.drop_from_cache(paths[:1], cache_path=self.cache) + with open(self.cache, encoding="utf-8") as f: + self.assertEqual(len(json.load(f)["entries"]), len(paths) - 1) + + +class TestFiltros(TempRoot): + def setUp(self): + super().setUp() + simple_tree(self.root) + self.sessions = self.load() + + def test_por_proyecto(self): + out = S.apply_filters(self.sessions, project="/home/u/otro") + self.assertEqual(len(out), 1) + + def test_por_contenido_de_la_conversacion(self): + out = S.apply_filters(self.sessions, grep="build") + self.assertEqual([s["id"][:8] for s in out], ["aaaaaaaa"]) + + def test_por_titulo_ruta_rama_o_uuid(self): + self.assertEqual(len(S.apply_filters(self.sessions, query="arreglar")), 1) + self.assertEqual(len(S.apply_filters(self.sessions, query="main")), 2) + self.assertEqual(len(S.apply_filters(self.sessions, query="cccccccc")), 1) + + def test_ocultar_vacias(self): + out = S.apply_filters(self.sessions, hide_empty=True) + self.assertTrue(all(not s["e"] for s in out)) + self.assertEqual(len(out), 2) + + +class TestPick(TempRoot): + def setUp(self): + super().setUp() + simple_tree(self.root) + self.sessions = self.load() + + def test_por_indice(self): + self.assertEqual(S.pick(self.sessions, "1")["id"][:8], "aaaaaaaa") + + def test_por_prefijo_de_uuid(self): + self.assertEqual(S.pick(self.sessions, "cccc")["id"][:8], "cccccccc") + + def test_indice_fuera_de_rango(self): + with self.assertRaises(S.SessionError): + S.pick(self.sessions, "99") + + def test_prefijo_inexistente(self): + with self.assertRaises(S.SessionError): + S.pick(self.sessions, "zzzz") + + def test_prefijo_ambiguo(self): + write_session(self.root, "-home-u-proj", "aaaaaaaa-0000-0000-0000-0000000000ff", + [user("otra más", at=ts(50))]) + with self.assertRaises(S.SessionError): + S.pick(self.load(), "aaaa") + + +class TestPublicRecords(TempRoot): + def test_saca_las_claves_internas_sin_tocar_el_original(self): + simple_tree(self.root) + sessions = self.load() + pub = S.public_records(sessions) + for r in pub: + self.assertNotIn("project_dir", r) + self.assertNotIn("mtime", r) + self.assertIn("project_dir", sessions[0]) + + +class TestParseTs(unittest.TestCase): + def test_acepta_z_y_offset(self): + self.assertIsNotNone(S.parse_ts("2025-08-14T10:00:00.000Z")) + self.assertIsNotNone(S.parse_ts("2025-08-14T10:00:00+02:00")) + + def test_devuelve_none_si_no_se_puede_leer(self): + self.assertIsNone(S.parse_ts(None)) + self.assertIsNone(S.parse_ts("")) + self.assertIsNone(S.parse_ts("ayer a la tarde")) + + +class TestRoots(unittest.TestCase): + def test_claude_config_dir_manda(self): + with unittest.mock.patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "/x/cfg"}): + self.assertEqual(S.default_root(), os.path.join("/x/cfg", "projects")) + + def test_sin_variable_cae_al_home(self): + with unittest.mock.patch.dict(os.environ, {}, clear=True): + self.assertTrue(S.default_root().endswith(os.path.join(".claude", "projects"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_terminal.py b/tests/test_terminal.py new file mode 100644 index 0000000..fdb9fc8 --- /dev/null +++ b/tests/test_terminal.py @@ -0,0 +1,142 @@ +import io +import unittest +from datetime import datetime, timedelta, timezone + +from claude_sesiones import terminal as T + +NOW = datetime(2025, 8, 14, 12, 0, tzinfo=timezone.utc) + + +def ago(days): + """Un ISO que queda a `days` días de NOW.""" + return (NOW - timedelta(days=days)).isoformat() + + +class TestStyle(unittest.TestCase): + def test_apagado_no_emite_nada(self): + st = T.Style(False) + self.assertEqual(st.amber, "") + self.assertEqual(st.reset, "") + + def test_prendido_emite_ansi(self): + st = T.Style(True) + self.assertTrue(st.amber.startswith("\x1b[")) + + def test_un_color_que_no_existe_es_attribute_error(self): + with self.assertRaises(AttributeError): + T.Style(True).fucsia + + def test_from_stream_sin_tty_apaga_el_color(self): + self.assertFalse(T.Style.from_stream(io.StringIO()).on) + + +class TestFormato(unittest.TestCase): + def test_fmt_dur(self): + self.assertEqual(T.fmt_dur(None), "—") + self.assertEqual(T.fmt_dur(0), "<1m") + self.assertEqual(T.fmt_dur(45), "45m") + self.assertEqual(T.fmt_dur(60), "1h") + self.assertEqual(T.fmt_dur(125), "2h05") + + def test_fmt_rel(self): + self.assertEqual(T.fmt_rel(ago(0.2), NOW), "hoy") + self.assertEqual(T.fmt_rel(ago(1.5), NOW), "ayer") + self.assertEqual(T.fmt_rel(ago(3), NOW), "hace 3d") + self.assertEqual(T.fmt_rel(ago(10), NOW), "hace 1sem") + self.assertEqual(T.fmt_rel(ago(70), NOW), "hace 2mes") + + def test_fmt_size(self): + self.assertEqual(T.fmt_size(12.5), "12.5 KB") + self.assertEqual(T.fmt_size(2048), "2.0 MB") + + def test_clip(self): + self.assertEqual(T.clip("hola", 10), "hola") + self.assertEqual(T.clip("hola mundo", 6), "hola …") + self.assertEqual(T.clip("con\nsalto", 20), "con salto") + + def test_visible_len_ignora_los_codigos_ansi(self): + self.assertEqual(T.visible_len("\x1b[1mhola\x1b[0m"), 4) + + def test_plural(self): + self.assertEqual(T.plural(1, "sesión", "sesiones"), "1 sesión") + self.assertEqual(T.plural(2, "sesión", "sesiones"), "2 sesiones") + + def test_stripe_sin_color_es_una_barra(self): + self.assertEqual(T.stripe(ago(1), NOW, T.Style(False)), "|") + + def test_stripe_cambia_de_color_con_la_edad(self): + st = T.Style(True) + colores = {T.stripe(ago(d), NOW, st) for d in (1, 4, 10, 60)} + self.assertEqual(len(colores), 4) + + +class TestMarkdown(unittest.TestCase): + def test_strip_md(self): + self.assertEqual(T.strip_md("## Título"), "Título") + self.assertEqual(T.strip_md("esto es **fuerte**"), "esto es fuerte") + + def test_render_block_respeta_las_cercas(self): + st = T.Style(False) + out = T.render_block("texto\n```py\nx = 1\n```\nfin", st, 40, "") + self.assertIn(" texto", out) + self.assertIn(" x = 1", out) + self.assertNotIn("```py", "".join(out)) + + def test_render_block_no_parte_palabras_largas(self): + out = T.render_block("a" * 60, T.Style(False), 20, "") + self.assertEqual(out, [" " + "a" * 60]) + + +class TestSalida(unittest.TestCase): + def sesion(self, **kw): + s = { + "id": "abcdef01-2345-6789-abcd-ef0123456789", + "p": "/home/u/proj", "b": "main", "t": "Arreglar el build", + "ai": True, "n": False, "e": False, "i": False, + "f": ago(1), "l": ago(1), "d": 12, "u": 2, "a": 3, "k": 10.0, + "v": "1.0.0", "c": [{"r": "u", "x": "hola"}, {"r": "a", "x": "chau"}, + {"r": "t", "x": "Bash: ls"}], + } + s.update(kw) + return s + + def test_print_table_sin_color_no_tiene_ansi(self): + buf = io.StringIO() + T.print_table([self.sesion()], T.Style(False), NOW, buf, width=120) + out = buf.getvalue() + self.assertNotIn("\x1b", out) + self.assertEqual(len(out.strip().split("\n")), 2) # encabezado + 1 fila + self.assertIn("Arreglar el build", out) + self.assertIn("abcdef01", out) + + def test_print_table_angosta_esconde_columnas(self): + buf = io.StringIO() + T.print_table([self.sesion()], T.Style(False), NOW, buf, width=60) + self.assertNotIn("/home/u/proj", buf.getvalue()) + + def test_print_chat_incluye_el_comando_para_reanudar(self): + buf = io.StringIO() + T.print_chat(self.sesion(), T.Style(False), buf) + out = buf.getvalue() + self.assertIn("cd /home/u/proj && claude --resume abcdef01", out) + self.assertIn("hola", out) + self.assertIn("Bash: ls", out) + + def test_no_tools_saca_las_herramientas(self): + buf = io.StringIO() + T.print_chat(self.sesion(), T.Style(False), buf, show_tools=False) + self.assertNotIn("Bash: ls", buf.getvalue()) + + def test_una_sesion_vacia_lo_dice(self): + buf = io.StringIO() + T.print_chat(self.sesion(c=[], t=None), T.Style(False), buf) + self.assertIn("no tiene mensajes", buf.getvalue()) + + def test_resume_cmd(self): + self.assertEqual( + T.resume_cmd({"p": "/a b", "id": "xyz"}), + "cd /a b && claude --resume xyz") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_webpage.py b/tests/test_webpage.py new file mode 100644 index 0000000..e738458 --- /dev/null +++ b/tests/test_webpage.py @@ -0,0 +1,92 @@ +import json +import os +import re +import tempfile +import unittest + +from claude_sesiones import webpage + +PAYLOAD_RE = re.compile( + r'<script id="payload" type="application/json">(.*?)</script>', re.S) + +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á"}]) + self.assertNotIn("</", raw) + self.assertEqual(json.loads(raw)[0]["x"], "mirá este </script> de acá") + + def test_no_escapa_a_ascii(self): + self.assertIn("ñ", webpage.encode_payload([{"x": "año"}])) + + +class TestRender(unittest.TestCase): + def test_reemplaza_el_marcador(self): + html = webpage.render([REGISTRO], template="<b>__DATA__</b>") + self.assertNotIn("__DATA__", html) + self.assertIn('"id":"abc"', html) + + def test_falla_si_el_template_no_tiene_marcador(self): + with self.assertRaises(webpage.TemplateError): + webpage.render([REGISTRO], template="<b>sin marcador</b>") + + def test_falla_si_el_marcador_esta_repetido(self): + with self.assertRaises(webpage.TemplateError): + webpage.render([REGISTRO], template="__DATA__ y __DATA__") + + def test_una_transcripcion_con_html_no_corta_el_script(self): + # El caso que motiva el escape: una sesión donde se habló de este mismo + # generador tiene "</script>" y "__DATA__" adentro del texto. + peligrosa = dict(REGISTRO, c=[{"r": "u", "x": "poné </script><img> y __DATA__"}]) + html = webpage.render([peligrosa]) + + 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__") + + +class TestTemplate(unittest.TestCase): + def setUp(self): + self.html = webpage.template_text() + + def test_el_template_del_paquete_tiene_un_solo_marcador(self): + self.assertEqual(self.html.count(webpage.MARKER), 1) + + def test_es_un_documento_completo(self): + # Sin doctype ni charset, un file:// se abre en quirks mode y con la + # codificación del sistema: los acentos salen rotos. + self.assertTrue(self.html.lstrip().startswith("<!doctype html>")) + self.assertIn('<meta charset="utf-8">', self.html) + self.assertIn('name="viewport"', self.html) + self.assertTrue(self.html.rstrip().endswith("</html>")) + + def test_no_pide_nada_por_red(self): + for atributo in ("src=\"http", "href=\"http", "@import"): + self.assertNotIn(atributo, self.html) + + def test_se_puede_pasar_otro_template(self): + with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, + encoding="utf-8") as f: + f.write("propio __DATA__") + ruta = f.name + self.addCleanup(os.unlink, ruta) + self.assertTrue(webpage.template_text(ruta).startswith("propio")) + + +class TestWrite(unittest.TestCase): + def test_escribe_y_resume(self): + with tempfile.TemporaryDirectory() as d: + out = os.path.join(d, "s.html") + stats = webpage.write( + [REGISTRO, dict(REGISTRO, id="def", p="/otro", u=2)], out) + self.assertEqual(stats, {"sesiones": 2, "proyectos": 2, + "mensajes": 3, "bloques": 2}) + with open(out, encoding="utf-8") as f: + self.assertEqual(len(PAYLOAD_RE.findall(f.read())), 1) + + +if __name__ == "__main__": + unittest.main() |