254 lines
13 KiB
Python
254 lines
13 KiB
Python
"""
|
||
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()
|