diff --git a/tests/laborious/activities/test_mlflow.py b/tests/laborious/activities/test_mlflow.py index c6b56cf..d7542d5 100644 --- a/tests/laborious/activities/test_mlflow.py +++ b/tests/laborious/activities/test_mlflow.py @@ -855,3 +855,22 @@ def test_get_reference_data_exception(mlflow): result = mlflow.get_reference_data(input_data) assert result is None + + +def test_find_downloaded_csv_prefers_the_direct_path(mlflow, tmp_path): + direct = tmp_path / 'evaluation_data.csv' + direct.write_text('a\n1\n') + + assert mlflow._find_downloaded_csv(str(tmp_path), 'evaluation_data.csv') == direct + + +def test_find_downloaded_csv_searches_nested_directories(mlflow, tmp_path): + nested = tmp_path / 'artifacts' / 'data' / 'evaluation_data.csv' + nested.parent.mkdir(parents=True) + nested.write_text('a\n1\n') + + assert mlflow._find_downloaded_csv(str(tmp_path), 'evaluation_data.csv') == nested + + +def test_find_downloaded_csv_returns_none_when_missing(mlflow, tmp_path): + assert mlflow._find_downloaded_csv(str(tmp_path), 'evaluation_data.csv') is None diff --git a/tests/laborious/activities/test_model_metrics.py b/tests/laborious/activities/test_model_metrics.py index 98a22f4..7f5bdcd 100644 --- a/tests/laborious/activities/test_model_metrics.py +++ b/tests/laborious/activities/test_model_metrics.py @@ -1,7 +1,7 @@ from unittest.mock import ANY, MagicMock, patch -from pandas import DataFrame, Timestamp -from pytest import fixture, raises +from pandas import DataFrame, Series, Timestamp +from pytest import fixture, mark, raises from sientia_do.notifications.models import NotificationLevel from sientia_do.observability.metrics_controller import MetricsController from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ @@ -953,3 +953,50 @@ def test_calculate_simple_metrics_unknown_metric_ignored(model_metrics_activity) assert len(result['metric']) == 1 assert result['metric'].values[0] == 'rmse' + + +@mark.parametrize( + 'failing_stage', + ['detect_univariate_drift', 'detect_multivariate_drift', 'get_drift_metrics_dataframe'], +) +@patch('laborious.activities.model_metrics.DriftAnalysis') +def test_get_drift_metrics_reraises_insufficient_data_untouched( + mock_drift_analysis, failing_stage, model_metrics_activity +): + """Insufficient data is not a stage failure: it propagates without the stage error report.""" + error = DriftInsufficientDataError('not enough rows', analysis_rows=1) + getattr(mock_drift_analysis.return_value, failing_stage).side_effect = error + data = DataFrame({'timestamp': ['2023-05-26 11:12:27'], 'target': [1.0], 'feature1': [1.0]}) + model_metrics_activity._drift_analyze_stage_error = MagicMock() + + with raises(DriftInsufficientDataError) as exc_info: + model_metrics_activity.get_drift_metrics( + reference_data=data, + target_data=data, + target_name='target', + reference_columns=data.drop(columns=['target', 'timestamp']).columns, + drift_metrics=['ks_test'], + chunk_period='min', + metadata=metadata['metadata'], + ) + + assert exc_info.value is error + model_metrics_activity._drift_analyze_stage_error.assert_not_called() + + +def test_to_naive_utc_converts_aware_timestamps_to_naive_utc(): + series = Series(['2024-01-01T03:00:00-03:00', '2024-01-01T09:00:00-03:00']) + + result = ModelMetrics._to_naive_utc(series) + + assert result.dt.tz is None + assert result.tolist() == [Timestamp('2024-01-01 06:00:00'), Timestamp('2024-01-01 12:00:00')] + + +def test_to_naive_utc_keeps_naive_timestamps(): + series = Series(['2024-01-01 06:00:00']) + + result = ModelMetrics._to_naive_utc(series) + + assert result.dt.tz is None + assert result.tolist() == [Timestamp('2024-01-01 06:00:00')] diff --git a/tests/laborious/activities/test_storage.py b/tests/laborious/activities/test_storage.py index 6285b12..f88df3a 100644 --- a/tests/laborious/activities/test_storage.py +++ b/tests/laborious/activities/test_storage.py @@ -325,3 +325,17 @@ def test_cleanup_minio_objects_expired_list_objects_error(mock_now, storage): attachment_content=ANY, ) storage.error.assert_called_once() + + +def test_close_without_engine_skips_postgres(storage, _patch_monitoring_shutdown): + """A Storage whose Postgres init never ran (no engine) still closes MinIO.""" + del storage.engine + minio = MagicMock() + storage.minio_repository = minio + + with patch('laborious.activities.storage.Postgres.close') as mock_postgres_close: + storage.close() + + mock_postgres_close.assert_not_called() + minio.close.assert_called_once() + assert storage.minio_repository is None diff --git a/tests/laborious/utils/repository/test_opc_repository.py b/tests/laborious/utils/repository/test_opc_repository.py index cf0851a..8a944f8 100644 --- a/tests/laborious/utils/repository/test_opc_repository.py +++ b/tests/laborious/utils/repository/test_opc_repository.py @@ -785,3 +785,59 @@ def test_run_reconnect_swallows_an_unexpected_exception(opc_repository): opc_repository._run_reconnect('BadSessionIdInvalid', 'tok') assert opc_repository.error.call_count >= 2 + + +def test_create_client_without_watchdog_attribute(opc_repository, mock_client): + """Older asyncua clients have no `watchdog_intervall`; it is not invented on them.""" + opc_repository.cert_path = None + mock_client.aio_obj = MagicMock(spec=['name', 'description', 'product_uri', 'uaclient']) + + opc_repository._create_client() + + assert opc_repository.client is mock_client + assert not hasattr(mock_client.aio_obj, 'watchdog_intervall') + + +def test_run_reconnect_success_logs_no_error(opc_repository): + opc_repository._reconnect_locked = MagicMock(return_value=(True, {})) + + opc_repository._run_reconnect('BadSessionIdInvalid', 'tok') + + opc_repository._reconnect_locked.assert_called_once() + opc_repository.error.assert_not_called() + + +@pytest.mark.parametrize('failure', [AttributeError('no write_value'), TypeError('bad args')]) +def test_write_node_value_falls_back_to_set_value(failure): + node = MagicMock() + node.write_value.side_effect = failure + + OpcRepository._write_node_value(node, 'ua-data', 42.0, 'variant') + + node.write_value.assert_called_once_with('ua-data') + node.set_value.assert_called_once_with(42.0, 'variant') + + +def test_write_node_value_uses_set_value_without_write_value(): + node = MagicMock(spec=['set_value']) + + OpcRepository._write_node_value(node, 'ua-data', 42.0, 'variant') + + node.set_value.assert_called_once_with(42.0, 'variant') + + +def test_write_data_session_not_ready_reports_the_reconnect_it_started(opc_repository): + """When the SessionNotReady reconnect starts, the write reports reconnect_in_progress.""" + opc_repository._session_ready.clear() + opc_repository._reconnect_thread_in_progress = MagicMock(side_effect=[False, True]) + opc_repository._start_reconnect = MagicMock() + opc_repository.validate_connection = MagicMock() + + is_success, error_data = opc_repository.write_data( + 'ns=2;s=TestNode', 42.0, 'float', metadata['metadata'] + ) + + opc_repository._start_reconnect.assert_called_once_with('SessionNotReady', ANY) + opc_repository.validate_connection.assert_not_called() + assert is_success is False + assert error_data['opc_error_kind'] == 'reconnect_in_progress' diff --git a/tests/laborious/worker/test_worker.py b/tests/laborious/worker/test_worker.py index 15b3b7c..a4c1898 100644 --- a/tests/laborious/worker/test_worker.py +++ b/tests/laborious/worker/test_worker.py @@ -248,3 +248,45 @@ async def test_main_worker_gather_error_exits_one(monkeypatch): await worker.main() assert m_logger.return_value.custom_error.called + + +@mark.asyncio +async def test_main_legacy_runtime_installs_single(monkeypatch): + """The `legacy` runtime is served by the `single` store runtime.""" + monkeypatch.setenv('RUNTIME', 'legacy') + fake_plugin = MagicMock() + fake_plugin.install_runtime = AsyncMock(side_effect=RuntimeError('stop after install')) + with ( + patch('laborious.worker.worker.start_prometheus_server'), + patch('laborious.worker.worker.get_logger'), + patch( + 'laborious.worker.worker.build_mongodb_config', + return_value={'connection_string': 'cs', 'database_name': 'db'}, + ), + patch('laborious.worker.worker.NotificationHandler'), + patch('laborious.worker.worker.MetricsController'), + patch( + 'laborious.worker.worker.build_plugin_store_config', + return_value={ + 'base_url': '', + 'owner': '', + 'repo': '', + 'username': None, + 'password': None, + 'branch': None, + 'cache_ttl_seconds': None, + 'pypi_index_url': '', + 'pypi_username': None, + 'pypi_password': None, + }, + ), + patch('laborious.worker.worker.PluginStore', return_value=fake_plugin), + patch.object(worker.metrics.APP_UP, 'labels') as labels, + patch('laborious.worker.worker.sys.exit', side_effect=SystemExit(1)), + ): + labels.return_value = MagicMock() + with raises(SystemExit): + await worker.main() + + fake_plugin.install_runtime.assert_awaited_once() + assert fake_plugin.install_runtime.await_args.kwargs['runtime_name'] == 'single'