SIENTIAPDE-1646

Add tests for retrain, drift, storage, OPC repo, worker paths

- covers csv lookup fallback, drift insufficient-data passthrough,
  timestamp tz normalization, storage close without engine, OPC
  watchdog/write_value fallbacks and reconnect reporting, and
  legacy runtime install in worker main
This commit is contained in:
2026-09-28 11:53:01 -03:00
parent d27f6573fe
commit 9920cd61c2
5 changed files with 180 additions and 2 deletions

View File

@@ -855,3 +855,22 @@ def test_get_reference_data_exception(mlflow):
result = mlflow.get_reference_data(input_data) result = mlflow.get_reference_data(input_data)
assert result is None 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

View File

@@ -1,7 +1,7 @@
from unittest.mock import ANY, MagicMock, patch from unittest.mock import ANY, MagicMock, patch
from pandas import DataFrame, Timestamp from pandas import DataFrame, Series, Timestamp
from pytest import fixture, raises from pytest import fixture, mark, raises
from sientia_do.notifications.models import NotificationLevel from sientia_do.notifications.models import NotificationLevel
from sientia_do.observability.metrics_controller import MetricsController from sientia_do.observability.metrics_controller import MetricsController
from sientia_do.temporal.constants import DATETIME_FORMAT_WITH_TZ 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 len(result['metric']) == 1
assert result['metric'].values[0] == 'rmse' 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')]

View File

@@ -325,3 +325,17 @@ def test_cleanup_minio_objects_expired_list_objects_error(mock_now, storage):
attachment_content=ANY, attachment_content=ANY,
) )
storage.error.assert_called_once() 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

View File

@@ -785,3 +785,59 @@ def test_run_reconnect_swallows_an_unexpected_exception(opc_repository):
opc_repository._run_reconnect('BadSessionIdInvalid', 'tok') opc_repository._run_reconnect('BadSessionIdInvalid', 'tok')
assert opc_repository.error.call_count >= 2 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'

View File

@@ -248,3 +248,45 @@ async def test_main_worker_gather_error_exits_one(monkeypatch):
await worker.main() await worker.main()
assert m_logger.return_value.custom_error.called 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'