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)
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 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')]

View File

@@ -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

View File

@@ -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'

View File

@@ -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'