feat(gateway): SubscriptionEngine multi-name fan-out (C1)
This commit is contained in:
0
tests/unit/engines/__init__.py
Normal file
0
tests/unit/engines/__init__.py
Normal file
205
tests/unit/engines/test_subscription.py
Normal file
205
tests/unit/engines/test_subscription.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from sientia_do.temporal.constants import OPC_TIMEZONE
|
||||
|
||||
import ingestor.metrics as metrics
|
||||
from ingestor.engines.subscription import SubscriptionEngine
|
||||
|
||||
|
||||
def make_engine():
|
||||
eng = SubscriptionEngine(
|
||||
pool=AsyncMock(),
|
||||
sink=AsyncMock(),
|
||||
logger=MagicMock(),
|
||||
notification_handler=MagicMock(),
|
||||
metrics_controller=AsyncMock(),
|
||||
metadata={},
|
||||
)
|
||||
eng.emit_metric = AsyncMock()
|
||||
eng.send_notification_async = AsyncMock()
|
||||
return eng
|
||||
|
||||
|
||||
def make_node(name='ns=2;i=2'):
|
||||
node = MagicMock()
|
||||
node.__str__ = lambda self: name
|
||||
return node
|
||||
|
||||
|
||||
def make_data(value=42, timestamp=None):
|
||||
data = MagicMock()
|
||||
data.monitored_item.Value.Value.Value = value
|
||||
if timestamp is not None:
|
||||
data.monitored_item.Value.SourceTimestamp = timestamp
|
||||
return data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_datachange_fans_out():
|
||||
eng = make_engine()
|
||||
eng.nodes['ns=2;i=2'] = {
|
||||
'names': [{'tag_name': 'a'}, {'tag_name': 'b'}],
|
||||
'cycle_rule': {'cycle_increment': 1, 'cycle_count': 3},
|
||||
}
|
||||
node = make_node()
|
||||
data = make_data(42)
|
||||
|
||||
await eng.datachange_notification(node, 42, data)
|
||||
|
||||
assert eng.sink.publish_reading.await_count == 1
|
||||
_, kwargs = eng.sink.publish_reading.call_args
|
||||
assert len(kwargs['names']) == 2
|
||||
assert kwargs['tag'] == 'ns=2;i=2'
|
||||
assert kwargs['value'] == 42
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_datachange_resets_cycle_count_and_non_receive_count():
|
||||
eng = make_engine()
|
||||
eng.non_receive_count = 7
|
||||
eng.nodes['ns=2;i=2'] = {
|
||||
'names': [{'tag_name': 'a'}],
|
||||
'cycle_rule': {'cycle_increment': 1, 'cycle_count': 3},
|
||||
}
|
||||
|
||||
await eng.datachange_notification(make_node(), 42, make_data(42))
|
||||
|
||||
assert eng.nodes['ns=2;i=2']['cycle_rule']['cycle_count'] == 0
|
||||
assert eng.non_receive_count == 0
|
||||
eng.emit_metric.assert_any_await(
|
||||
metric_object=metrics.OPC_CYCLES_WITHOUT_DATA,
|
||||
method='set',
|
||||
value=0,
|
||||
tags={'pod_id': eng.pod_id, 'server_name': 'unknown'},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_datachange_emits_read_lag():
|
||||
eng = make_engine()
|
||||
eng.nodes['ns=2;i=2'] = {
|
||||
'names': [{'tag_name': 'a'}],
|
||||
'cycle_rule': {'cycle_increment': 1, 'cycle_count': 0},
|
||||
'server_name': 'srv1',
|
||||
}
|
||||
source_ts = datetime.now(tz=OPC_TIMEZONE)
|
||||
|
||||
await eng.datachange_notification(make_node(), 42, make_data(42, timestamp=source_ts))
|
||||
|
||||
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['tags'] == {'pod_id': eng.pod_id, 'server_name': 'srv1'}
|
||||
assert abs(lag_call.kwargs['value']) < 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_subscription_uses_pool_client():
|
||||
eng = make_engine()
|
||||
client = AsyncMock()
|
||||
eng.pool.get.return_value = client
|
||||
|
||||
await eng.create_subscription('srv1', {'subscription_period_ms': 500}, 'sub1')
|
||||
|
||||
eng.pool.get.assert_awaited_once_with('srv1', {'subscription_period_ms': 500})
|
||||
client.create_subscription.assert_awaited_once_with(500, eng)
|
||||
assert eng.subscriptions['sub1'] is not None
|
||||
eng.emit_metric.assert_any_await(
|
||||
metric_object=metrics.OPC_SUBSCRIPTIONS_CREATED,
|
||||
method='inc',
|
||||
value=1,
|
||||
tags={'pod_id': eng.pod_id, 'server_name': 'srv1', 'slot_name': 'sub1'},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_registers_nodes_with_cycle_rule():
|
||||
eng = make_engine()
|
||||
client = AsyncMock()
|
||||
eng.pool.get.return_value = client
|
||||
await eng.create_subscription('srv1', {'subscription_period_ms': 500}, 'sub1')
|
||||
|
||||
nodes = {
|
||||
'ns=2;i=2': {
|
||||
'frequency': 1000,
|
||||
'names': [{'tag_name': 'a'}, {'tag_name': 'b'}],
|
||||
'active_read': {'enabled': False},
|
||||
}
|
||||
}
|
||||
|
||||
await eng.subscribe('sub1', nodes, collect_period=1)
|
||||
|
||||
assert eng.nodes['ns=2;i=2']['server_name'] == 'srv1'
|
||||
assert eng.nodes['ns=2;i=2']['cycle_rule']['cycle_count'] == 0
|
||||
assert eng.nodes['ns=2;i=2']['cycle_rule']['cycle_increment'] == pytest.approx(1.0)
|
||||
eng.subscriptions['sub1'].subscribe_data_change.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_without_subscription_raises():
|
||||
eng = make_engine()
|
||||
with pytest.raises(ValueError, match='Subscription not created'):
|
||||
await eng.subscribe('sub1', {}, collect_period=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_missing_logs_warning():
|
||||
eng = make_engine()
|
||||
await eng.unsubscribe('sub1')
|
||||
eng.logger.warning.assert_called_once_with(
|
||||
"Subscription 'sub1' not found. Cannot unsubscribe."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_deletes_subscription():
|
||||
eng = make_engine()
|
||||
client = AsyncMock()
|
||||
eng.pool.get.return_value = client
|
||||
await eng.create_subscription('srv1', {'subscription_period_ms': 500}, 'sub1')
|
||||
sub = eng.subscriptions['sub1']
|
||||
|
||||
await eng.unsubscribe('sub1')
|
||||
|
||||
sub.delete.assert_awaited_once()
|
||||
assert 'sub1' not in eng.subscriptions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_cycles_triggers_notification_over_threshold():
|
||||
eng = make_engine()
|
||||
eng.nodes['ns=2;i=2'] = {
|
||||
'names': [{'tag_name': 'a'}],
|
||||
'cycle_rule': {'cycle_increment': 5.5, 'cycle_count': 0},
|
||||
}
|
||||
|
||||
await eng.check_cycles()
|
||||
|
||||
eng.send_notification_async.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_opc_listenning_below_threshold():
|
||||
eng = make_engine()
|
||||
eng.non_receive_count = 2
|
||||
|
||||
result = await eng.check_opc_listenning('srv1')
|
||||
|
||||
assert eng.non_receive_count == 3
|
||||
assert result is False
|
||||
eng.send_notification_async.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_opc_listenning_triggers_reconnect_at_15():
|
||||
eng = make_engine()
|
||||
eng.non_receive_count = 14
|
||||
|
||||
result = await eng.check_opc_listenning('srv1')
|
||||
|
||||
assert result is True
|
||||
assert eng.send_notification_async.call_count == 2
|
||||
Reference in New Issue
Block a user