diff --git a/tests/conftest.py b/tests/conftest.py index ef2f072..1a24d20 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,8 +31,7 @@ def _check_module_import(module_name, error_message): # In CI (indicated by CI env var), fail instead of skip if os.environ.get("CI"): raise RuntimeError( - "CFD Python C extension not built. " - "The wheel may be missing the compiled extension." + "CFD Python C extension not built. The wheel may be missing the compiled extension." ) else: pytest.skip( diff --git a/tests/test_boundary_conditions.py b/tests/test_boundary_conditions.py index 64eada1..b854074 100644 --- a/tests/test_boundary_conditions.py +++ b/tests/test_boundary_conditions.py @@ -23,6 +23,11 @@ def test_bc_type_constants_exist(self): for const_name in bc_types: assert hasattr(cfd_python, const_name), f"Missing constant: {const_name}" + def test_bc_type_symmetry_exists(self): + """Test BC_TYPE_SYMMETRY constant is defined (v0.2.0)""" + assert hasattr(cfd_python, "BC_TYPE_SYMMETRY") + assert isinstance(cfd_python.BC_TYPE_SYMMETRY, int) + def test_bc_type_constants_are_integers(self): """Test BC_TYPE_* constants are integers""" bc_types = [ @@ -65,6 +70,12 @@ def test_bc_edge_constants_exist(self): for const_name in bc_edges: assert hasattr(cfd_python, const_name), f"Missing constant: {const_name}" + def test_bc_3d_edge_constants_exist(self): + """Test BC_EDGE_FRONT and BC_EDGE_BACK constants (v0.2.0)""" + for name in ["BC_EDGE_FRONT", "BC_EDGE_BACK"]: + assert hasattr(cfd_python, name), f"Missing: {name}" + assert isinstance(getattr(cfd_python, name), int) + def test_bc_edge_constants_are_integers(self): """Test BC_EDGE_* constants are integers""" bc_edges = [ @@ -178,6 +189,14 @@ def test_bc_backend_available_returns_bool(self): result, bool ), f"bc_backend_available should return bool for {backend}" + def test_bc_set_backend_invalid_returns_bool(self): + """Test setting an invalid backend returns a boolean""" + original = cfd_python.bc_get_backend() + result = cfd_python.bc_set_backend(9999) + assert isinstance(result, bool) + # Restore original backend + cfd_python.bc_set_backend(original) + class TestBCApplyScalar: """Test bc_apply_scalar function""" @@ -236,6 +255,13 @@ def test_bc_apply_velocity_periodic(self): result = cfd_python.bc_apply_velocity(u, v, nx, ny, cfd_python.BC_TYPE_PERIODIC) assert result is None + def test_bc_apply_velocity_invalid_size_raises(self): + """Test bc_apply_velocity with mismatched u size raises error""" + u = [0.0] * 6 # Wrong size for 4x4 + v = [0.0] * 16 + with pytest.raises(ValueError): + cfd_python.bc_apply_velocity(u, v, 4, 4, cfd_python.BC_TYPE_NEUMANN) + class TestBCApplyDirichlet: """Test bc_apply_dirichlet function""" @@ -266,6 +292,12 @@ def test_bc_apply_dirichlet_fixed_values(self): for i in range(nx): assert field[(ny - 1) * nx + i] == top, f"Top boundary at col {i}" + def test_bc_apply_dirichlet_invalid_size_raises(self): + """Test bc_apply_dirichlet with mismatched size raises error""" + field = [0.0] * 6 # Wrong size for 4x4 + with pytest.raises(ValueError): + cfd_python.bc_apply_dirichlet(field, 4, 4, 1.0, 2.0, 3.0, 4.0) + class TestBCApplyNoslip: """Test bc_apply_noslip function""" @@ -305,6 +337,13 @@ def test_bc_apply_noslip_zeros_boundaries(self): assert u[idx] == 0.0, f"u top boundary at {i}" assert v[idx] == 0.0, f"v top boundary at {i}" + def test_bc_apply_noslip_invalid_size_raises(self): + """Test bc_apply_noslip with mismatched u size raises error""" + u = [0.0] * 6 # Wrong size for 4x4 + v = [0.0] * 16 + with pytest.raises(ValueError): + cfd_python.bc_apply_noslip(u, v, 4, 4) + class TestBCApplyInlet: """Test inlet boundary condition functions""" @@ -350,6 +389,38 @@ def test_bc_apply_inlet_parabolic_left(self): assert left_u[ny // 2] >= left_u[0], "Parabolic profile should peak near center" assert left_u[ny // 2] >= left_u[ny - 1], "Parabolic profile should peak near center" + def test_bc_apply_inlet_uniform_invalid_size_raises(self): + """Test bc_apply_inlet_uniform with mismatched size raises error""" + u = [0.0] * 6 # Wrong size for 4x4 + v = [0.0] * 16 + with pytest.raises(ValueError): + cfd_python.bc_apply_inlet_uniform(u, v, 4, 4, 1.0, 0.0, cfd_python.BC_EDGE_LEFT) + + def test_bc_apply_inlet_parabolic_invalid_size_raises(self): + """Test bc_apply_inlet_parabolic with mismatched size raises error""" + u = [0.0] * 6 # Wrong size for 4x4 + v = [0.0] * 16 + with pytest.raises(ValueError): + cfd_python.bc_apply_inlet_parabolic(u, v, 4, 4, 1.0, cfd_python.BC_EDGE_LEFT) + + @pytest.mark.parametrize( + "edge", + [ + cfd_python.BC_EDGE_LEFT, + cfd_python.BC_EDGE_RIGHT, + cfd_python.BC_EDGE_BOTTOM, + cfd_python.BC_EDGE_TOP, + ], + ) + def test_bc_apply_inlet_uniform_all_edges(self, edge): + """Test uniform inlet works on all edges""" + nx, ny = 6, 6 + size = nx * ny + u = [0.0] * size + v = [0.0] * size + result = cfd_python.bc_apply_inlet_uniform(u, v, nx, ny, 1.0, 0.0, edge) + assert result is None + class TestBCApplyOutlet: """Test outlet boundary condition functions""" @@ -389,6 +460,58 @@ def test_bc_apply_outlet_velocity_right(self): assert u[boundary_idx] == u[interior_idx], f"u outlet should copy interior at row {j}" assert v[boundary_idx] == v[interior_idx], f"v outlet should copy interior at row {j}" + def test_bc_apply_outlet_scalar_invalid_size_raises(self): + """Test bc_apply_outlet_scalar with mismatched size raises error""" + field = [0.0] * 6 # Wrong size for 4x4 + with pytest.raises(ValueError): + cfd_python.bc_apply_outlet_scalar(field, 4, 4, cfd_python.BC_EDGE_RIGHT) + + def test_bc_apply_outlet_velocity_invalid_size_raises(self): + """Test bc_apply_outlet_velocity with mismatched u size raises error""" + u = [0.0] * 6 # Wrong size for 4x4 + v = [0.0] * 16 + with pytest.raises(ValueError): + cfd_python.bc_apply_outlet_velocity(u, v, 4, 4, cfd_python.BC_EDGE_RIGHT) + + @pytest.mark.parametrize( + "edge", + [ + cfd_python.BC_EDGE_LEFT, + cfd_python.BC_EDGE_RIGHT, + cfd_python.BC_EDGE_BOTTOM, + cfd_python.BC_EDGE_TOP, + ], + ) + def test_bc_apply_outlet_scalar_all_edges(self, edge): + """Test outlet scalar works on all edges""" + nx, ny = 6, 6 + field = [float(i) for i in range(nx * ny)] + result = cfd_python.bc_apply_outlet_scalar(field, nx, ny, edge) + assert result is None + + +class TestBCStress: + """Stress tests for boundary condition functions.""" + + def test_bc_apply_scalar_repeated_calls(self): + """Verify no crashes or leaks under repeated bc_apply_scalar calls.""" + for _ in range(100): + field = [1.0] * 16 + cfd_python.bc_apply_scalar(field, 4, 4, cfd_python.BC_TYPE_NEUMANN) + + def test_bc_apply_velocity_repeated_calls(self): + """Verify no crashes or leaks under repeated bc_apply_velocity calls.""" + for _ in range(100): + u = [1.0] * 16 + v = [0.5] * 16 + cfd_python.bc_apply_velocity(u, v, 4, 4, cfd_python.BC_TYPE_NEUMANN) + + def test_bc_apply_dirichlet_repeated_calls(self): + """Verify no crashes or leaks under repeated bc_apply_dirichlet calls.""" + for _ in range(100): + field = [0.0] * 16 + cfd_python.bc_apply_dirichlet(field, 4, 4, 1.0, 2.0, 3.0, 4.0) + class TestBCFunctionsExported: """Test that all BC functions are properly exported""" @@ -423,11 +546,16 @@ def test_bc_constants_in_all(self): "BC_TYPE_NOSLIP", "BC_TYPE_INLET", "BC_TYPE_OUTLET", + # v0.2.0 + "BC_TYPE_SYMMETRY", # Edges "BC_EDGE_LEFT", "BC_EDGE_RIGHT", "BC_EDGE_BOTTOM", "BC_EDGE_TOP", + # v0.2.0 + "BC_EDGE_FRONT", + "BC_EDGE_BACK", # Backends "BC_BACKEND_AUTO", "BC_BACKEND_SCALAR", diff --git a/tests/test_cpu_features.py b/tests/test_cpu_features.py index feb341d..974ac69 100644 --- a/tests/test_cpu_features.py +++ b/tests/test_cpu_features.py @@ -150,7 +150,17 @@ def test_has_simd_matches_arch(self): ) -@pytest.mark.skip(reason=STRETCHED_GRID_BUG_REASON) +def _check_stretched_grid_buggy(): + """Probe for the known stretched grid formula bug. + + Returns True if the bug is present, False otherwise. + Raises on unexpected failures (missing symbol, type error, etc.). + """ + grid = cfd_python.create_grid_stretched(5, 5, 0.0, 1.0, 0.0, 1.0, 1.5) + # Bug: x_coords[-1] should be close to xmax, not xmin + return abs(grid["x_coords"][-1] - grid["xmax"]) > 0.1 + + class TestCreateGridStretched: """Test create_grid_stretched function. @@ -168,6 +178,11 @@ class TestCreateGridStretched: - Higher beta clusters points near BOUNDARIES (useful for boundary layers) """ + @pytest.fixture(autouse=True) + def _skip_if_buggy(self): + if _check_stretched_grid_buggy(): + pytest.skip(STRETCHED_GRID_BUG_REASON) + def test_create_grid_stretched_basic(self): """Test basic stretched grid creation""" grid = cfd_python.create_grid_stretched(10, 10, 0.0, 1.0, 0.0, 1.0, 1.0) @@ -245,6 +260,12 @@ def test_create_grid_stretched_invalid_beta_raises(self): with pytest.raises(ValueError): cfd_python.create_grid_stretched(10, 10, 0.0, 1.0, 0.0, 1.0, -1.0) + def test_create_grid_stretched_repeated_calls(self): + """Verify no crashes or leaks under repeated calls.""" + for _ in range(100): + grid = cfd_python.create_grid_stretched(5, 5, 0.0, 1.0, 0.0, 1.0, 1.5) + assert grid is not None + class TestPhase6Exports: """Test that all Phase 6 functions are properly exported""" diff --git a/tests/test_derived_fields.py b/tests/test_derived_fields.py index 2f4a0c8..3f09150 100644 --- a/tests/test_derived_fields.py +++ b/tests/test_derived_fields.py @@ -199,6 +199,34 @@ def test_compute_flow_statistics_wrong_type_raises(self): cfd_python.compute_flow_statistics([1.0], "not list", [1.0], 1, 1) +class TestDerivedFieldsStress: + """Stress tests for derived field functions.""" + + def test_calculate_field_stats_repeated_calls(self): + """Verify no crashes or leaks under repeated calls.""" + data = [float(i) for i in range(100)] + for _ in range(100): + stats = cfd_python.calculate_field_stats(data) + assert stats is not None + + def test_compute_velocity_magnitude_repeated_calls(self): + """Verify no crashes or leaks under repeated calls.""" + u = [1.0] * 16 + v = [1.0] * 16 + for _ in range(100): + result = cfd_python.compute_velocity_magnitude(u, v, 4, 4) + assert result is not None + + def test_compute_flow_statistics_repeated_calls(self): + """Verify no crashes or leaks under repeated calls.""" + u = [1.0] * 16 + v = [0.5] * 16 + p = [100.0] * 16 + for _ in range(100): + result = cfd_python.compute_flow_statistics(u, v, p, 4, 4) + assert result is not None + + class TestDerivedFieldsExported: """Test that all derived fields functions are properly exported""" diff --git a/tests/test_errors.py b/tests/test_errors.py index dcf53db..0e0a414 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -117,6 +117,20 @@ def test_cfd_max_iter_error(self): assert isinstance(err, cfd_python.CFDError) assert err.status_code == -7 + def test_cfd_limit_exceeded_error_inheritance(self): + """Test CFDLimitExceededError inherits from both CFDError and ResourceWarning""" + err = cfd_python.CFDLimitExceededError("limit exceeded", -8) + assert isinstance(err, cfd_python.CFDError) + assert isinstance(err, ResourceWarning) + assert err.status_code == -8 + + def test_cfd_not_found_error_inheritance(self): + """Test CFDNotFoundError inherits from both CFDError and LookupError""" + err = cfd_python.CFDNotFoundError("not found", -9) + assert isinstance(err, cfd_python.CFDError) + assert isinstance(err, LookupError) + assert err.status_code == -9 + class TestRaiseForStatus: """Test raise_for_status function""" @@ -168,6 +182,18 @@ def test_max_iter_raises_cfd_max_iter_error(self): cfd_python.raise_for_status(-7) assert exc_info.value.status_code == -7 + def test_limit_exceeded_raises_cfd_limit_exceeded_error(self): + """Test that -8 raises CFDLimitExceededError""" + with pytest.raises(cfd_python.CFDLimitExceededError) as exc_info: + cfd_python.raise_for_status(-8) + assert exc_info.value.status_code == -8 + + def test_not_found_raises_cfd_not_found_error(self): + """Test that -9 raises CFDNotFoundError""" + with pytest.raises(cfd_python.CFDNotFoundError) as exc_info: + cfd_python.raise_for_status(-9) + assert exc_info.value.status_code == -9 + def test_unknown_error_raises_cfd_error(self): """Test that unknown negative codes raise CFDError""" with pytest.raises(cfd_python.CFDError) as exc_info: @@ -194,8 +220,70 @@ def test_exceptions_in_all(self): "CFDUnsupportedError", "CFDDivergedError", "CFDMaxIterError", + "CFDLimitExceededError", + "CFDNotFoundError", "raise_for_status", ] for name in exceptions: assert name in cfd_python.__all__, f"{name} should be in __all__" assert hasattr(cfd_python, name), f"{name} should be accessible" + + +class TestStatusConstants: + """Test CFD_SUCCESS and CFD_ERROR_* status code constants.""" + + _STATUS_CONSTANTS = [ + ("CFD_SUCCESS", 0), + ("CFD_ERROR", -1), + ("CFD_ERROR_NOMEM", -2), + ("CFD_ERROR_INVALID", -3), + ("CFD_ERROR_IO", -4), + ("CFD_ERROR_UNSUPPORTED", -5), + ("CFD_ERROR_DIVERGED", -6), + ("CFD_ERROR_MAX_ITER", -7), + ] + + _NEW_STATUS_CONSTANTS = [ + ("CFD_ERROR_LIMIT_EXCEEDED", -8), + ("CFD_ERROR_NOT_FOUND", -9), + ] + + def test_status_constants_exist(self): + for name, _ in self._STATUS_CONSTANTS: + assert hasattr(cfd_python, name), f"Missing constant: {name}" + + def test_status_constants_are_integers(self): + for name, _ in self._STATUS_CONSTANTS: + assert isinstance(getattr(cfd_python, name), int) + + def test_status_constants_values(self): + for name, expected in self._STATUS_CONSTANTS: + assert getattr(cfd_python, name) == expected, f"{name} should be {expected}" + + def test_status_constants_in_all(self): + for name, _ in self._STATUS_CONSTANTS + self._NEW_STATUS_CONSTANTS: + assert name in cfd_python.__all__, f"{name} should be in __all__" + + def test_new_status_constants_exist(self): + for name, _ in self._NEW_STATUS_CONSTANTS: + assert hasattr(cfd_python, name), f"Missing constant: {name}" + + def test_new_status_constants_values(self): + for name, expected in self._NEW_STATUS_CONSTANTS: + assert getattr(cfd_python, name) == expected, f"{name} should be {expected}" + + +class TestClearError: + """Test clear_error function.""" + + def test_clear_error_does_not_raise(self): + cfd_python.clear_error() + + def test_clear_error_resets_last_status(self): + cfd_python.clear_error() + status = cfd_python.get_last_status() + assert status == 0 + + def test_clear_error_repeated_calls(self): + for _ in range(50): + cfd_python.clear_error() diff --git a/tests/test_gpu.py b/tests/test_gpu.py new file mode 100644 index 0000000..7377d40 --- /dev/null +++ b/tests/test_gpu.py @@ -0,0 +1,63 @@ +"""Tests for GPU device management functions (v0.2.0).""" + +import pytest + +import cfd_python + + +class TestGPUFunctions: + """Tests for gpu_is_available(), gpu_get_device_info(), etc.""" + + def test_gpu_is_available_returns_bool(self): + result = cfd_python.gpu_is_available() + assert isinstance(result, bool) + + def test_gpu_get_device_info_returns_list(self): + result = cfd_python.gpu_get_device_info() + assert isinstance(result, list) + + def test_gpu_get_device_info_entries_are_dicts(self): + devices = cfd_python.gpu_get_device_info() + for device in devices: + assert isinstance(device, dict) + + def test_gpu_get_default_config_returns_dict(self): + result = cfd_python.gpu_get_default_config() + assert isinstance(result, dict) + + def test_gpu_is_available_consistent_with_device_info(self): + available = cfd_python.gpu_is_available() + devices = cfd_python.gpu_get_device_info() + if available: + assert len(devices) > 0 + else: + assert len(devices) == 0 + + def test_gpu_functions_repeated_calls(self): + """Verify no crashes or leaks under repeated calls.""" + for _ in range(50): + cfd_python.gpu_is_available() + cfd_python.gpu_get_device_info() + cfd_python.gpu_get_default_config() + + def test_gpu_select_device_invalid_id(self): + """Test gpu_select_device with invalid device ID.""" + # If no GPU available, selecting any device should fail or be a no-op + if not cfd_python.gpu_is_available(): + with pytest.raises(RuntimeError): + cfd_python.gpu_select_device(9999) + else: + # With GPU, very high ID should fail + devices = cfd_python.gpu_get_device_info() + with pytest.raises(RuntimeError): + cfd_python.gpu_select_device(len(devices) + 100) + + def test_gpu_functions_in_all(self): + funcs = [ + "gpu_is_available", + "gpu_get_device_info", + "gpu_select_device", + "gpu_get_default_config", + ] + for name in funcs: + assert name in cfd_python.__all__, f"{name} not in __all__" diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py new file mode 100644 index 0000000..ce4d461 --- /dev/null +++ b/tests/test_lifecycle.py @@ -0,0 +1,99 @@ +"""Tests for library lifecycle and version functions (v0.2.0).""" + +import pytest + +import cfd_python + + +class TestLibraryLifecycle: + """Tests for init(), finalize(), is_initialized().""" + + @pytest.fixture(autouse=True) + def _restore_init_state(self): + """Snapshot and restore library init state so tests don't leak side-effects.""" + was_initialized = cfd_python.is_initialized() + yield + if was_initialized: + cfd_python.init() + else: + cfd_python.finalize() + + def test_is_initialized_returns_bool(self): + result = cfd_python.is_initialized() + assert isinstance(result, bool) + + def test_init_succeeds(self): + cfd_python.init() + assert cfd_python.is_initialized() + + def test_finalize_succeeds(self): + cfd_python.init() + cfd_python.finalize() + + def test_repeated_init_safe(self): + """Verify repeated init calls do not crash.""" + for _ in range(10): + cfd_python.init() + assert cfd_python.is_initialized() + + def test_init_finalize_cycle(self): + """Verify init/finalize can be cycled without errors.""" + for _ in range(5): + cfd_python.init() + cfd_python.finalize() + + def test_double_finalize_safe(self): + """Verify calling finalize twice does not crash.""" + cfd_python.init() + cfd_python.finalize() + cfd_python.finalize() # Should not crash + + +class TestCFDVersion: + """Tests for get_cfd_version() and version constants.""" + + def test_get_cfd_version_returns_string(self): + result = cfd_python.get_cfd_version() + assert isinstance(result, str) + assert len(result) > 0 + + def test_get_cfd_version_format(self): + version = cfd_python.get_cfd_version() + parts = version.split(".") + assert len(parts) >= 2, f"Version '{version}' should have at least major.minor" + + def test_get_cfd_version_repeated_calls(self): + """Verify no leaks or crashes under repeated calls.""" + for _ in range(100): + result = cfd_python.get_cfd_version() + assert isinstance(result, str) + + def test_version_constants_exist(self): + for name in ["CFD_VERSION_MAJOR", "CFD_VERSION_MINOR", "CFD_VERSION_PATCH"]: + assert hasattr(cfd_python, name), f"Missing constant: {name}" + + def test_version_constants_are_integers(self): + for name in ["CFD_VERSION_MAJOR", "CFD_VERSION_MINOR", "CFD_VERSION_PATCH"]: + assert isinstance(getattr(cfd_python, name), int) + + def test_version_constants_non_negative(self): + for name in ["CFD_VERSION_MAJOR", "CFD_VERSION_MINOR", "CFD_VERSION_PATCH"]: + assert getattr(cfd_python, name) >= 0 + + def test_version_constants_match_string(self): + """Check version string format and constant consistency. + + Note: get_cfd_version() returns the runtime library version which may + differ from the compile-time CFD_VERSION_* constants if headers and + libraries are from different builds. + """ + version_str = cfd_python.get_cfd_version() + parts = version_str.split(".") + # Version string should have numeric parts + assert all(p.isdigit() for p in parts), f"Non-numeric version parts in '{version_str}'" + # Constants should form a valid version + major = cfd_python.CFD_VERSION_MAJOR + minor = cfd_python.CFD_VERSION_MINOR + patch = cfd_python.CFD_VERSION_PATCH + constructed = f"{major}.{minor}.{patch}" + assert len(constructed) > 0 diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..db1303a --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,60 @@ +"""Tests for logging functions and constants (v0.2.0).""" + +import pytest + +import cfd_python + + +class TestLogConstants: + """Tests for CFD_LOG_LEVEL_* constants.""" + + _LEVELS = ["CFD_LOG_LEVEL_INFO", "CFD_LOG_LEVEL_WARNING", "CFD_LOG_LEVEL_ERROR"] + + def test_log_level_constants_exist(self): + for name in self._LEVELS: + assert hasattr(cfd_python, name), f"Missing: {name}" + + def test_log_level_constants_are_integers(self): + for name in self._LEVELS: + assert isinstance(getattr(cfd_python, name), int) + + def test_log_level_constants_ordered(self): + assert cfd_python.CFD_LOG_LEVEL_INFO < cfd_python.CFD_LOG_LEVEL_WARNING + assert cfd_python.CFD_LOG_LEVEL_WARNING < cfd_python.CFD_LOG_LEVEL_ERROR + + def test_log_level_constants_in_all(self): + for name in self._LEVELS: + assert name in cfd_python.__all__, f"{name} not in __all__" + + +class TestSetLogCallback: + """Tests for set_log_callback() function.""" + + def test_set_log_callback_none_clears(self): + cfd_python.set_log_callback(None) # Should not raise + + def test_set_log_callback_callable(self): + messages = [] + + def callback(level, msg): + messages.append((level, msg)) + + cfd_python.set_log_callback(callback) + cfd_python.set_log_callback(None) # Cleanup + + def test_set_log_callback_lambda(self): + cfd_python.set_log_callback(lambda level, msg: None) + cfd_python.set_log_callback(None) # Cleanup + + def test_set_log_callback_invalid_raises(self): + with pytest.raises(TypeError): + cfd_python.set_log_callback("not_callable") + + def test_set_log_callback_repeated(self): + """Verify repeated callback registration does not leak.""" + for _ in range(50): + cfd_python.set_log_callback(lambda level, msg: None) + cfd_python.set_log_callback(None) # Cleanup + + def test_set_log_callback_in_all(self): + assert "set_log_callback" in cfd_python.__all__ diff --git a/tests/test_module.py b/tests/test_module.py index b2143f5..a9948c0 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -4,6 +4,8 @@ import re +import pytest + import cfd_python @@ -101,3 +103,19 @@ def test_output_constants_in_all(self): ] for const_name in output_constants: assert const_name in cfd_python.__all__, f"{const_name} should be in __all__" + + +class TestCoreExportsCompleteness: + """Verify every symbol in _CORE_EXPORTS is actually exposed at runtime.""" + + @pytest.mark.parametrize("name", cfd_python._CORE_EXPORTS) + def test_core_export_accessible(self, name): + """Each _CORE_EXPORTS entry must be accessible via getattr.""" + assert hasattr( + cfd_python, name + ), f"{name} is declared in _CORE_EXPORTS but not accessible on the module" + + @pytest.mark.parametrize("name", cfd_python._CORE_EXPORTS) + def test_core_export_in_all(self, name): + """Each _CORE_EXPORTS entry must appear in __all__.""" + assert name in cfd_python.__all__, f"{name} is in _CORE_EXPORTS but missing from __all__" diff --git a/tests/test_output.py b/tests/test_output.py index 485f846..d249986 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -2,10 +2,36 @@ Tests for VTK and CSV output functions """ +import os +import tempfile + import pytest import cfd_python +# Check if write_csv_timeseries actually creates files +_CSV_SKIP_REASON = ( + "write_csv_timeseries not creating files - investigate CFD library implementation" +) +_CSV_WORKS = False + +with tempfile.TemporaryDirectory() as _td: + _tf = os.path.join(_td, "_probe.csv") + cfd_python.write_csv_timeseries( + _tf, + step=0, + time=0.0, + u_data=[0.0] * 4, + v_data=[0.0] * 4, + p_data=[0.0] * 4, + nx=2, + ny=2, + dt=0.001, + iterations=1, + create_new=True, + ) + _CSV_WORKS = os.path.exists(_tf) and os.path.getsize(_tf) > 0 + class TestVTKOutput: """Test VTK output functions""" @@ -71,9 +97,7 @@ def test_write_vtk_vector_validates_size(self, tmp_path): ) -@pytest.mark.skip( - reason="write_csv_timeseries not creating files - investigate CFD library implementation" -) +@pytest.mark.skipif(not _CSV_WORKS, reason=_CSV_SKIP_REASON) class TestCSVOutput: """Test CSV output functions""" @@ -158,3 +182,8 @@ def test_set_output_dir_returns_none(self, tmp_path): """Test set_output_dir returns None""" result = cfd_python.set_output_dir(str(tmp_path)) assert result is None + + def test_set_output_dir_invalid_type_raises(self): + """Test set_output_dir with non-string raises TypeError""" + with pytest.raises(TypeError): + cfd_python.set_output_dir(123) diff --git a/tests/test_poisson_solver.py b/tests/test_poisson_solver.py new file mode 100644 index 0000000..6338a6a --- /dev/null +++ b/tests/test_poisson_solver.py @@ -0,0 +1,195 @@ +"""Tests for Poisson solver functions and constants (v0.2.0).""" + +import cfd_python + + +class TestPoissonMethodConstants: + """Tests for POISSON_METHOD_* constants.""" + + _METHODS = [ + "POISSON_METHOD_JACOBI", + "POISSON_METHOD_GAUSS_SEIDEL", + "POISSON_METHOD_SOR", + "POISSON_METHOD_REDBLACK_SOR", + "POISSON_METHOD_CG", + "POISSON_METHOD_BICGSTAB", + "POISSON_METHOD_MULTIGRID", + ] + + def test_method_constants_exist(self): + for name in self._METHODS: + assert hasattr(cfd_python, name), f"Missing: {name}" + + def test_method_constants_are_integers(self): + for name in self._METHODS: + assert isinstance(getattr(cfd_python, name), int) + + def test_method_constants_unique(self): + values = [getattr(cfd_python, name) for name in self._METHODS] + assert len(values) == len(set(values)), "Duplicate method constant values" + + def test_method_constants_in_all(self): + for name in self._METHODS: + assert name in cfd_python.__all__, f"{name} not in __all__" + + +class TestPoissonBackendConstants: + """Tests for POISSON_BACKEND_* constants.""" + + _BACKENDS = [ + "POISSON_BACKEND_AUTO", + "POISSON_BACKEND_SCALAR", + "POISSON_BACKEND_OMP", + "POISSON_BACKEND_SIMD", + "POISSON_BACKEND_GPU", + ] + + def test_backend_constants_exist(self): + for name in self._BACKENDS: + assert hasattr(cfd_python, name), f"Missing: {name}" + + def test_backend_constants_are_integers(self): + for name in self._BACKENDS: + assert isinstance(getattr(cfd_python, name), int) + + def test_backend_constants_unique(self): + values = [getattr(cfd_python, name) for name in self._BACKENDS] + assert len(values) == len(set(values)), "Duplicate backend constant values" + + def test_backend_constants_in_all(self): + for name in self._BACKENDS: + assert name in cfd_python.__all__, f"{name} not in __all__" + + +class TestPoissonSolverPresets: + """Tests for POISSON_SOLVER_* preset constants.""" + + _PRESETS = [ + "POISSON_SOLVER_SOR_SCALAR", + "POISSON_SOLVER_JACOBI_SIMD", + "POISSON_SOLVER_REDBLACK_SIMD", + "POISSON_SOLVER_REDBLACK_OMP", + "POISSON_SOLVER_REDBLACK_SCALAR", + "POISSON_SOLVER_CG_SCALAR", + "POISSON_SOLVER_CG_SIMD", + "POISSON_SOLVER_CG_OMP", + ] + + def test_solver_preset_constants_exist(self): + for name in self._PRESETS: + assert hasattr(cfd_python, name), f"Missing: {name}" + + def test_solver_preset_constants_are_integers(self): + for name in self._PRESETS: + assert isinstance(getattr(cfd_python, name), int) + + def test_solver_preset_constants_unique(self): + values = [getattr(cfd_python, name) for name in self._PRESETS] + assert len(values) == len(set(values)), "Duplicate solver preset values" + + +class TestPoissonPrecondConstants: + """Tests for POISSON_PRECOND_* constants.""" + + _PRECONDS = ["POISSON_PRECOND_NONE", "POISSON_PRECOND_JACOBI"] + + def test_precond_constants_exist(self): + for name in self._PRECONDS: + assert hasattr(cfd_python, name), f"Missing: {name}" + + def test_precond_constants_are_integers(self): + for name in self._PRECONDS: + assert isinstance(getattr(cfd_python, name), int) + + def test_precond_constants_unique(self): + values = [getattr(cfd_python, name) for name in self._PRECONDS] + assert len(values) == len(set(values)), "Duplicate preconditioner constant values" + + def test_precond_constants_in_all(self): + for name in self._PRECONDS: + assert name in cfd_python.__all__, f"{name} not in __all__" + + +class TestPoissonFunctions: + """Tests for Poisson solver query and configuration functions.""" + + def test_get_default_poisson_params_returns_dict(self): + result = cfd_python.get_default_poisson_params() + assert isinstance(result, dict) + + def test_default_params_has_expected_keys(self): + params = cfd_python.get_default_poisson_params() + expected = { + "tolerance", + "absolute_tolerance", + "max_iterations", + "omega", + "check_interval", + "verbose", + "preconditioner", + } + for key in expected: + assert key in params, f"Missing key: {key}" + + def test_default_params_tolerance_positive(self): + params = cfd_python.get_default_poisson_params() + assert params["tolerance"] > 0 + + def test_default_params_max_iterations_positive(self): + params = cfd_python.get_default_poisson_params() + assert params["max_iterations"] > 0 + + def test_poisson_get_backend_returns_int(self): + result = cfd_python.poisson_get_backend() + assert isinstance(result, int) + + def test_poisson_get_backend_name_returns_string(self): + result = cfd_python.poisson_get_backend_name() + assert isinstance(result, str) + assert len(result) > 0 + + def test_poisson_backend_available_returns_bool(self): + result = cfd_python.poisson_backend_available(cfd_python.POISSON_BACKEND_SCALAR) + assert isinstance(result, bool) + + def test_poisson_scalar_backend_always_available(self): + assert cfd_python.poisson_backend_available(cfd_python.POISSON_BACKEND_SCALAR) + + def test_poisson_set_backend_scalar(self): + original = cfd_python.poisson_get_backend() + result = cfd_python.poisson_set_backend(cfd_python.POISSON_BACKEND_SCALAR) + assert isinstance(result, bool) + cfd_python.poisson_set_backend(original) # restore + + def test_poisson_simd_available_returns_bool(self): + result = cfd_python.poisson_simd_available() + assert isinstance(result, bool) + + def test_poisson_functions_repeated_calls(self): + """Verify no crashes or leaks under repeated Poisson function calls.""" + for _ in range(50): + cfd_python.get_default_poisson_params() + cfd_python.poisson_get_backend() + cfd_python.poisson_get_backend_name() + + def test_poisson_set_backend_invalid_returns_false(self): + """Test setting an invalid backend returns False.""" + result = cfd_python.poisson_set_backend(9999) + assert result is False + + def test_poisson_backend_available_invalid_returns_false(self): + """Test invalid backend is not available.""" + result = cfd_python.poisson_backend_available(9999) + assert result is False + + def test_poisson_functions_in_all(self): + funcs = [ + "get_default_poisson_params", + "poisson_get_backend", + "poisson_get_backend_name", + "poisson_set_backend", + "poisson_backend_available", + "poisson_simd_available", + ] + for name in funcs: + assert name in cfd_python.__all__, f"{name} not in __all__" diff --git a/tests/test_simulation.py b/tests/test_simulation.py index 2f6aa3a..db35961 100644 --- a/tests/test_simulation.py +++ b/tests/test_simulation.py @@ -37,6 +37,25 @@ def test_create_grid_has_coordinates(self): assert len(grid["x_coords"]) == 5 assert len(grid["y_coords"]) == 3 + def test_create_grid_3d(self): + """Test create_grid with 3D parameters (nz > 1)""" + grid = cfd_python.create_grid(5, 5, 0.0, 1.0, 0.0, 1.0, nz=4, zmin=0.0, zmax=1.0) + assert isinstance(grid, dict) + assert grid["nz"] == 4 + assert grid["zmin"] == 0.0 + assert grid["zmax"] == 1.0 + + def test_create_grid_3d_has_z_coords(self): + """Test 3D grid has z_coords array""" + grid = cfd_python.create_grid(5, 5, 0.0, 1.0, 0.0, 1.0, nz=3, zmin=0.0, zmax=1.0) + assert "z_coords" in grid + assert len(grid["z_coords"]) == 3 + + def test_create_grid_default_nz_is_2d(self): + """Test that default nz=1 creates a 2D grid""" + grid = cfd_python.create_grid(5, 5, 0.0, 1.0, 0.0, 1.0) + assert grid["nz"] == 1 + class TestSolverParams: """Test solver parameters function""" @@ -111,6 +130,12 @@ def test_run_simulation_invalid_solver(self): with pytest.raises(RuntimeError): cfd_python.run_simulation(5, 5, steps=3, solver_type="nonexistent_solver") + def test_run_simulation_minimum_grid(self): + """Test run_simulation with minimum valid grid size""" + result = cfd_python.run_simulation(3, 3, steps=1) + assert isinstance(result, list) + assert len(result) == 9 + class TestRunSimulationWithParams: """Test run_simulation_with_params function""" @@ -161,3 +186,15 @@ def test_with_output_file(self, tmp_path): ) assert "output_file" in result assert output_file.exists() + + def test_invalid_solver_raises(self): + """Test run_simulation_with_params with invalid solver raises error""" + with pytest.raises(RuntimeError): + cfd_python.run_simulation_with_params( + 5, 5, 0.0, 1.0, 0.0, 1.0, solver_type="nonexistent_solver" + ) + + def test_invalid_nx_type_raises(self): + """Test run_simulation_with_params with non-int nx raises TypeError""" + with pytest.raises(TypeError): + cfd_python.run_simulation_with_params("five", 5, 0.0, 1.0, 0.0, 1.0) diff --git a/tests/test_vtk_output.py b/tests/test_vtk_output.py index c78632d..d4c2a35 100644 --- a/tests/test_vtk_output.py +++ b/tests/test_vtk_output.py @@ -5,10 +5,36 @@ particularly the fixed use-after-free bug in write_vtk_vector. """ +import os +import tempfile + import pytest import cfd_python +# Check if write_csv_timeseries actually creates files +_CSV_SKIP_REASON = ( + "write_csv_timeseries not creating files - investigate CFD library implementation" +) +_CSV_WORKS = False + +with tempfile.TemporaryDirectory() as _td: + _tf = os.path.join(_td, "_probe.csv") + cfd_python.write_csv_timeseries( + _tf, + step=0, + time=0.0, + u_data=[0.0] * 4, + v_data=[0.0] * 4, + p_data=[0.0] * 4, + nx=2, + ny=2, + dt=0.001, + iterations=1, + create_new=True, + ) + _CSV_WORKS = os.path.exists(_tf) and os.path.getsize(_tf) > 0 + class TestWriteVtkScalar: """Test the write_vtk_scalar function.""" @@ -271,9 +297,7 @@ def test_has_solver_empty_string(self): assert cfd_python.has_solver("") is False -@pytest.mark.skip( - reason="write_csv_timeseries not creating files - investigate CFD library implementation" -) +@pytest.mark.skipif(not _CSV_WORKS, reason=_CSV_SKIP_REASON) class TestWriteCsvTimeseries: """Test the write_csv_timeseries function."""