142 lines
5.2 KiB
Python
142 lines
5.2 KiB
Python
import io
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from unittest.mock import Mock, patch
|
|
|
|
from production_analytics.calculations.channel_config import load_channel_material_calculation
|
|
from production_analytics.calculations.material_consumption import MaterialIntegrationState
|
|
from production_analytics.cli.__main__ import _parser, main
|
|
from production_analytics.enlyze.gateway import EnlyzeProductionRun
|
|
from production_analytics.service.channel_material import ChannelPollResult
|
|
from production_analytics.service.channel_material_runner import ChannelMaterialPollingRunner
|
|
from production_analytics.service.channel_material_runtime import build_channel_material_runner
|
|
|
|
EXAMPLE = (
|
|
Path(__file__).resolve().parents[1] / "config/e1-channel-material-consumption.example.yaml"
|
|
)
|
|
NOW = datetime(2026, 9, 1, tzinfo=UTC)
|
|
|
|
|
|
def test_cli_channel_material_arguments():
|
|
args = _parser().parse_args(
|
|
[
|
|
"run",
|
|
"channel-material-poll",
|
|
"--config",
|
|
str(EXAMPLE),
|
|
"--poll-interval-seconds",
|
|
"15",
|
|
"--state-directory",
|
|
"state",
|
|
"--secrets-file",
|
|
"secrets.env",
|
|
"--once",
|
|
]
|
|
)
|
|
assert args.command == "channel-material-poll"
|
|
assert args.config == EXAMPLE
|
|
assert args.poll_interval_seconds == 15
|
|
assert args.state_directory == Path("state")
|
|
assert args.secrets_file == Path("secrets.env")
|
|
assert args.once
|
|
|
|
|
|
def test_runtime_constructs_channel_service(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("ENLYZE_BASE_URL", "https://example.invalid/api/")
|
|
for key, value in {
|
|
"HOST": "localhost",
|
|
"PORT": "5432",
|
|
"DB": "analytics",
|
|
"USER": "user",
|
|
"PASSWORD": "environment-password",
|
|
}.items():
|
|
monkeypatch.setenv(f"POSTGRES_{key}", value)
|
|
secrets = tmp_path / "secrets.env"
|
|
secrets.write_text("ENLYZE_API_KEY=secret-from-file\nPOSTGRES_PASSWORD=file-password\n")
|
|
calculation = load_channel_material_calculation(EXAMPLE)
|
|
with patch(
|
|
"production_analytics.service.channel_material_runtime.ChannelMaterialPollingService"
|
|
) as service:
|
|
runner = build_channel_material_runner(
|
|
calculation,
|
|
poll_interval_seconds=7,
|
|
state_directory=tmp_path / "state",
|
|
secrets_file=secrets,
|
|
)
|
|
kwargs = service.call_args.kwargs
|
|
assert kwargs["machine_id"] == calculation.machine_ref
|
|
assert kwargs["channels"] == calculation.channels
|
|
assert kwargs["max_sample_gap_seconds"] == calculation.max_sample_gap_seconds
|
|
assert kwargs["percentage_tolerance"] == calculation.percentage_tolerance
|
|
assert kwargs["material_names"] == calculation.material_names
|
|
assert kwargs["gateway"]._client._settings.api_key == "secret-from-file"
|
|
assert kwargs["state_store"].directory == tmp_path / "state"
|
|
assert runner.service is service.return_value
|
|
assert runner.interval == 7
|
|
assert runner.snapshot_writer.settings.password == "file-password"
|
|
|
|
|
|
def test_once_executes_one_cycle_and_exits(capsys):
|
|
with patch(
|
|
"production_analytics.service.channel_material_runtime.build_channel_material_runner"
|
|
) as build:
|
|
assert main(["run", "channel-material-poll", "--config", str(EXAMPLE), "--once"]) == 0
|
|
build.return_value.run_once.assert_called_once_with()
|
|
build.return_value.run.assert_not_called()
|
|
assert capsys.readouterr().err == ""
|
|
|
|
|
|
def test_no_snapshots_are_written_without_eligible_production_run():
|
|
writer = Mock()
|
|
output = io.StringIO()
|
|
runner = ChannelMaterialPollingRunner(
|
|
Mock(poll_once=Mock(return_value=None)),
|
|
machine_id="machine",
|
|
calculation_id="calculation",
|
|
snapshot_writer=writer,
|
|
poll_interval_seconds=10,
|
|
clock=lambda: NOW,
|
|
stdout=output,
|
|
)
|
|
assert not runner.run_once()
|
|
writer.write.assert_not_called()
|
|
assert "snapshots=0" in output.getvalue()
|
|
|
|
|
|
def test_one_cycle_writes_each_channel_snapshot():
|
|
run = EnlyzeProductionRun("run", "machine", None, "order", NOW, None)
|
|
first = ChannelPollResult(
|
|
run, "Ex1", "C1", MaterialIntegrationState(1.5, 10), "42", "Material", "MAPPED", True
|
|
)
|
|
second = ChannelPollResult(
|
|
run, "Ex1", "C2", MaterialIntegrationState(2.5, 10), None, None, "AMBIGUOUS", False
|
|
)
|
|
writer = Mock()
|
|
output = io.StringIO()
|
|
runner = ChannelMaterialPollingRunner(
|
|
Mock(poll_once=Mock(return_value=(first, second))),
|
|
machine_id="machine",
|
|
calculation_id="calculation",
|
|
snapshot_writer=writer,
|
|
poll_interval_seconds=10,
|
|
clock=lambda: NOW,
|
|
stdout=output,
|
|
)
|
|
assert runner.run_once()
|
|
assert writer.write.call_count == 2
|
|
assert writer.write.call_args_list[0].kwargs == {
|
|
"timestamp": NOW,
|
|
"calculation_id": "calculation",
|
|
"machine_id": "machine",
|
|
"extruder": "Ex1",
|
|
"channel": "C1",
|
|
"production_order": "order",
|
|
"run_id": "run",
|
|
"cumulative_consumption_kg": 1.5,
|
|
"material_number": "42",
|
|
"material_name": "Material",
|
|
"material_mapping_status": "MAPPED",
|
|
"percentage_sum_valid": True,
|
|
}
|
|
assert "channel snapshots=2 written" in output.getvalue()
|