from datetime import datetime from unittest.mock import AsyncMock, MagicMock import pytest from asyncua import ua import ingestor.metrics as metrics from ingestor.engines.active_read import ActiveReadEngine def make_engine(): eng = ActiveReadEngine( pool=AsyncMock(), sink=AsyncMock(), logger=MagicMock(), notification_handler=MagicMock(), metrics_controller=AsyncMock(), ) eng.emit_metric = AsyncMock() return eng def make_dv(value=42.0, status='Good', source_ts=None, server_ts=None): source_ts = source_ts or datetime(2026, 7, 13, 12, 0, 0) server_ts = server_ts or source_ts return ua.DataValue( ua.Variant(value), StatusCode_=ua.StatusCode(getattr(ua.StatusCodes, status)), SourceTimestamp=source_ts, ServerTimestamp=server_ts, ) def make_client(dv=None): node = MagicMock() node.read_data_value = AsyncMock(return_value=dv or make_dv()) client = MagicMock() client.get_node = MagicMock(return_value=node) return client, node NAMES = [{'tag_name': 'a', 'model_id': 'm1', 'collection': 'coll'}] @pytest.mark.asyncio async def test_read_now_persists_and_returns_fields_when_names_given(): eng = make_engine() client, node = make_client() eng.pool.get.return_value = client result = await eng.read_now('srv1', 'ns=2;i=5', names=NAMES) client.get_node.assert_called_once_with('ns=2;i=5') node.read_data_value.assert_awaited_once() eng.sink.publish_reading.assert_awaited_once() _, kwargs = eng.sink.publish_reading.call_args assert kwargs['tag'] == 'ns=2;i=5' assert kwargs['value'] == 42.0 assert kwargs['names'] == NAMES assert result['value'] == 42.0 assert result['opc_status_code'] == 'Good' assert result['persisted'] is True assert result['source_timestamp'].startswith('2026-07-13 12:00:00') assert result['server_timestamp'].startswith('2026-07-13 12:00:00') eng.emit_metric.assert_any_await( metric_object=metrics.OPC_ACTIVE_READ_TOTAL, tags={'pod_id': eng.pod_id, 'server_name': 'srv1', 'result': 'success'}, ) latency_call = next( c for c in eng.emit_metric.await_args_list if c.kwargs['metric_object'] is metrics.OPC_ACTIVE_READ_LATENCY ) assert latency_call.kwargs['method'] == 'observe' assert latency_call.kwargs['value'] >= 0 lag_call = next( c for c in eng.emit_metric.await_args_list if c.kwargs['metric_object'] is metrics.OPC_READ_LAG ) assert lag_call.kwargs['method'] == 'set' @pytest.mark.asyncio async def test_read_now_without_names_does_not_persist(): eng = make_engine() client, _ = make_client() eng.pool.get.return_value = client result = await eng.read_now('srv1', 'ns=2;i=5') eng.sink.publish_reading.assert_not_awaited() assert result['persisted'] is False @pytest.mark.asyncio async def test_read_now_error_emits_metric_and_reraises(): eng = make_engine() node = MagicMock() node.read_data_value = AsyncMock(side_effect=RuntimeError('boom')) client = MagicMock() client.get_node = MagicMock(return_value=node) eng.pool.get.return_value = client with pytest.raises(RuntimeError, match='boom'): await eng.read_now('srv1', 'ns=2;i=5') eng.emit_metric.assert_any_await( metric_object=metrics.OPC_ACTIVE_READ_TOTAL, tags={'pod_id': eng.pod_id, 'server_name': 'srv1', 'result': 'error'}, ) eng.sink.publish_reading.assert_not_awaited() def make_slot(cycle_multiple=None, enabled=True): active_read = {'enabled': enabled} if cycle_multiple is not None: active_read['cycle_multiple'] = cycle_multiple return { 'srv_name': { 'server_id': 'srv1', 'url': 'opc.tcp://x', 'tags': { 'ns=2;i=5': {'frequency': 1000, 'names': NAMES, 'active_read': active_read}, }, } } @pytest.mark.asyncio async def test_scheduled_tick_respects_cycle_multiple(): eng = make_engine() eng.read_now = AsyncMock() slot = make_slot(cycle_multiple=3) await eng.scheduled_tick(slot) await eng.scheduled_tick(slot) eng.read_now.assert_not_awaited() await eng.scheduled_tick(slot) eng.read_now.assert_awaited_once_with( 'srv1', 'ns=2;i=5', names=NAMES, server_cfg={'server_id': 'srv1', 'url': 'opc.tcp://x'} ) @pytest.mark.asyncio async def test_scheduled_tick_skips_disabled_tags(): eng = make_engine() eng.read_now = AsyncMock() slot = make_slot(enabled=False) await eng.scheduled_tick(slot) eng.read_now.assert_not_awaited() @pytest.mark.asyncio async def test_scheduled_tick_continues_past_tag_error(): eng = make_engine() bad_slot = make_slot(cycle_multiple=1) good_names = [{'tag_name': 'b', 'model_id': 'm2', 'collection': 'coll'}] bad_slot['srv_name']['tags']['ns=2;i=6'] = { 'frequency': 1000, 'names': good_names, 'active_read': {'enabled': True, 'cycle_multiple': 1}, } calls = [] async def fake_read_now(server_id, addr, names=None, server_cfg=None): calls.append(addr) if addr == 'ns=2;i=5': raise RuntimeError('boom') eng.read_now = AsyncMock(side_effect=fake_read_now) await eng.scheduled_tick(bad_slot) assert set(calls) == {'ns=2;i=5', 'ns=2;i=6'} eng.logger.error.assert_called_once()