caudal/programa/pruebas/test_actividades.py
2026-10-13 08:01:06 -05:00

254 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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