From 3e2a9c3e224a77faf8beec55fe80db0ce0725dc5 Mon Sep 17 00:00:00 2001 From: Miqueas Quintanilla Date: Wed, 19 Aug 2026 20:37:12 -0500 Subject: [PATCH 1/3] test: property-based con Hypothesis y pruebas de escala de red Los tests existentes son casos puntuales elegidos a mano y la red mas grande verificada tenia cinco ramales. Se agregan dos frentes: - Invariantes con Hypothesis: monotonias fisicas (mas trabajadores o mas altitud nunca reducen el caudal; mas resistencia no aumenta el caudal de esa rama), conservacion de Kirchhoff en redes generadas aleatoriamente, escalado dimensional de Atkinson, cota del redondeo de seguridad, interpolacion acotada de la curva de ventilador, determinismo y rechazo de valores no finitos. - Escala y condicionamiento: redes de mina por niveles de 50, 200 y 500 ramales, y una red con resistencias separadas doce ordenes de magnitud. Quinientos ramales convergen en decimas de segundo con balance exacto. El invariante de red sin ventilador se expresa como cota en funcion de la tolerancia pedida: el solver converge cuando la correccion de malla cae por debajo del umbral, de modo que el caudal residual escala con el. --- pyproject.toml | 5 +- tests/python/test_escala.py | 156 +++++++++++++++ tests/python/test_properties.py | 327 ++++++++++++++++++++++++++++++++ 3 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 tests/python/test_escala.py create mode 100644 tests/python/test_properties.py diff --git a/pyproject.toml b/pyproject.toml index 4042159..2c43a43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ Issues = "https://github.com/Miqueas7/VentPy/issues" ventpy = "ventpy.cli:main" [project.optional-dependencies] -test = ["pytest>=7.0"] +test = ["pytest>=7.0", "hypothesis>=6.0"] viz = ["matplotlib>=3.7", "numpy>=1.24"] [tool.scikit-build] @@ -42,6 +42,9 @@ wheel.packages = ["python/ventpy"] [tool.pytest.ini_options] testpaths = ["tests/python"] +markers = [ + "slow: pruebas de escala que tardan mas de lo habitual", +] [tool.cibuildwheel] build = "cp39-* cp310-* cp311-* cp312-* cp313-*" diff --git a/tests/python/test_escala.py b/tests/python/test_escala.py new file mode 100644 index 0000000..889856c --- /dev/null +++ b/tests/python/test_escala.py @@ -0,0 +1,156 @@ +"""Pruebas de escala y condicionamiento del solver de red. + +Los tests funcionales usan redes de 2 a 5 ramales. Un modelo real de mina +tiene cientos. Aqui se generan topologias de mina por niveles (rampa de +ingreso, galerias por nivel y retorno) de tamano creciente para verificar +que el balance converge, que lo hace en un tiempo razonable, y que cuando +no converge lo reporta honestamente en vez de entregar numeros invalidos. +""" + +import math +import time + +import pytest + +import ventpy + + +def _rama(bid, desde, hasta, r, fan=0.0): + b = ventpy.NetworkBranch() + b.branch_id = bid + b.from_node = desde + b.to_node = hasta + b.r_manual = r + b.fan_pressure_pa = fan + return b + + +def red_por_niveles(n_niveles, r_rampa=0.02, r_galeria=0.35, fan_pa=2500.0): + """Mina de `n_niveles`: ingreso por el lado A, galeria por nivel, retorno + por el lado B. Genera 3*n_niveles + 1 ramales y n_niveles + 1 mallas.""" + ramas = [_rama("FAN", "S", "A0", r_rampa, fan=fan_pa)] + for i in range(n_niveles): + ramas.append(_rama(f"GAL{i}", f"A{i}", f"B{i}", r_galeria)) + if i + 1 < n_niveles: + ramas.append(_rama(f"IN{i}", f"A{i}", f"A{i+1}", r_rampa)) + ramas.append(_rama(f"OUT{i}", f"B{i+1}", f"B{i}", r_rampa)) + ramas.append(_rama("RET", "B0", "S", r_rampa)) + + d = ventpy.NetworkDefinition() + d.branches = ramas + return d + + +def _kirchhoff_ok(resultado, tol=1e-6): + nodos = set() + for b in resultado.branches: + nodos.add(b.from_node) + nodos.add(b.to_node) + for nodo in nodos: + balance = 0.0 + for b in resultado.branches: + if b.to_node == nodo: + balance += b.q_m3min + if b.from_node == nodo: + balance -= b.q_m3min + if abs(balance) > tol: + return False, nodo, balance + return True, None, 0.0 + + +def _resolver(red, max_iter=2000): + sp = ventpy.SolverParams() + sp.tolerance_m3min = 0.06 + sp.max_iterations = max_iter + t0 = time.perf_counter() + r = ventpy.NetworkSolver.solve(red, ventpy.AtmosphericParams(), sp) + return r, time.perf_counter() - t0 + + +class TestEscala: + def test_red_mediana_50_ramales(self): + red = red_por_niveles(17) # ~50 ramales + assert len(red.branches) >= 45 + r, seg = _resolver(red) + assert r.converged, f"no convergio: residual {r.max_residual_m3min}" + ok, nodo, bal = _kirchhoff_ok(r) + assert ok, f"Kirchhoff roto en {nodo}: {bal}" + assert all(math.isfinite(b.q_m3min) for b in r.branches) + assert seg < 10.0, f"demasiado lento: {seg:.2f} s" + + @pytest.mark.slow + def test_red_grande_200_ramales(self): + red = red_por_niveles(67) # ~200 ramales + r, seg = _resolver(red) + if r.converged: + ok, nodo, bal = _kirchhoff_ok(r) + assert ok, f"Kirchhoff roto en {nodo}: {bal}" + else: + # falla honesta: residual y advertencia presentes + assert r.max_residual_m3min > 0.0 + assert any("NO CONVERGIO" in w for w in r.warnings) + assert seg < 60.0, f"demasiado lento: {seg:.2f} s" + + @pytest.mark.slow + def test_red_muy_grande_500_ramales(self): + red = red_por_niveles(167) # ~500 ramales + r, seg = _resolver(red) + assert all(math.isfinite(b.q_m3min) for b in r.branches) + if r.converged: + ok, nodo, bal = _kirchhoff_ok(r) + assert ok, f"Kirchhoff roto en {nodo}: {bal}" + else: + assert any("NO CONVERGIO" in w for w in r.warnings) + assert seg < 120.0, f"demasiado lento: {seg:.2f} s" + + +class TestCondicionamiento: + def test_resistencias_en_rangos_extremos(self): + """Resistencias que difieren en 12 ordenes de magnitud dentro de la + misma red: el solver debe converger o reportar el fallo, nunca + entregar valores no finitos.""" + d = ventpy.NetworkDefinition() + d.branches = [ + _rama("FAN", "S", "A", 1e-6, fan=3000.0), + _rama("FINA", "A", "B", 1e6), # labor practicamente cerrada + _rama("ANCHA", "A", "B", 1e-6), # by-pass de resistencia minima + _rama("RET", "B", "S", 0.1), + ] + r, _ = _resolver(d) + for b in r.branches: + assert math.isfinite(b.q_m3min), f"{b.branch_id} no finito" + assert math.isfinite(b.pressure_drop_pa) + if r.converged: + ok, nodo, bal = _kirchhoff_ok(r) + assert ok, f"Kirchhoff roto en {nodo}: {bal}" + # el caudal prefiere abrumadoramente la rama de baja resistencia + fina = next(b for b in r.branches if b.branch_id == "FINA") + ancha = next(b for b in r.branches if b.branch_id == "ANCHA") + assert abs(ancha.q_m3min) > abs(fina.q_m3min) + else: + assert any("NO CONVERGIO" in w for w in r.warnings) + + @pytest.mark.parametrize("tolerancia", [0.6, 0.06, 0.006, 0.0006]) + def test_red_sin_ventilador_tiende_a_caudal_nulo(self, tolerancia): + """Sin fuente de presion no puede haber circulacion sostenida. + + El solver converge cuando la correccion de malla cae por debajo de la + tolerancia, no cuando el caudal es exactamente cero, de modo que queda + un residuo acotado por esa tolerancia: pedir una tolerancia diez veces + menor deja un caudal diez veces menor. Se verifica esa relacion, que + es lo que el algoritmo garantiza. + """ + d = ventpy.NetworkDefinition() + d.branches = [ + _rama("A", "N1", "N2", 0.5), + _rama("B", "N2", "N1", 0.5), + ] + sp = ventpy.SolverParams() + sp.tolerance_m3min = tolerancia + sp.max_iterations = 5000 + r = ventpy.NetworkSolver.solve(d, ventpy.AtmosphericParams(), sp) + + assert r.converged + for b in r.branches: + assert abs(b.q_m3min) <= tolerancia, ( + f"{b.branch_id}: {b.q_m3min} excede la tolerancia {tolerancia}") diff --git a/tests/python/test_properties.py b/tests/python/test_properties.py new file mode 100644 index 0000000..b580a7c --- /dev/null +++ b/tests/python/test_properties.py @@ -0,0 +1,327 @@ +"""Pruebas basadas en propiedades (Hypothesis). + +A diferencia de los tests de caso puntual, aqui se declaran invariantes que +deben cumplirse para CUALQUIER entrada dentro del dominio fisico, y la +libreria de generacion busca contraejemplos. + +Los rangos de las estrategias son fisicamente razonables para mineria +subterranea: altitudes hasta 5.000 msnm, potencias 10-1000 HP, secciones +1-100 m2, resistencias 1e-3 a 10 Ns2/m8. +""" + +import math + +import pytest +from hypothesis import HealthCheck, assume, given, settings +from hypothesis import strategies as st + +import ventpy + +# Perfil comun: las llamadas al nucleo son rapidas, pero construir structs +# nanobind tiene coste; se limita el numero de ejemplos por propiedad. +PROP = settings(max_examples=60, deadline=None, + suppress_health_check=[HealthCheck.too_slow]) + +altitudes = st.floats(min_value=0.0, max_value=5000.0, allow_nan=False, + allow_infinity=False) +resistencias = st.floats(min_value=1e-3, max_value=10.0, allow_nan=False, + allow_infinity=False) +areas = st.floats(min_value=1.0, max_value=100.0, allow_nan=False, + allow_infinity=False) + + +# --------------------------------------------------------------------------- +# Monotonias fisicas +# --------------------------------------------------------------------------- + +class TestMonotonia: + @given(n=st.integers(min_value=1, max_value=500), alt=altitudes) + @settings(PROP) + def test_caudal_personal_no_decrece_con_trabajadores(self, n, alt): + """Un trabajador mas nunca puede exigir menos aire.""" + cfg = ventpy.RegulatoryConfig.peru() + q_n = ventpy.calculate_personnel_flow(n, alt, cfg).q_personnel + q_n1 = ventpy.calculate_personnel_flow(n + 1, alt, cfg).q_personnel + assert q_n1 >= q_n + + @given(n=st.integers(min_value=1, max_value=200), + alt=st.floats(min_value=0.0, max_value=4000.0, allow_nan=False, + allow_infinity=False)) + @settings(PROP) + def test_caudal_personal_no_decrece_con_altitud(self, n, alt): + """La escala del Art. 247 es no decreciente en altitud.""" + cfg = ventpy.RegulatoryConfig.peru() + q_bajo = ventpy.calculate_personnel_flow(n, alt, cfg).q_personnel + q_alto = ventpy.calculate_personnel_flow(n, alt + 500.0, cfg).q_personnel + assert q_alto >= q_bajo + + @given(r1=resistencias, r2=resistencias, delta=st.floats( + min_value=0.1, max_value=5.0, allow_nan=False, allow_infinity=False)) + @settings(PROP) + def test_mas_resistencia_no_aumenta_su_caudal(self, r1, r2, delta): + """En dos ramas en paralelo, subir la resistencia de una no puede + aumentar el caudal que pasa por ella.""" + def resolver(ra): + d = ventpy.NetworkDefinition() + d.branches = [ + _rama("F", "S", "A", 0.05, fan=800.0), + _rama("P1", "A", "B", ra), + _rama("P2", "A", "B", r2), + _rama("R", "B", "S", 0.1), + ] + sp = ventpy.SolverParams() + sp.tolerance_m3min = 0.006 + sp.max_iterations = 500 + return ventpy.NetworkSolver.solve(d, ventpy.AtmosphericParams(), sp) + + base = resolver(r1) + subida = resolver(r1 + delta) + assume(base.converged and subida.converged) + assert abs(subida.branches[1].q_m3min) <= abs(base.branches[1].q_m3min) + 1e-6 + # y la rama gemela no puede perder caudal cuando su competidora empeora + assert abs(subida.branches[2].q_m3min) >= abs(base.branches[2].q_m3min) - 1e-6 + + +# --------------------------------------------------------------------------- +# Redondeo de seguridad +# --------------------------------------------------------------------------- + +class TestRedondeoSeguridad: + @given(gen=st.floats(min_value=0.1, max_value=500.0, allow_nan=False, + allow_infinity=False), + target=st.floats(min_value=0.5, max_value=3.0, allow_nan=False, + allow_infinity=False), + eficiencia=st.floats(min_value=0.0, max_value=0.95, allow_nan=False, + allow_infinity=False)) + @settings(PROP) + def test_q_dust_nunca_por_debajo_del_crudo(self, gen, target, eficiencia): + """El caudal reportado cubre siempre la dilucion teorica, y no la + excede en mas de 1 m3/min (redondeo hacia arriba, no inflado).""" + p = ventpy.DustParams() + p.dust_generation_rate_mg_s = gen + p.target_concentration_mg_m3 = target + p.water_suppression = True + p.suppression_efficiency = eficiencia + r = ventpy.calculate_dust_flow(p, ventpy.RegulatoryConfig.peru()) + + crudo = gen * (1.0 - eficiencia) / target * 60.0 + assert r.q_dust >= crudo - 1e-6 + assert r.q_dust < crudo + 1.0 + + +# --------------------------------------------------------------------------- +# Conservacion de masa en la red (invariante mas fuerte) +# --------------------------------------------------------------------------- + +def _rama(bid, desde, hasta, r, fan=0.0): + b = ventpy.NetworkBranch() + b.branch_id = bid + b.from_node = desde + b.to_node = hasta + b.r_manual = r + b.fan_pressure_pa = fan + return b + + +@st.composite +def redes_conexas(draw): + """Genera una red conexa con al menos una malla: un ciclo base de n nodos + mas cuerdas aleatorias entre nodos existentes.""" + n_nodos = draw(st.integers(min_value=3, max_value=8)) + n_cuerdas = draw(st.integers(min_value=1, max_value=4)) + nodos = [f"N{i}" for i in range(n_nodos)] + + ramas = [] + for i in range(n_nodos): # ciclo base + r = draw(st.floats(min_value=0.01, max_value=5.0, allow_nan=False, + allow_infinity=False)) + ramas.append(_rama(f"C{i}", nodos[i], nodos[(i + 1) % n_nodos], r)) + + for k in range(n_cuerdas): # cuerdas: crean mallas adicionales + i = draw(st.integers(min_value=0, max_value=n_nodos - 1)) + j = draw(st.integers(min_value=0, max_value=n_nodos - 1)) + assume(i != j) + r = draw(st.floats(min_value=0.01, max_value=5.0, allow_nan=False, + allow_infinity=False)) + ramas.append(_rama(f"X{k}", nodos[i], nodos[j], r)) + + fan = draw(st.floats(min_value=100.0, max_value=3000.0, allow_nan=False, + allow_infinity=False)) + ramas[0].fan_pressure_pa = fan + + d = ventpy.NetworkDefinition() + d.branches = ramas + return d + + +class TestKirchhoff: + @given(red=redes_conexas()) + @settings(max_examples=80, deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large]) + def test_conservacion_en_todos_los_nodos(self, red): + """Para CUALQUIER red que converja, la suma de caudales en cada nodo + debe anularse (primera ley de Kirchhoff).""" + sp = ventpy.SolverParams() + sp.tolerance_m3min = 0.006 + sp.max_iterations = 500 + r = ventpy.NetworkSolver.solve(red, ventpy.AtmosphericParams(), sp) + assume(r.converged) + + nodos = set() + for b in r.branches: + nodos.add(b.from_node) + nodos.add(b.to_node) + + for nodo in nodos: + balance = 0.0 + for b in r.branches: + if b.to_node == nodo: + balance += b.q_m3min + if b.from_node == nodo: + balance -= b.q_m3min + assert abs(balance) < 1e-6, f"nodo {nodo}: balance {balance}" + + @given(red=redes_conexas()) + @settings(max_examples=40, deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large]) + def test_resultados_finitos_o_no_convergido(self, red): + """Nunca se entregan NaN/infinito: o los numeros son finitos, o el + resultado viene marcado como no convergido.""" + sp = ventpy.SolverParams() + sp.tolerance_m3min = 0.006 + sp.max_iterations = 200 + r = ventpy.NetworkSolver.solve(red, ventpy.AtmosphericParams(), sp) + if r.converged: + for b in r.branches: + assert math.isfinite(b.q_m3min) + assert math.isfinite(b.pressure_drop_pa) + + +# --------------------------------------------------------------------------- +# Cobertura: relaciones internas del resultado +# --------------------------------------------------------------------------- + +class TestCoberturaInvariantes: + @given(req=st.floats(min_value=1.0, max_value=1e5, allow_nan=False, + allow_infinity=False), + med=st.floats(min_value=0.0, max_value=1e5, allow_nan=False, + allow_infinity=False)) + @settings(PROP) + def test_relaciones_del_resultado(self, req, med): + m = ventpy.ZoneMeasurement() + m.zone_name = "Z" + m.q_measured_m3min = med + r = ventpy.CoverageCalculator.compare_zone(req, m) + + assert r.coverage_ratio == pytest.approx(med / req, rel=1e-12) + assert r.compliant == (med >= req) + assert (r.deficit_m3min > 0.0) == (not r.compliant) + if not r.compliant: + assert r.deficit_m3min >= req - med - 1e-9 # redondeo hacia arriba + + +# --------------------------------------------------------------------------- +# Escalado dimensional de Atkinson +# --------------------------------------------------------------------------- + +class TestAtkinsonEscalado: + @given(largo=st.floats(min_value=10.0, max_value=2000.0, allow_nan=False, + allow_infinity=False), + area=areas, + k=st.floats(min_value=0.002, max_value=0.05, allow_nan=False, + allow_infinity=False)) + @settings(PROP) + def test_resistencia_proporcional_a_longitud(self, largo, area, k): + def r_de(L): + p = ventpy.AirwayParams() + p.airway_id = "A" + p.length_m = L + p.perimeter_m = 4.0 * math.sqrt(area) + p.area_m2 = area + p.lining = ventpy.AirwayLining.Manual + p.atkinson_k = k + return ventpy.AtkinsonCalculator.calculate_resistance( + p, ventpy.AtmosphericParams()).r_friction + + assert r_de(2.0 * largo) == pytest.approx(2.0 * r_de(largo), rel=1e-9) + + @given(area=areas, k=st.floats(min_value=0.002, max_value=0.05, + allow_nan=False, allow_infinity=False)) + @settings(PROP) + def test_resistencia_inversa_al_cubo_del_area(self, area, k): + def r_de(A): + p = ventpy.AirwayParams() + p.airway_id = "A" + p.length_m = 500.0 + p.perimeter_m = 15.0 + p.area_m2 = A + p.lining = ventpy.AirwayLining.Manual + p.atkinson_k = k + return ventpy.AtkinsonCalculator.calculate_resistance( + p, ventpy.AtmosphericParams()).r_friction + + assert r_de(2.0 * area) == pytest.approx(r_de(area) / 8.0, rel=1e-9) + + +# --------------------------------------------------------------------------- +# Curva de ventilador: interpolacion acotada +# --------------------------------------------------------------------------- + +class TestCurvaVentilador: + @given(q=st.floats(min_value=600.0, max_value=3000.0, allow_nan=False, + allow_infinity=False), + rho=st.floats(min_value=0.6, max_value=1.3, allow_nan=False, + allow_infinity=False)) + @settings(PROP) + def test_interpolacion_dentro_del_rango_de_catalogo(self, q, rho): + c = ventpy.FanCurve() + c.fan_id = "AX" + c.points = [ventpy.FanCurvePoint(), ventpy.FanCurvePoint(), + ventpy.FanCurvePoint()] + for punto, (qq, pp) in zip(c.points, [(600.0, 2000.0), (1800.0, 1400.0), + (3000.0, 200.0)]): + punto.q_m3min = qq + punto.pressure_pa = pp + + p = ventpy.FanCalculator.pressure_at(c, q, rho) + factor = rho / c.rated_density_kg_m3 + assert 200.0 * factor - 1e-9 <= p <= 2000.0 * factor + 1e-9 + + +# --------------------------------------------------------------------------- +# Determinismo y rechazo de valores no finitos +# --------------------------------------------------------------------------- + +class TestDeterminismoYDominio: + @given(n=st.integers(min_value=1, max_value=300), alt=altitudes) + @settings(PROP) + def test_misma_entrada_mismo_resultado(self, n, alt): + cfg = ventpy.RegulatoryConfig.peru() + a = ventpy.calculate_personnel_flow(n, alt, cfg) + b = ventpy.calculate_personnel_flow(n, alt, cfg) + assert a.q_personnel == b.q_personnel + assert a.flow_per_person_base == b.flow_per_person_base + + @given(malo=st.sampled_from([float("nan"), float("inf"), float("-inf")])) + @settings(PROP) + def test_altitud_no_finita_es_rechazada(self, malo): + cfg = ventpy.RegulatoryConfig.peru() + with pytest.raises(ValueError): + ventpy.calculate_personnel_flow(10, malo, cfg) + + @given(malo=st.sampled_from([float("nan"), float("inf"), float("-inf")])) + @settings(PROP) + def test_medicion_no_finita_es_rechazada(self, malo): + m = ventpy.ZoneMeasurement() + m.zone_name = "Z" + m.q_measured_m3min = malo + with pytest.raises(ValueError): + ventpy.CoverageCalculator.compare_zone(100.0, m) + + @given(malo=st.sampled_from([float("nan"), float("inf"), float("-inf")])) + @settings(PROP) + def test_resistencia_no_finita_es_rechazada(self, malo): + d = ventpy.NetworkDefinition() + d.branches = [_rama("A", "N1", "N2", malo), _rama("B", "N2", "N1", 0.5)] + with pytest.raises(ValueError): + ventpy.NetworkSolver.solve(d, ventpy.AtmosphericParams()) From 707f68e9897119ae431f64ee916e64bdc8ade193 Mon Sep 17 00:00:00 2001 From: Miqueas Quintanilla Date: Wed, 19 Aug 2026 20:37:12 -0500 Subject: [PATCH 2/3] chore: ignora el directorio de build en Release --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e79dd7b..b165735 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Build artifacts build/ build-tests/ +build-release/ _build/ dist/ wheelhouse/ From fcf7e28ef3ac678ddaac26614d0a378f9f61d236 Mon Sep 17 00:00:00 2001 From: Miqueas Quintanilla Date: Wed, 19 Aug 2026 20:38:09 -0500 Subject: [PATCH 3/3] ci: suite en las tres plataformas, sanitizers y cobertura con umbral El flujo existente solo construye ruedas al etiquetar una version: las pruebas nunca se ejecutaban de forma automatica. Se agrega un flujo que corre en cada propuesta de cambio: - ctest en Linux, macOS y Windows, en Debug y en Release por separado (la optimizacion puede alterar el comportamiento en coma flotante y varias pruebas verifican fronteras normativas exactas). - AddressSanitizer y UndefinedBehaviorSanitizer en Linux, unico lugar donde se detecta comportamiento indefinido. - Cobertura de lineas y ramas del nucleo con umbral minimo. - Suite de Python contra la extension compilada en las tres plataformas, incluidas las pruebas de escala y una comprobacion de la interfaz de linea de comandos. --- .github/workflows/tests.yml | 120 ++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..fe59f71 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,120 @@ +name: Tests + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Suite C++ en las tres plataformas y en ambas configuraciones. Debug y + # Release se ejecutan por separado porque la optimizacion puede alterar el + # comportamiento en coma flotante, y varias pruebas verifican fronteras + # normativas exactas. + cpp: + name: C++ ${{ matrix.os }} / ${{ matrix.build_type }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + build_type: [Debug, Release] + steps: + - uses: actions/checkout@v4 + - name: Configurar + run: cmake -S . -B build -DVENTPY_BUILD_TESTS=ON -DVENTPY_BUILD_PYTHON=OFF -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} + - name: Compilar + run: cmake --build build --config ${{ matrix.build_type }} --parallel + - name: Ejecutar pruebas + run: ctest --test-dir build -C ${{ matrix.build_type }} --output-on-failure + + # AddressSanitizer y UndefinedBehaviorSanitizer. El compilador de Windows no + # ofrece UBSan, de modo que este trabajo es el unico lugar donde se detecta + # comportamiento indefinido (lecturas sin inicializar, desbordes, division + # entre cero, conversiones invalidas). + sanitizers: + name: Sanitizers (ASan + UBSan) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Configurar con sanitizers + env: + FLAGS: -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=undefined -g -O1 + run: | + cmake -S . -B build-san \ + -DVENTPY_BUILD_TESTS=ON -DVENTPY_BUILD_PYTHON=OFF \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_CXX_FLAGS="$FLAGS" -DCMAKE_EXE_LINKER_FLAGS="$FLAGS" + - name: Compilar + run: cmake --build build-san --parallel + - name: Ejecutar pruebas bajo sanitizers + env: + ASAN_OPTIONS: detect_leaks=1:abort_on_error=1:strict_string_checks=1 + UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 + run: ctest --test-dir build-san --output-on-failure + + # Cobertura de lineas y ramas del nucleo. El umbral evita que la cobertura + # caiga sin que nadie lo note; se mide solo include/ventpy (la libreria), + # excluyendo pruebas y dependencias externas. + coverage: + name: Cobertura + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Instalar lcov + run: sudo apt-get update && sudo apt-get install -y lcov + - name: Configurar con instrumentacion + run: | + cmake -S . -B build-cov \ + -DVENTPY_BUILD_TESTS=ON -DVENTPY_BUILD_PYTHON=OFF \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_CXX_FLAGS="--coverage -O0 -g" \ + -DCMAKE_EXE_LINKER_FLAGS="--coverage" + - name: Compilar y ejecutar + run: | + cmake --build build-cov --parallel + ctest --test-dir build-cov --output-on-failure + - name: Medir y comprobar umbral + run: | + lcov --capture --directory build-cov --output-file cov.info \ + --rc branch_coverage=1 --ignore-errors mismatch,gcov,source + lcov --extract cov.info "*/include/ventpy/*" --output-file cov-core.info \ + --rc branch_coverage=1 --ignore-errors unused + lcov --list cov-core.info --rc branch_coverage=1 + LINES=$(lcov --summary cov-core.info --rc branch_coverage=1 2>&1 | grep -oP 'lines\.+: \K[0-9.]+') + echo "Cobertura de lineas del nucleo: $LINES %" + awk -v v="$LINES" 'BEGIN { if (v+0 < 85.0) { print "Por debajo del umbral (85%)"; exit 1 } }' + - uses: actions/upload-artifact@v4 + if: always() + with: + name: cobertura + path: cov-core.info + + # Suite Python contra la extension compilada, en las tres plataformas. + # Incluye las pruebas basadas en propiedades, que generan sus propios casos. + python: + name: Python ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Instalar el paquete con sus extras de prueba + run: python -m pip install --upgrade pip && python -m pip install ".[test]" + - name: Ejecutar la suite + run: python -m pytest tests/python -q + - name: Ejecutar las pruebas de escala + run: python -m pytest tests/python -q -m slow + - name: Comprobar la interfaz de linea de comandos + run: | + ventpy --help + ventpy lmp --norma peru