SIENTIAPDE-1646
SIENTIAPDE-1646 Enhance OPC integration and E2E testing framework - Updated .gitignore to exclude '.cursor/*' for better file management. - Added new marker in pyproject.toml for E2E tests using OPC. - Introduced async OPC server fixtures and improved connection handling in conftest.py. - Enhanced error handling in OPC activities and added metrics for session management in metrics.py. - Updated E2E tests to cover new OPC scenarios, including session errors and reconnect handling. - Refactored helper functions to improve prediction assertion logic in helpers.py. - Documented new OPC scenarios in scenarios.md for clarity on expected outcomes.
This commit is contained in:
140
e2e/conftest.py
140
e2e/conftest.py
@@ -1,5 +1,7 @@
|
||||
"""Pytest configuration and fixtures for E2E tests."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
@@ -14,6 +16,7 @@ from testcontainers.postgres import PostgresContainer
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
from temporalio.worker import Worker
|
||||
|
||||
from e2e.opc_test_server import OpcE2ETestServer
|
||||
from laborious.activities.activities import Activities
|
||||
from laborious.workflows.drift import Drift
|
||||
from laborious.workflows.minimal_retrain import MinimalRetrain
|
||||
@@ -461,3 +464,140 @@ async def temporal_worker_minimal_retrain(temporal_test_env, test_activities):
|
||||
yield worker
|
||||
|
||||
|
||||
def _connect_activities_to_opc_server(activities: Activities, server_url: str) -> None:
|
||||
"""
|
||||
Initialize OPC repositories and block until the E2E server session is ready.
|
||||
|
||||
Runs synchronously (typically via ``asyncio.to_thread``) so the asyncua test
|
||||
server event loop is not blocked during ``Client.connect()``.
|
||||
|
||||
Args:
|
||||
activities (Activities): Worker activities under test.
|
||||
server_url (str): ``opc.tcp://`` URL from ``OpcE2ETestServer``.
|
||||
"""
|
||||
activities.init_opc()
|
||||
repo = activities.opc_repository['1']
|
||||
deadline = time.monotonic() + 30.0
|
||||
while time.monotonic() < deadline:
|
||||
if repo._session_ready.is_set():
|
||||
return
|
||||
connected, _ = repo.connect()
|
||||
if connected:
|
||||
return
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError(f'Could not connect OpcRepository to OPC E2E server at {server_url}')
|
||||
|
||||
|
||||
def _build_e2e_opc_config(server_url: str) -> dict[str, dict]:
|
||||
"""
|
||||
OPC server config for E2E Activities pointing at an in-process asyncua server.
|
||||
|
||||
Args:
|
||||
server_url (str): ``opc.tcp://`` endpoint from ``OpcE2ETestServer``.
|
||||
|
||||
Return:
|
||||
dict: ``opc_config`` payload for ``Activities`` (server id ``1``).
|
||||
"""
|
||||
return {
|
||||
'1': {
|
||||
'id': '1',
|
||||
'server_name': 'e2e_opcua',
|
||||
'url': server_url,
|
||||
'server_uri': server_url,
|
||||
'cert_path': None,
|
||||
'private_key_path': None,
|
||||
'server_cert_path': None,
|
||||
'reconnection_interval': 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def opc_e2e_server():
|
||||
"""In-process asyncua server with writable prediction/confidence nodes."""
|
||||
server = OpcE2ETestServer()
|
||||
await server.start()
|
||||
await asyncio.sleep(0.5)
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope='function')
|
||||
async def test_activities_real_opc(
|
||||
postgres_container,
|
||||
minio_container,
|
||||
mock_logger,
|
||||
notification_handler,
|
||||
metrics_controller,
|
||||
mlflow_repository_stub,
|
||||
plugin_store_stub,
|
||||
pi_web_api_client_stub,
|
||||
opc_e2e_server: OpcE2ETestServer,
|
||||
):
|
||||
"""Activities with real OpcRepository connected to the in-process OPC UA server."""
|
||||
minio_client = minio_container.get_client()
|
||||
if not minio_client.bucket_exists('test-bucket'):
|
||||
minio_client.make_bucket('test-bucket')
|
||||
minio_port = minio_container.get_exposed_port(9000)
|
||||
|
||||
activities = Activities(
|
||||
postgres_config={
|
||||
'host': 'localhost',
|
||||
'port': int(postgres_container.get_exposed_port(5432)),
|
||||
'user': postgres_container.username,
|
||||
'password': postgres_container.password,
|
||||
'dbname': postgres_container.dbname,
|
||||
'min_connections': 1,
|
||||
'max_connections': 5,
|
||||
},
|
||||
plugin_store=plugin_store_stub,
|
||||
minio_config={
|
||||
'endpoint_url': f'localhost:{minio_port}',
|
||||
'access_key': 'minioadmin',
|
||||
'secret_key': 'minioadmin',
|
||||
'default_bucket': 'test-bucket',
|
||||
'retention_hours': 24,
|
||||
'secure': False,
|
||||
},
|
||||
opc_config=_build_e2e_opc_config(opc_e2e_server.url),
|
||||
pi_web_api_config={
|
||||
'base_url': 'http://localhost:8080',
|
||||
'auth_type': 'bearer',
|
||||
'auth_token': 'test_token',
|
||||
},
|
||||
logger=mock_logger,
|
||||
notification_handler=notification_handler,
|
||||
metrics_controller=metrics_controller,
|
||||
mlflow_repository=mlflow_repository_stub,
|
||||
)
|
||||
activities.pi_web_api_client = pi_web_api_client_stub
|
||||
await asyncio.to_thread(_connect_activities_to_opc_server, activities, opc_e2e_server.url)
|
||||
try:
|
||||
yield activities
|
||||
finally:
|
||||
await asyncio.to_thread(_teardown_real_opc_activities, activities)
|
||||
|
||||
|
||||
def _teardown_real_opc_activities(activities: Activities) -> None:
|
||||
"""Disconnect OPC sessions and shut down activities (sync, for asyncio.to_thread)."""
|
||||
for opc_repo in activities.opc_repository.values():
|
||||
opc_repo.disconnect()
|
||||
activities.shutdown()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope='function')
|
||||
async def temporal_worker_real_opc(temporal_test_env, test_activities_real_opc):
|
||||
"""Temporal worker using real OpcRepository against the in-process OPC UA server."""
|
||||
with ThreadPoolExecutor(max_workers=32) as activity_executor:
|
||||
async with Worker(
|
||||
temporal_test_env.client,
|
||||
task_queue='test-queue',
|
||||
workflows=[PredictionsBatch, PredictionProcess, FormatAndExportPrediction],
|
||||
activities=_worker_activity_list(test_activities_real_opc),
|
||||
activity_executor=activity_executor,
|
||||
) as worker:
|
||||
yield worker
|
||||
|
||||
|
||||
|
||||
@@ -205,6 +205,7 @@ def assert_prediction(
|
||||
prediction_confidence: int | Decimal = 0,
|
||||
prediction_status: str = 'Good',
|
||||
comments: str = '',
|
||||
comments_contains: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Assert exactly one prediction row exists for model_id with expected columns.
|
||||
@@ -215,10 +216,9 @@ def assert_prediction(
|
||||
prediction: Expected prediction value.
|
||||
prediction_confidence: Expected confidence (int or Decimal for numeric column).
|
||||
prediction_status: Expected status string.
|
||||
comments: Expected comments string.
|
||||
comments: Expected exact comments string (ignored when ``comments_contains`` is set).
|
||||
comments_contains: When set, assert this substring appears in comments.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
with postgres_engine.connect() as conn:
|
||||
result_query = conn.execute(
|
||||
text(
|
||||
@@ -238,7 +238,13 @@ def assert_prediction(
|
||||
str(prediction_confidence)
|
||||
), f'Expected prediction_confidence={prediction_confidence}, got {row[2]}'
|
||||
assert row[3] == prediction_status, f"Expected prediction_status='{prediction_status}', got {row[3]}"
|
||||
assert row[4] == comments, f"Expected comments='{comments}', got {row[4]}"
|
||||
actual_comments = row[4] or ''
|
||||
if comments_contains is not None:
|
||||
assert comments_contains in actual_comments, (
|
||||
f"Expected comments to contain '{comments_contains}', got '{actual_comments}'"
|
||||
)
|
||||
else:
|
||||
assert actual_comments == comments, f"Expected comments='{comments}', got '{actual_comments}'"
|
||||
|
||||
|
||||
def assert_continue(
|
||||
|
||||
189
e2e/opc_test_server.py
Normal file
189
e2e/opc_test_server.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
In-process OPC UA server for E2E tests (asyncua).
|
||||
|
||||
Provides writable prediction/confidence nodes and optional write faults
|
||||
(Tier-1 BadSessionIdInvalid via PreWrite callback).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from asyncua import Server, ua
|
||||
from asyncua.common.callback import CallbackType
|
||||
from asyncua.common.utils import ServiceError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from asyncua.common.node import Node
|
||||
|
||||
|
||||
UNKNOWN_NODE_ID = 'ns=99;i=9999'
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpcE2ENodeIds:
|
||||
"""NodeId strings used in opc_output_config for E2E workflows."""
|
||||
|
||||
prediction: str
|
||||
confidence: str
|
||||
unknown: str = UNKNOWN_NODE_ID
|
||||
|
||||
|
||||
class OpcE2ETestServer:
|
||||
"""
|
||||
Ephemeral asyncua server with Laborious E2E variables and controllable faults.
|
||||
|
||||
Args:
|
||||
host: Bind address (default 127.0.0.1).
|
||||
"""
|
||||
|
||||
def __init__(self, host: str = '127.0.0.1') -> None:
|
||||
self._host = host
|
||||
self._server: Server | None = None
|
||||
self._prediction_node: Node | None = None
|
||||
self._confidence_node: Node | None = None
|
||||
self._session_bad_on_write = False
|
||||
self._url: str | None = None
|
||||
self._node_ids: OpcE2ENodeIds | None = None
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
if self._url is None:
|
||||
raise RuntimeError('OPC E2E server is not started')
|
||||
return self._url
|
||||
|
||||
@property
|
||||
def node_ids(self) -> OpcE2ENodeIds:
|
||||
if self._node_ids is None:
|
||||
raise RuntimeError('OPC E2E server is not started')
|
||||
return self._node_ids
|
||||
|
||||
def set_session_bad_on_write(self, enabled: bool) -> None:
|
||||
"""
|
||||
When enabled, every client Write is rejected with BadSessionIdInvalid.
|
||||
|
||||
Args:
|
||||
enabled (bool): Turn Tier-1 session fault injection on or off.
|
||||
"""
|
||||
self._session_bad_on_write = enabled
|
||||
|
||||
async def start(self) -> OpcE2ENodeIds:
|
||||
"""
|
||||
Start the OPC UA server on a free TCP port.
|
||||
|
||||
Return:
|
||||
OpcE2ENodeIds: NodeId strings for prediction and confidence tags.
|
||||
"""
|
||||
port = _free_port(self._host)
|
||||
self._url = f'opc.tcp://{self._host}:{port}/freeopcua/server/'
|
||||
|
||||
server = Server()
|
||||
server.set_endpoint(self._url)
|
||||
await server.init()
|
||||
server.iserver.callback_service.addListener(
|
||||
CallbackType.PreWrite,
|
||||
self._pre_write_callback,
|
||||
)
|
||||
|
||||
idx = await server.register_namespace('http://sientia.test/laborious-e2e')
|
||||
e2e_object = await server.nodes.objects.add_object(idx, 'LaboriousE2E')
|
||||
prediction = await e2e_object.add_variable(
|
||||
idx,
|
||||
'Prediction',
|
||||
ua.Variant(0.0, ua.VariantType.Float),
|
||||
)
|
||||
confidence = await e2e_object.add_variable(
|
||||
idx,
|
||||
'Confidence',
|
||||
ua.Variant(0.0, ua.VariantType.Float),
|
||||
)
|
||||
await prediction.set_writable()
|
||||
await confidence.set_writable()
|
||||
|
||||
await server.start()
|
||||
self._server = server
|
||||
self._prediction_node = prediction
|
||||
self._confidence_node = confidence
|
||||
self._node_ids = OpcE2ENodeIds(
|
||||
prediction=prediction.nodeid.to_string(),
|
||||
confidence=confidence.nodeid.to_string(),
|
||||
)
|
||||
return self._node_ids
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the OPC UA server and release the listening port."""
|
||||
if self._server is not None:
|
||||
await self._server.stop()
|
||||
self._server = None
|
||||
self._prediction_node = None
|
||||
self._confidence_node = None
|
||||
self._url = None
|
||||
self._node_ids = None
|
||||
self._session_bad_on_write = False
|
||||
|
||||
async def read_prediction(self) -> float:
|
||||
"""
|
||||
Read the current prediction variable value from the address space.
|
||||
|
||||
Return:
|
||||
float: Stored prediction value.
|
||||
"""
|
||||
if self._prediction_node is None:
|
||||
raise RuntimeError('OPC E2E server is not started')
|
||||
value = await self._prediction_node.read_value()
|
||||
return float(value)
|
||||
|
||||
async def read_confidence(self) -> float:
|
||||
"""
|
||||
Read the current confidence variable value from the address space.
|
||||
|
||||
Return:
|
||||
float: Stored confidence value.
|
||||
"""
|
||||
if self._confidence_node is None:
|
||||
raise RuntimeError('OPC E2E server is not started')
|
||||
value = await self._confidence_node.read_value()
|
||||
return float(value)
|
||||
|
||||
async def _pre_write_callback(self, _event, _service) -> None:
|
||||
if self._session_bad_on_write:
|
||||
raise ServiceError(ua.StatusCodes.BadSessionIdInvalid)
|
||||
|
||||
|
||||
def _free_port(host: str) -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind((host, 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def build_opc_output_config(
|
||||
node_ids: OpcE2ENodeIds,
|
||||
*,
|
||||
prediction_tag: str | None = None,
|
||||
confidence_tag: str | None = None,
|
||||
prediction_only: bool = False,
|
||||
server_key: str = '1',
|
||||
) -> dict[str, dict]:
|
||||
"""
|
||||
Build opc_output_config for PredictionsBatch using real server NodeIds.
|
||||
|
||||
Args:
|
||||
node_ids (OpcE2ENodeIds): Node ids from OpcE2ETestServer.
|
||||
prediction_tag (str | None): Override prediction NodeId (default: node_ids.prediction).
|
||||
confidence_tag (str | None): Override confidence NodeId (default: node_ids.confidence).
|
||||
prediction_only (bool): When True, omit confidence_tags (single write per activity).
|
||||
server_key (str): OPC server id key in opc_output_config.
|
||||
|
||||
Return:
|
||||
dict: opc_output_config payload for workflow input.
|
||||
"""
|
||||
pred = prediction_tag if prediction_tag is not None else node_ids.prediction
|
||||
conf = confidence_tag if confidence_tag is not None else node_ids.confidence
|
||||
server_config: dict = {
|
||||
'prediction_tags': {pred: {'data_type': 'float'}},
|
||||
}
|
||||
if not prediction_only:
|
||||
server_config['confidence_tags'] = {conf: {'data_type': 'float'}}
|
||||
return {server_key: server_config}
|
||||
@@ -10,6 +10,27 @@ It is a functional reference of scenario behavior, inputs, and expected outcomes
|
||||
- Tests run under `e2e/` and are marked with `@pytest.mark.integration`.
|
||||
- PostgreSQL and MinIO are provisioned with testcontainers.
|
||||
- `test_minio_offload.py` uses real MinIO I/O; other scenario suites may use stubs/mocks for optional outputs.
|
||||
- Real OPC UA scenarios use `@pytest.mark.opc` and an in-process asyncua server (`e2e/test_opc_real_server.py`).
|
||||
|
||||
### Local validation
|
||||
|
||||
Use the existing project virtualenv and the shared `validate` script for unit/quality gates; run E2E separately (Docker required).
|
||||
|
||||
```bash
|
||||
source ./venv/bin/activate
|
||||
|
||||
# Auto-fix + static checks (no pytest)
|
||||
validate --fix --project-name=laborious
|
||||
|
||||
# Full unit + quality gate
|
||||
validate --project-name=laborious
|
||||
|
||||
# E2E (integration)
|
||||
pytest e2e/ --override-ini testpaths=e2e -m integration
|
||||
|
||||
# E2E (real OPC server only)
|
||||
pytest e2e/test_opc_real_server.py --override-ini testpaths=e2e -m opc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -195,6 +216,30 @@ Source: `e2e/test_predictions_batch_format_export.py`
|
||||
- Workflow completes.
|
||||
- Prediction persisted with PI error confidence and descriptive comment.
|
||||
|
||||
#### 3.2.4 OPC session / channel error (confidence 14)
|
||||
**Summary**: Tier-1 `BadSessionIdInvalid` (or equivalent session error) degrades the prediction without failing the workflow.
|
||||
|
||||
**Sources**:
|
||||
- Mock: `e2e/test_predictions_batch_format_export.py::test_scenario_3_2_4_opc_session_bad_mock`
|
||||
- Real server: `e2e/test_opc_real_server.py::test_scenario_3_2_4_opc_session_bad_real_server` (`@pytest.mark.opc`)
|
||||
|
||||
**Expected Outcome**:
|
||||
- Workflow completes.
|
||||
- `prediction_confidence` is 14.
|
||||
- Comments contain `OPC UA session/channel error: BadSessionIdInvalid`.
|
||||
|
||||
#### 3.2.5 OPC write blocked during reconnect (confidence 14)
|
||||
**Summary**: While reconnect holds the repository connection lock, writes fail fast with `reconnect_in_progress`.
|
||||
|
||||
**Sources**:
|
||||
- Mock: `e2e/test_predictions_batch_format_export.py::test_scenario_3_2_5_opc_reconnect_in_progress_mock`
|
||||
- Real server: `e2e/test_opc_real_server.py::test_scenario_3_2_5_opc_write_blocked_during_reconnect_real_server` (`@pytest.mark.opc`)
|
||||
|
||||
**Expected Outcome**:
|
||||
- Workflow completes.
|
||||
- `prediction_confidence` is 14.
|
||||
- Comments contain `OPC UA reconnect in progress`.
|
||||
|
||||
### 3.3.1 Combined Optional Outputs (PI + OPC)
|
||||
**Summary**: Both external output channels are enabled together.
|
||||
|
||||
|
||||
201
e2e/test_opc_real_server.py
Normal file
201
e2e/test_opc_real_server.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
E2E tests for OPC export using an in-process asyncua server and real OpcRepository.
|
||||
|
||||
Covers scenarios 3.1.2, 3.2.2, 3.2.4, and 3.2.5 from e2e/scenarios.md.
|
||||
Mock-based OPC tests remain in test_predictions_batch_format_export.py.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
from temporalio.worker import Worker
|
||||
|
||||
from e2e.helpers import assert_prediction, insert_sample_data, make_workflow_id, start_and_await_workflow
|
||||
from e2e.opc_test_server import UNKNOWN_NODE_ID, OpcE2ETestServer, build_opc_output_config
|
||||
from e2e.test_predictions_batch_format_export import get_base_input_data
|
||||
from laborious.activities.activities import Activities
|
||||
from laborious.activities.opc import OPC_RECONNECT_IN_PROGRESS_COMMENT
|
||||
from laborious.utils.repository.opc_repository import OpcRepository
|
||||
from laborious.workflows.predictions_batch import PredictionsBatch
|
||||
|
||||
|
||||
def _slow_reconnect_under_lock(repo: OpcRepository, hold_seconds: float = 0.75) -> None:
|
||||
"""
|
||||
Hold the connection lock briefly so concurrent writes see reconnect_in_progress.
|
||||
|
||||
Args:
|
||||
repo (OpcRepository): Connected repository.
|
||||
hold_seconds (float): Time to keep the lock before reconnecting.
|
||||
"""
|
||||
with repo._connection_lock:
|
||||
time.sleep(hold_seconds)
|
||||
repo._reconnect_locked()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.opc
|
||||
async def test_scenario_3_1_2_export_with_opc_only_real_server(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker_real_opc: Worker,
|
||||
test_activities_real_opc: Activities,
|
||||
opc_e2e_server: OpcE2ETestServer,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
Scenario 3.1.2 (real OPC): connect, write prediction and confidence, verify server values.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
model_id = 412
|
||||
insert_sample_data(postgres_engine, model_id, [23.5, 78.2])
|
||||
|
||||
input_data = get_base_input_data(model_id)
|
||||
input_data['opc_output_config'] = build_opc_output_config(opc_e2e_server.node_ids)
|
||||
input_data['pi_web_api_output_config'] = None
|
||||
|
||||
await start_and_await_workflow(
|
||||
client,
|
||||
PredictionsBatch.run,
|
||||
input_data,
|
||||
make_workflow_id('test-opc-real-happy'),
|
||||
)
|
||||
|
||||
test_activities_real_opc.pi_web_api_client.write_value.assert_not_called()
|
||||
assert await opc_e2e_server.read_prediction() == pytest.approx(0.5)
|
||||
assert await opc_e2e_server.read_confidence() == pytest.approx(0.0)
|
||||
assert_prediction(postgres_engine, model_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.opc
|
||||
async def test_scenario_3_2_2_opc_write_error_real_server(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker_real_opc: Worker,
|
||||
test_activities_real_opc: Activities,
|
||||
opc_e2e_server: OpcE2ETestServer,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
Scenario 3.2.2 (real OPC): unknown NodeId yields generic write failure (confidence 12).
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
model_id = 422
|
||||
insert_sample_data(postgres_engine, model_id, [23.5, 78.2])
|
||||
|
||||
node_ids = opc_e2e_server.node_ids
|
||||
input_data = get_base_input_data(model_id)
|
||||
input_data['opc_output_config'] = build_opc_output_config(
|
||||
node_ids,
|
||||
prediction_tag=UNKNOWN_NODE_ID,
|
||||
confidence_tag=UNKNOWN_NODE_ID,
|
||||
)
|
||||
input_data['pi_web_api_output_config'] = None
|
||||
|
||||
await start_and_await_workflow(
|
||||
client,
|
||||
PredictionsBatch.run,
|
||||
input_data,
|
||||
make_workflow_id('test-opc-real-bad-node'),
|
||||
)
|
||||
|
||||
assert_prediction(
|
||||
postgres_engine,
|
||||
model_id,
|
||||
prediction_confidence=12,
|
||||
comments='Some data could not be written to OPC servers',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.opc
|
||||
async def test_scenario_3_2_4_opc_session_bad_real_server(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker_real_opc: Worker,
|
||||
test_activities_real_opc: Activities,
|
||||
opc_e2e_server: OpcE2ETestServer,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
Scenario 3.2.4 (real OPC): server PreWrite fault injects BadSessionIdInvalid (confidence 14).
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
model_id = 424
|
||||
insert_sample_data(postgres_engine, model_id, [23.5, 78.2])
|
||||
|
||||
opc_e2e_server.set_session_bad_on_write(True)
|
||||
try:
|
||||
input_data = get_base_input_data(model_id)
|
||||
input_data['opc_output_config'] = build_opc_output_config(
|
||||
opc_e2e_server.node_ids,
|
||||
prediction_only=True,
|
||||
)
|
||||
input_data['pi_web_api_output_config'] = None
|
||||
|
||||
await start_and_await_workflow(
|
||||
client,
|
||||
PredictionsBatch.run,
|
||||
input_data,
|
||||
make_workflow_id('test-opc-real-session-bad'),
|
||||
)
|
||||
finally:
|
||||
opc_e2e_server.set_session_bad_on_write(False)
|
||||
|
||||
assert_prediction(
|
||||
postgres_engine,
|
||||
model_id,
|
||||
prediction_confidence=14,
|
||||
comments_contains='OPC UA session/channel error: BadSessionIdInvalid',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.opc
|
||||
async def test_scenario_3_2_5_opc_write_blocked_during_reconnect_real_server(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker_real_opc: Worker,
|
||||
test_activities_real_opc: Activities,
|
||||
opc_e2e_server: OpcE2ETestServer,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
Scenario 3.2.5 (real OPC): writes rejected while reconnect holds the connection lock.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
model_id = 425
|
||||
insert_sample_data(postgres_engine, model_id, [23.5, 78.2])
|
||||
|
||||
repo = test_activities_real_opc.opc_repository['1']
|
||||
repo._session_ready.clear()
|
||||
reconnect_thread = threading.Thread(
|
||||
target=_slow_reconnect_under_lock,
|
||||
args=(repo,),
|
||||
daemon=True,
|
||||
)
|
||||
reconnect_thread.start()
|
||||
|
||||
input_data = get_base_input_data(model_id)
|
||||
input_data['opc_output_config'] = build_opc_output_config(opc_e2e_server.node_ids)
|
||||
input_data['pi_web_api_output_config'] = None
|
||||
|
||||
try:
|
||||
await start_and_await_workflow(
|
||||
client,
|
||||
PredictionsBatch.run,
|
||||
input_data,
|
||||
make_workflow_id('test-opc-real-reconnect-block'),
|
||||
)
|
||||
finally:
|
||||
reconnect_thread.join(timeout=5.0)
|
||||
|
||||
assert_prediction(
|
||||
postgres_engine,
|
||||
model_id,
|
||||
prediction_confidence=14,
|
||||
comments_contains=OPC_RECONNECT_IN_PROGRESS_COMMENT,
|
||||
)
|
||||
@@ -4,7 +4,7 @@ End-to-end tests for PredictionsBatch workflow - Format and Export scenarios.
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any, cast
|
||||
from unittest.mock import ANY, call
|
||||
from unittest.mock import call
|
||||
|
||||
import pytest
|
||||
from sientia_do.notifications.models import NotificationLevel
|
||||
@@ -123,7 +123,6 @@ async def test_scenario_3_1_1_default_prediction_export(
|
||||
'addr_1',
|
||||
0,
|
||||
'float',
|
||||
ANY,
|
||||
{
|
||||
'model_id': 311,
|
||||
'model_name': 'test_model',
|
||||
@@ -135,7 +134,6 @@ async def test_scenario_3_1_1_default_prediction_export(
|
||||
'addr_2',
|
||||
2,
|
||||
'float',
|
||||
ANY,
|
||||
{
|
||||
'model_id': 311,
|
||||
'model_name': 'test_model',
|
||||
@@ -222,20 +220,28 @@ async def test_scenario_3_1_2_export_with_opc_only(
|
||||
opc_write_data = cast(Any, test_activities.opc_repository['1'].write_data)
|
||||
opc_write_data.assert_has_calls(
|
||||
[
|
||||
call('addr_1', 0.5, 'float', ANY,
|
||||
{
|
||||
'model_id': 312,
|
||||
'model_name': 'test_model',
|
||||
'schedule_name': 'test-schedule',
|
||||
'workflow_name': 'predictions_batch',
|
||||
}),
|
||||
call('addr_2', 0, 'float', ANY,
|
||||
{
|
||||
'model_id': 312,
|
||||
'model_name': 'test_model',
|
||||
'schedule_name': 'test-schedule',
|
||||
'workflow_name': 'predictions_batch',
|
||||
}),
|
||||
call(
|
||||
'addr_1',
|
||||
0.5,
|
||||
'float',
|
||||
{
|
||||
'model_id': 312,
|
||||
'model_name': 'test_model',
|
||||
'schedule_name': 'test-schedule',
|
||||
'workflow_name': 'predictions_batch',
|
||||
},
|
||||
),
|
||||
call(
|
||||
'addr_2',
|
||||
0,
|
||||
'float',
|
||||
{
|
||||
'model_id': 312,
|
||||
'model_name': 'test_model',
|
||||
'schedule_name': 'test-schedule',
|
||||
'workflow_name': 'predictions_batch',
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -470,20 +476,28 @@ async def test_scenario_3_1_5_export_without_transformed_data(
|
||||
opc_write_data = cast(Any, test_activities.opc_repository['1'].write_data)
|
||||
opc_write_data.assert_has_calls(
|
||||
[
|
||||
call('addr_1', 0.5, 'float', ANY,
|
||||
{
|
||||
'model_id': 315,
|
||||
'model_name': 'test_model',
|
||||
'schedule_name': 'test-schedule',
|
||||
'workflow_name': 'predictions_batch',
|
||||
}),
|
||||
call('addr_2', 0, 'float', ANY,
|
||||
{
|
||||
'model_id': 315,
|
||||
'model_name': 'test_model',
|
||||
'schedule_name': 'test-schedule',
|
||||
'workflow_name': 'predictions_batch',
|
||||
}),
|
||||
call(
|
||||
'addr_1',
|
||||
0.5,
|
||||
'float',
|
||||
{
|
||||
'model_id': 315,
|
||||
'model_name': 'test_model',
|
||||
'schedule_name': 'test-schedule',
|
||||
'workflow_name': 'predictions_batch',
|
||||
},
|
||||
),
|
||||
call(
|
||||
'addr_2',
|
||||
0,
|
||||
'float',
|
||||
{
|
||||
'model_id': 315,
|
||||
'model_name': 'test_model',
|
||||
'schedule_name': 'test-schedule',
|
||||
'workflow_name': 'predictions_batch',
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -616,7 +630,110 @@ async def test_scenario_3_2_2_opc_write_error(
|
||||
prediction_confidence=12,
|
||||
comments='Some data could not be written to OPC servers',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scenario_3_2_4_opc_session_bad_mock(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
Scenario 3.2.4 (mock): Tier-1 session error maps to confidence 14.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
model_id = 324
|
||||
|
||||
insert_sample_data(postgres_engine, model_id, [23.5, 78.2])
|
||||
|
||||
opc_write_data = cast(Any, test_activities.opc_repository['1'].write_data)
|
||||
opc_write_data.return_value = (
|
||||
False,
|
||||
{
|
||||
'opc_error_kind': 'session_bad',
|
||||
'opc_status': 'BadSessionIdInvalid',
|
||||
'notification_id': 'OPC_WRITE_DATA_ERROR_1',
|
||||
'message': 'OPC session invalid',
|
||||
'block': 'opc_repository',
|
||||
'level': NotificationLevel.ERROR,
|
||||
'attachment_content': 'BadSessionIdInvalid',
|
||||
},
|
||||
)
|
||||
|
||||
input_data = get_base_input_data(model_id)
|
||||
input_data['opc_output_config'] = {
|
||||
'1': {
|
||||
'prediction_tags': {'addr_1': {'data_type': 'float'}},
|
||||
'confidence_tags': {'addr_2': {'data_type': 'float'}},
|
||||
}
|
||||
}
|
||||
input_data['pi_web_api_output_config'] = None
|
||||
|
||||
await start_and_await_workflow(
|
||||
client, PredictionsBatch.run, input_data, make_workflow_id('test-opc-session-bad-mock')
|
||||
)
|
||||
|
||||
assert_prediction(
|
||||
postgres_engine,
|
||||
model_id,
|
||||
prediction_confidence=14,
|
||||
comments_contains='OPC UA session/channel error: BadSessionIdInvalid',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scenario_3_2_5_opc_reconnect_in_progress_mock(
|
||||
temporal_test_env: WorkflowEnvironment,
|
||||
temporal_worker: Worker,
|
||||
test_activities: Activities,
|
||||
postgres_engine,
|
||||
):
|
||||
"""
|
||||
Scenario 3.2.5 (mock): reconnect_in_progress maps to confidence 14.
|
||||
"""
|
||||
client = temporal_test_env.client
|
||||
model_id = 325
|
||||
|
||||
insert_sample_data(postgres_engine, model_id, [23.5, 78.2])
|
||||
|
||||
opc_write_data = cast(Any, test_activities.opc_repository['1'].write_data)
|
||||
opc_write_data.return_value = (
|
||||
False,
|
||||
{
|
||||
'opc_error_kind': 'reconnect_in_progress',
|
||||
'notification_id': 'OPC_WRITE_DATA_ERROR_1',
|
||||
'message': 'OPC reconnect in progress',
|
||||
'block': 'opc_repository',
|
||||
'level': NotificationLevel.ERROR,
|
||||
'attachment_content': 'reconnect',
|
||||
},
|
||||
)
|
||||
|
||||
input_data = get_base_input_data(model_id)
|
||||
input_data['opc_output_config'] = {
|
||||
'1': {
|
||||
'prediction_tags': {'addr_1': {'data_type': 'float'}},
|
||||
'confidence_tags': {'addr_2': {'data_type': 'float'}},
|
||||
}
|
||||
}
|
||||
input_data['pi_web_api_output_config'] = None
|
||||
|
||||
await start_and_await_workflow(
|
||||
client,
|
||||
PredictionsBatch.run,
|
||||
input_data,
|
||||
make_workflow_id('test-opc-reconnect-mock'),
|
||||
)
|
||||
|
||||
assert_prediction(
|
||||
postgres_engine,
|
||||
model_id,
|
||||
prediction_confidence=14,
|
||||
comments_contains='OPC UA reconnect in progress',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user