""" Pruebas de las actividades: la rentabilidad de la minería calculada aparte, sin mover un euro de las cifras de la cartera. python3 -m unittest discover -s pruebas -v """ from __future__ import annotations import copy import datetime as dt import sys import tempfile import unittest from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from cartera import actividades # noqa: E402 from cartera.datos import Datos, escribir # noqa: E402 from cartera.operaciones import Cartera # noqa: E402 from cartera.vista import resumen # noqa: E402 HOY = dt.date(2026, 9, 20) MINERIA = "Minería" def cartera_con_mineria(marcadas: bool = True) -> Datos: """Bitcoin con compras normales y, aparte, la operación de minería.""" etiqueta = {"actividad": MINERIA} if marcadas else {} movimientos = [ # Lo que no es minería: dos compras corrientes {"id": "m1", "fecha": "2025-01-15", "activo": "C1", "tipo": "Compra", "cantidad": 0.5, "precio": 40000.0, "importe": 20000.0}, {"id": "m2", "fecha": "2025-06-15", "activo": "C1", "tipo": "Compra", "cantidad": 0.25, "precio": 60000.0, "importe": 15000.0}, # La minería: los aparatos, su devolución, el hosting y lo producido {"id": "g1", "fecha": "2026-01-10", "activo": "C1", "tipo": "Gasto", "importe": 6000.0, "notas": "ASIC", "estimado": True, **etiqueta}, {"id": "d1", "fecha": "2026-03-01", "activo": "C1", "tipo": "Dividendo", "importe": 1000.0, "notas": "Devolución parcial de los ASIC", "estimado": True, **etiqueta}, {"id": "h1", "fecha": "2026-07-01", "activo": "C1", "tipo": "Gasto", "importe": 70.0, "notas": "Hosting", **etiqueta}, {"id": "h2", "fecha": "2026-08-01", "activo": "C1", "tipo": "Gasto", "importe": 70.0, "notas": "Hosting", **etiqueta}, {"id": "h3", "fecha": "2026-09-01", "activo": "C1", "tipo": "Gasto", "importe": 70.0, "notas": "Hosting", **etiqueta}, {"id": "b1", "fecha": "2026-07-20", "activo": "C1", "tipo": "Mineria", "cantidad": 0.03, **etiqueta}, {"id": "b2", "fecha": "2026-08-20", "activo": "C1", "tipo": "Mineria", "cantidad": 0.03, **etiqueta}, {"id": "b3", "fecha": "2026-09-10", "activo": "C1", "tipo": "Mineria", "cantidad": 0.04, **etiqueta}, ] return Datos( [{"id": "C1", "nombre": "Bitcoin", "tipo": "Cripto", "codigo": "bitcoin", "unidad": "BTC", "estado": "abierta", "fecha_inicio": "2025-01-01"}], movimientos, [{"fecha": "2026-09-19", "activo": "C1", "precio": 100000.0, "fuente": "prueba"}], []) class TestCalculo(unittest.TestCase): def test_marcar_operaciones_no_cambia_ninguna_cifra_de_la_cartera(self): sin, con = resumen(cartera_con_mineria(False), HOY), resumen(cartera_con_mineria(True), HOY) self.assertEqual(sin["global"], con["global"]) self.assertEqual(sin["abiertos"], con["abiertos"]) self.assertEqual(sin["actividades"], []) # sin marcar no hay actividad self.assertEqual(len(con["actividades"]), 1) def test_las_cifras_de_la_mineria(self): a = resumen(cartera_con_mineria(), HOY)["actividades"][0] self.assertEqual(a["nombre"], MINERIA) self.assertAlmostEqual(a["invertido"], 6210.0) # 6.000 de ASIC + 3 meses de hosting self.assertAlmostEqual(a["recuperado"], 1000.0) self.assertAlmostEqual(a["produccion"][0]["cantidad"], 0.10) self.assertAlmostEqual(a["valor"], 10000.0) # 0,10 BTC a 100.000 € self.assertAlmostEqual(a["ganancia"], 4790.0) # 10.000 + 1.000 − 6.210 self.assertAlmostEqual(a["coste_unidad"], 52100.0) # (6.210 − 1.000) / 0,10 self.assertGreater(a["tir"], 0) self.assertEqual(a["estimadas"], 2) # el ASIC y su devolución self.assertEqual(a["operaciones"], 8) def test_el_coste_por_bitcoin_avisa_cuando_la_mineria_pierde(self): datos = cartera_con_mineria() # El mismo gasto produciendo la cuarta parte: sale muchísimo más caro for m in datos.movimientos: if m["tipo"] == "Mineria": m["cantidad"] = m["cantidad"] / 4 a = resumen(datos, HOY)["actividades"][0] self.assertAlmostEqual(a["coste_unidad"], 208400.0) self.assertGreater(a["coste_unidad"], 100000.0) # más caro que comprarlo self.assertLess(a["ganancia"], 0) def test_la_serie_mensual_separa_gasto_y_produccion(self): a = resumen(cartera_con_mineria(), HOY)["actividades"][0] meses = {m["mes"]: m for m in a["meses"]} self.assertAlmostEqual(meses["2026-07"]["gasto"], 70.0) self.assertAlmostEqual(meses["2026-07"]["producido"], 0.03) self.assertAlmostEqual(meses["2026-09"]["producido"], 0.04) self.assertEqual(len(a["meses"]), actividades.MESES_SERIE) def test_agrupa_aunque_se_escriba_con_otras_mayusculas_o_sin_tilde(self): datos = cartera_con_mineria() datos.movimientos[4]["actividad"] = "mineria" datos.movimientos[5]["actividad"] = "MINERÍA" a = resumen(datos, HOY)["actividades"] self.assertEqual(len(a), 1) self.assertEqual(a[0]["operaciones"], 8) def test_nombres_lista_lo_que_hay(self): self.assertEqual(actividades.nombres(cartera_con_mineria()), [MINERIA]) self.assertEqual(actividades.nombres(cartera_con_mineria(False)), []) class TestMarcar(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.carpeta = Path(self.tmp.name) / "datos" self.carpeta.mkdir() datos = cartera_con_mineria(False) datos.movimientos[2]["actividad"] = MINERIA # solo el ASIC viene marcado escribir(self.carpeta, datos) self.c = Cartera(self.carpeta) self.addCleanup(self.c.cerrar) def test_propone_todas_las_del_activo_y_marca_las_que_mas_se_parecen(self): propuestas = actividades.candidatas(self.c.datos, MINERIA, HOY) ids = {p["id"] for p in propuestas} # Todo lo del bitcoin que aún no tiene actividad: gastos, minerías, cobros y compras self.assertEqual(ids, {"m1", "m2", "d1", "h1", "h2", "h3", "b1", "b2", "b3"}) probables = {p["id"] for p in propuestas if p["probable"]} self.assertEqual(probables, {"h1", "h2", "h3"}) # mismo tipo que el ASIC ya marcado # Las probables van las primeras, para que se vean sin bajar por la lista self.assertTrue(all(p["probable"] for p in propuestas[:len(probables)])) def test_la_mineria_sale_aunque_solo_este_marcado_el_hosting(self): """El fallo de la primera versión: filtrando también por tipo, quien empezaba marcando el gasto del hosting no encontraba nunca el bitcoin minado, y la actividad se quedaba valiendo 0 € por mucho que tuviera producción. """ self.c.etiquetar(["g1"], "") # solo el hosting marcado self.c.etiquetar(["h1"], MINERIA) self.assertEqual(resumen(self.c.datos, HOY)["actividades"][0]["valor"], 0.0) ids = [p["id"] for p in actividades.candidatas(self.c.datos, MINERIA, HOY)] for minada in ("b1", "b2", "b3"): self.assertIn(minada, ids) self.c.etiquetar(["b1", "b2", "b3"], MINERIA) a = resumen(self.c.datos, HOY)["actividades"][0] self.assertAlmostEqual(a["valor"], 0.10 * 100000.0) self.assertAlmostEqual(a["produccion"][0]["cantidad"], 0.10) def test_marcar_en_bloque_y_que_cuenten(self): # Como en la pantalla: vienen marcadas las probables y el usuario añade el resto ids = [p["id"] for p in actividades.candidatas(self.c.datos, MINERIA, HOY) if p["probable"]] r = self.c.etiquetar(ids + ["b1", "b2", "b3", "d1"], MINERIA) self.assertEqual(r["marcadas"], 7) a = resumen(self.c.datos, HOY)["actividades"][0] self.assertAlmostEqual(a["invertido"], 6210.0) self.assertAlmostEqual(a["ganancia"], 4790.0) # y repetir no vuelve a contarlas self.assertEqual(self.c.etiquetar(ids, MINERIA)["marcadas"], 0) def test_quitar_la_etiqueta(self): self.c.etiquetar(["g1"], "") self.assertEqual(resumen(self.c.datos, HOY)["actividades"], []) def test_renombrar_alcanza_a_todas(self): self.c.etiquetar(["h1", "h2"], MINERIA) r = self.c.renombrar_actividad(MINERIA, "Minería BTC") self.assertEqual(r["cambiadas"], 3) self.assertEqual(actividades.nombres(self.c.datos), ["Minería BTC"]) with self.assertRaises(Exception): self.c.renombrar_actividad("Minería BTC", " ") def test_una_automatica_etiquetada_pega_su_actividad_a_lo_que_crea(self): self.c.guardar_recurrente({"activo": "C1", "tipo": "Gasto", "importe": 70, "moneda": "EUR", "dia": 1, "desde": "2026-06-01", "notas": "Hosting", "actividad": MINERIA}) self.c.generar_recurrentes(HOY) creadas = [m for m in self.c.datos.vivos("movimientos") if str(m["id"]).startswith("r-")] self.assertTrue(creadas) self.assertTrue(all(m["actividad"] == MINERIA for m in creadas)) # y también salen como candidatas si alguna se hubiera quedado sin marcar creadas[0]["actividad"] = "" ids = {p["id"] for p in actividades.candidatas(self.c.datos, MINERIA, HOY)} self.assertIn(creadas[0]["id"], ids) class TestApuntarAOjo(unittest.TestCase): """Los aparatos y su devolución se apuntan a ojo: tienen que quedar «para revisar».""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) carpeta = Path(self.tmp.name) / "datos" carpeta.mkdir() escribir(carpeta, cartera_con_mineria(False)) self.c = Cartera(carpeta) self.addCleanup(self.c.cerrar) def test_un_gasto_se_puede_guardar_como_estimado_y_editar_sin_perder_la_marca(self): m = self.c.guardar_movimiento({"activo": "C1", "tipo": "Gasto", "fecha": "2026-02-10", "importe": 6000, "notas": "ASIC (a ojo)", "actividad": MINERIA, "estimado": True}) self.assertTrue(m["estimado"]) m2 = self.c.guardar_movimiento({"id": m["id"], "activo": "C1", "tipo": "Gasto", "fecha": "2026-02-10", "importe": 6200, "actividad": MINERIA, "estimado": True}) self.assertTrue(m2["estimado"]) # y al confirmarla deja de estar para revisar self.c.guardar_movimiento({"id": m["id"], "activo": "C1", "tipo": "Gasto", "fecha": "2026-02-10", "importe": 6200, "actividad": MINERIA, "estimado": False}) m3 = next(x for x in self.c.datos.vivos("movimientos") if x["id"] == m["id"]) self.assertFalse(m3["estimado"]) def test_la_devolucion_de_los_asic_se_apunta_como_dividendo_en_el_bitcoin(self): self.c.guardar_movimiento({"activo": "C1", "tipo": "Gasto", "fecha": "2026-02-10", "importe": 6000, "actividad": MINERIA, "estimado": True}) d = self.c.guardar_movimiento({"activo": "C1", "tipo": "Dividendo", "fecha": "2026-04-01", "importe": 1500, "notas": "Devolución de los ASIC", "actividad": MINERIA, "estimado": True}) self.assertEqual(d["tipo"], "Dividendo") a = resumen(self.c.datos, HOY)["actividades"][0] self.assertAlmostEqual(a["invertido"], 6000.0) self.assertAlmostEqual(a["recuperado"], 1500.0) self.assertEqual(a["estimadas"], 2) def test_el_formulario_web_ofrece_esas_dos_cosas(self): """Lo que falló de verdad estaba en la pantalla, no en el motor.""" app = (Path(__file__).resolve().parent.parent / "cartera" / "web" / "app.js").read_text() cripto = next(l for l in app.splitlines() if l.strip().startswith("Cripto: [")) self.assertIn('"Dividendo"', cripto) self.assertIn("estimado: chkEstimado.checked", app) class TestPatrimonioNeto(unittest.TestCase): def test_el_resumen_da_activos_deuda_y_neto(self): datos = cartera_con_mineria() datos.activos += [ {"id": "CC1", "nombre": "Banco", "tipo": "Cuenta", "estado": "abierta"}, {"id": "D1", "nombre": "Préstamo", "tipo": "Deuda", "estado": "abierta", "fecha_inicio": "2026-01-01", "deuda": {"capital": 10000.0, "tin": 0.0, "cuotas": 100, "primera_cuota": "2026-02-01"}}, ] datos.precios.append({"fecha": "2026-09-19", "activo": "CC1", "precio": 5000.0, "fuente": "manual"}) g = resumen(datos, HOY)["global"] self.assertAlmostEqual(g["patrimonio"], g["valor"] + 5000.0) self.assertGreater(g["deuda"], 0) self.assertAlmostEqual(g["patrimonio_neto"], g["patrimonio"] - g["deuda"]) if __name__ == "__main__": unittest.main()