main's requirements.txt unpins asyncua (PR #30), so CI installs 2.0.1, where DataValue's field was renamed StatusCode_ -> StatusCode. The old kwarg made every OPC write raise TypeError at runtime - masked locally by a stale asyncua 1.0.6 in the dev conda env. Also uses ua.DateTime (a datetime subclass) and ua.UInt32 to satisfy the new stricter types, with no behavior change (verified via binary encode/decode round-trip).
180 lines
5.3 KiB
Python
180 lines
5.3 KiB
Python
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()
|