448 lines
15 KiB
Python
448 lines
15 KiB
Python
import io
|
|
import sqlite3
|
|
from dataclasses import replace
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, Mock
|
|
|
|
import psycopg
|
|
import pytest
|
|
import yaml
|
|
|
|
from production_analytics.calculations.config import CalculationConfigError
|
|
from production_analytics.cli.__main__ import main
|
|
from production_analytics.erp import CurrentWorkplaceStatus, ErpReadError
|
|
from production_analytics.service.material_efficiency import (
|
|
MaterialEfficiencySnapshot,
|
|
)
|
|
from production_analytics.service.material_efficiency_runner import MaterialEfficiencyRunner
|
|
from production_analytics.service.material_efficiency_runtime import (
|
|
build_material_efficiency_runner,
|
|
)
|
|
from production_analytics.service.postgres_material import (
|
|
MaterialConsumptionSnapshot,
|
|
PostgresSettings,
|
|
)
|
|
from production_analytics.service.postgres_material_efficiency import (
|
|
PostgresMaterialEfficiencyWriter,
|
|
)
|
|
|
|
NOW = datetime(2026, 9, 4, 10, tzinfo=UTC)
|
|
SNAPSHOT = MaterialEfficiencySnapshot(
|
|
"example",
|
|
"machine",
|
|
"calc",
|
|
"00123",
|
|
" ORDER/00123'; -- ",
|
|
"article",
|
|
"fabric 5 x 50 m",
|
|
5,
|
|
NOW,
|
|
NOW,
|
|
800,
|
|
1000,
|
|
1.25,
|
|
1250,
|
|
)
|
|
SETTINGS = PostgresSettings("localhost", 5432, "analytics", "writer", "secret")
|
|
|
|
|
|
@pytest.fixture
|
|
def storage(monkeypatch):
|
|
# Real schema and INSERT semantics, with only driver transport adapted for SQLite.
|
|
database = sqlite3.connect(":memory:")
|
|
database.executescript(Path("db/schema.sql").read_text())
|
|
connection = MagicMock()
|
|
|
|
def execute(sql, params):
|
|
assert sql.lstrip().startswith("INSERT INTO")
|
|
assert "SELECT" not in sql.upper()
|
|
assert "RETURNING 1" in sql
|
|
assert sql.count("%s") == 14
|
|
assert SNAPSHOT.enlyze_production_order not in sql
|
|
return database.execute(
|
|
sql.replace("%s", "?"),
|
|
tuple(value.isoformat() if isinstance(value, datetime) else value for value in params),
|
|
)
|
|
|
|
connection.__enter__.return_value.execute.side_effect = execute
|
|
connect = Mock(return_value=connection)
|
|
monkeypatch.setattr(psycopg, "connect", connect)
|
|
yield database, connect, connection
|
|
database.close()
|
|
|
|
|
|
@pytest.mark.parametrize("nullable", [False, True])
|
|
def test_all_fields_and_immutable_event(storage, nullable):
|
|
database, connect, connection = storage
|
|
snapshot = (
|
|
replace(SNAPSHOT, article_number=None, article_description=None, nominal_width_m=None)
|
|
if nullable
|
|
else SNAPSHOT
|
|
)
|
|
writer = PostgresMaterialEfficiencyWriter(SETTINGS)
|
|
assert writer.write(snapshot) is True
|
|
inserted = writer.write(
|
|
replace(
|
|
snapshot,
|
|
material_consumption_kg=9999,
|
|
material_snapshot_timestamp=NOW.replace(minute=1),
|
|
)
|
|
)
|
|
assert inserted is False
|
|
assert connection.__enter__.return_value.execute.call_count == 2
|
|
assert database.execute("SELECT * FROM material_efficiency_snapshots").fetchall() == [
|
|
(
|
|
NOW.isoformat(),
|
|
NOW.isoformat(),
|
|
"calc",
|
|
"machine",
|
|
"example",
|
|
"00123",
|
|
SNAPSHOT.enlyze_production_order,
|
|
snapshot.article_number,
|
|
snapshot.article_description,
|
|
snapshot.nominal_width_m,
|
|
800,
|
|
1000,
|
|
1.25,
|
|
1250,
|
|
)
|
|
]
|
|
assert connect.call_count == 2
|
|
assert connect.call_args.kwargs["connect_timeout"] == 10
|
|
connection.__exit__.assert_called_with(None, None, None)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field,value",
|
|
[
|
|
("calculation_id", "other"),
|
|
("machine_id", "other"),
|
|
("enlyze_production_order", SNAPSHOT.enlyze_production_order.strip()),
|
|
("enlyze_production_order", SNAPSHOT.enlyze_production_order + "-combined"),
|
|
("erp_feedback_timestamp", NOW.replace(minute=1)),
|
|
],
|
|
)
|
|
def test_exact_identity(storage, field, value):
|
|
writer = PostgresMaterialEfficiencyWriter(SETTINGS)
|
|
writer.write(SNAPSHOT)
|
|
writer.write(replace(SNAPSHOT, **{field: value}))
|
|
assert storage[0].execute("SELECT count(*) FROM material_efficiency_snapshots").fetchone() == (
|
|
2,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("field", ["erp_feedback_timestamp", "material_snapshot_timestamp"])
|
|
def test_naive_rejected(storage, field):
|
|
with pytest.raises(ValueError, match="timezone-aware"):
|
|
PostgresMaterialEfficiencyWriter(SETTINGS).write(
|
|
replace(SNAPSHOT, **{field: NOW.replace(tzinfo=None)}),
|
|
)
|
|
storage[1].assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field",
|
|
[
|
|
"nominal_width_m",
|
|
"good_quantity_m2",
|
|
"material_consumption_kg",
|
|
"material_consumption_kg_per_m2",
|
|
"material_consumption_g_per_m2",
|
|
],
|
|
)
|
|
@pytest.mark.parametrize("value", [float("nan"), float("inf"), -float("inf")])
|
|
def test_nonfinite_rejected(storage, field, value):
|
|
with pytest.raises(ValueError, match="finite"):
|
|
PostgresMaterialEfficiencyWriter(SETTINGS).write(replace(SNAPSHOT, **{field: value}))
|
|
storage[1].assert_not_called()
|
|
|
|
|
|
def run_cycles(service, writer, cycles, interval=60, stdout=None):
|
|
sleep = Mock(side_effect=[None] * (cycles - 1) + [KeyboardInterrupt])
|
|
errors = io.StringIO()
|
|
MaterialEfficiencyRunner(
|
|
service, writer, poll_interval_seconds=interval, sleep=sleep, stderr=errors, stdout=stdout
|
|
).run()
|
|
assert sleep.call_args_list == [((interval,),)] * cycles
|
|
return errors.getvalue()
|
|
|
|
|
|
def test_runner_history_and_none(storage):
|
|
service = Mock(
|
|
evaluate_current=Mock(
|
|
side_effect=[
|
|
None,
|
|
SNAPSHOT,
|
|
SNAPSHOT,
|
|
replace(SNAPSHOT, erp_feedback_timestamp=NOW.replace(minute=15)),
|
|
]
|
|
)
|
|
)
|
|
output = io.StringIO()
|
|
run_cycles(service, PostgresMaterialEfficiencyWriter(SETTINGS), 4, 73, stdout=output)
|
|
lines = output.getvalue().splitlines()
|
|
assert len(lines) == 2
|
|
assert lines[0].startswith(NOW.isoformat())
|
|
assert lines[1].startswith(NOW.replace(minute=15).isoformat())
|
|
assert storage[1].call_count == 3
|
|
assert storage[0].execute("SELECT count(*) FROM material_efficiency_snapshots").fetchone() == (
|
|
2,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("error", [ErpReadError, psycopg.OperationalError, psycopg.InterfaceError])
|
|
def test_read_recovers(error):
|
|
service = Mock(evaluate_current=Mock(side_effect=[error("secret"), SNAPSHOT]))
|
|
writer = Mock()
|
|
errors = run_cycles(service, writer, 2)
|
|
writer.write.assert_called_once_with(SNAPSHOT)
|
|
assert error.__name__ in errors and "secret" not in errors
|
|
|
|
|
|
def test_write_recovers_with_fresh_transaction(storage):
|
|
database, connect, connection = storage
|
|
connect.side_effect = [psycopg.OperationalError("secret"), connection]
|
|
errors = run_cycles(
|
|
Mock(evaluate_current=Mock(return_value=SNAPSHOT)),
|
|
PostgresMaterialEfficiencyWriter(SETTINGS),
|
|
2,
|
|
)
|
|
assert "OperationalError" in errors and "secret" not in errors
|
|
assert connect.call_count == 2
|
|
assert database.execute("SELECT count(*) FROM material_efficiency_snapshots").fetchone() == (1,)
|
|
|
|
|
|
@pytest.mark.parametrize("error", [ValueError, TypeError, psycopg.ProgrammingError])
|
|
def test_programming_errors_propagate(error):
|
|
with pytest.raises(error):
|
|
run_cycles(Mock(evaluate_current=Mock(side_effect=error("bad contract"))), Mock(), 1)
|
|
|
|
|
|
@pytest.mark.parametrize("interval", [0, -1, float("nan"), float("inf"), True])
|
|
def test_invalid_interval(interval):
|
|
with pytest.raises(CalculationConfigError):
|
|
MaterialEfficiencyRunner(Mock(), Mock(), poll_interval_seconds=interval)
|
|
|
|
|
|
@pytest.fixture
|
|
def runtime_files(tmp_path):
|
|
config = tmp_path / "config.yaml"
|
|
config.write_text(
|
|
yaml.safe_dump(
|
|
dict(
|
|
workplace="example",
|
|
machine_id="machine",
|
|
calculation_id="calc",
|
|
production_order_format="ORDER/{production_order}",
|
|
erp_timezone="Europe/Berlin",
|
|
)
|
|
)
|
|
)
|
|
postgres = tmp_path / "postgres.env"
|
|
postgres.write_text(
|
|
"POSTGRES_HOST=localhost\nPOSTGRES_PORT=5432\nPOSTGRES_DB=analytics\n"
|
|
"POSTGRES_USER=writer\nPOSTGRES_PASSWORD=secret\n"
|
|
)
|
|
erp = tmp_path / "erp.env"
|
|
erp.write_text(
|
|
"ERP_DB_HOST=localhost\nERP_DB_PORT=1433\nERP_DB_NAME=erp\n"
|
|
"ERP_DB_USER=reader\nERP_DB_PASSWORD=secret\n"
|
|
)
|
|
return config, postgres, erp
|
|
|
|
|
|
def test_configured_runtime_naive_feedback_to_persistence(runtime_files, storage):
|
|
config, postgres, erp = runtime_files
|
|
runner = build_material_efficiency_runner(config, secrets_file=postgres, erp_secrets_file=erp)
|
|
assert runner.interval == 60
|
|
runner.service.erp = Mock(
|
|
get_current_workplace_status=Mock(
|
|
return_value=CurrentWorkplaceStatus(
|
|
"example",
|
|
"00123",
|
|
None,
|
|
None,
|
|
datetime(2026, 9, 4, 12),
|
|
None,
|
|
800,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
)
|
|
)
|
|
runner.service.materials = Mock(
|
|
latest_at_or_before=Mock(
|
|
return_value=MaterialConsumptionSnapshot(
|
|
NOW,
|
|
"calc",
|
|
"machine",
|
|
"ORDER/00123",
|
|
"run",
|
|
1000,
|
|
)
|
|
)
|
|
)
|
|
runner.sleep = Mock(side_effect=KeyboardInterrupt)
|
|
runner.run()
|
|
row = storage[0].execute("SELECT * FROM material_efficiency_snapshots").fetchone()
|
|
assert row[:7] == (
|
|
NOW.isoformat(),
|
|
NOW.isoformat(),
|
|
"calc",
|
|
"machine",
|
|
"example",
|
|
"00123",
|
|
"ORDER/00123",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"changes",
|
|
[
|
|
{"erp_timezone": "invalid"},
|
|
{"production_order_format": "missing"},
|
|
{"workplace": ""},
|
|
{"machine_id": None},
|
|
{"poll_interval_seconds": 0},
|
|
{"unknown": 1},
|
|
],
|
|
)
|
|
def test_startup_config_errors(runtime_files, changes, capsys):
|
|
config, postgres, erp = runtime_files
|
|
config.write_text(yaml.safe_dump(yaml.safe_load(config.read_text()) | changes))
|
|
assert (
|
|
main(
|
|
[
|
|
"run",
|
|
"material-efficiency",
|
|
"--config",
|
|
str(config),
|
|
"--secrets-file",
|
|
str(postgres),
|
|
"--erp-secrets-file",
|
|
str(erp),
|
|
]
|
|
)
|
|
== 2
|
|
)
|
|
assert "Config/startup error" in capsys.readouterr().err
|
|
|
|
|
|
def test_k7_config_and_cli(runtime_files, monkeypatch):
|
|
_, postgres, erp = runtime_files
|
|
runner = build_material_efficiency_runner(
|
|
Path("config/k7-material-efficiency.yaml"), secrets_file=postgres, erp_secrets_file=erp
|
|
)
|
|
assert runner.service.workplace == "K7"
|
|
assert runner.service.machine_id == "c220f95c-a65e-4cb7-99b7-0626d6c7508c"
|
|
assert runner.service.calculation_id == "k7-fiber-consumption"
|
|
assert runner.service.format_template == "K 7-{production_order}"
|
|
assert runner.service.erp_timezone.key == "Europe/Berlin"
|
|
run = Mock()
|
|
monkeypatch.setattr(MaterialEfficiencyRunner, "run", run)
|
|
assert (
|
|
main(
|
|
[
|
|
"run",
|
|
"material-efficiency",
|
|
"--config",
|
|
"config/k7-material-efficiency.yaml",
|
|
"--secrets-file",
|
|
str(postgres),
|
|
"--erp-secrets-file",
|
|
str(erp),
|
|
]
|
|
)
|
|
== 0
|
|
)
|
|
run.assert_called_once()
|
|
|
|
|
|
def test_failed_insert_rolls_back_and_later_cycle_reconnects(storage):
|
|
database, connect, connection = storage
|
|
execute = connection.__enter__.return_value.execute
|
|
original = execute.side_effect
|
|
attempts = 0
|
|
|
|
def fail_once(sql, params):
|
|
nonlocal attempts
|
|
attempts += 1
|
|
if attempts == 1:
|
|
raise psycopg.OperationalError("secret SQL")
|
|
return original(sql, params)
|
|
|
|
execute.side_effect = fail_once
|
|
errors = run_cycles(
|
|
Mock(evaluate_current=Mock(return_value=SNAPSHOT)),
|
|
PostgresMaterialEfficiencyWriter(SETTINGS),
|
|
2,
|
|
)
|
|
assert "secret" not in errors
|
|
assert connect.call_count == 2
|
|
assert connection.__exit__.call_args_list[0].args[0] is psycopg.OperationalError
|
|
assert database.execute("SELECT count(*) FROM material_efficiency_snapshots").fetchone() == (1,)
|
|
|
|
|
|
def test_configured_interval_and_missing_settings(runtime_files, monkeypatch):
|
|
config, postgres, erp = runtime_files
|
|
config.write_text(
|
|
yaml.safe_dump(yaml.safe_load(config.read_text()) | {"poll_interval_seconds": 91})
|
|
)
|
|
runner = build_material_efficiency_runner(config, secrets_file=postgres, erp_secrets_file=erp)
|
|
assert runner.interval == 91
|
|
postgres.write_text("")
|
|
for key in ("HOST", "PORT", "DB", "USER", "PASSWORD"):
|
|
monkeypatch.delenv(f"POSTGRES_{key}", raising=False)
|
|
from production_analytics.enlyze.exploration import ConfigurationError
|
|
|
|
with pytest.raises(ConfigurationError, match="POSTGRES_HOST"):
|
|
build_material_efficiency_runner(config, secrets_file=postgres, erp_secrets_file=erp)
|
|
|
|
|
|
@pytest.mark.parametrize("result", [None, False, True])
|
|
def test_runner_success_output(result):
|
|
snapshot = replace(SNAPSHOT, enlyze_production_order="ORDER/00123")
|
|
service = Mock(evaluate_current=Mock(return_value=None if result is None else snapshot))
|
|
writer = Mock(write=Mock(return_value=result))
|
|
output = Mock(wraps=io.StringIO())
|
|
errors = run_cycles(service, writer, 1, stdout=output)
|
|
assert errors == ""
|
|
if result is None:
|
|
writer.write.assert_not_called()
|
|
else:
|
|
writer.write.assert_called_once_with(snapshot)
|
|
if result is True:
|
|
assert output.getvalue() == (
|
|
"2026-09-04T10:00:00+00:00 workplace='example' production_order='ORDER/00123' "
|
|
"good_m2=800.000 material_kg=1000.000 g_per_m2=1250.000\n"
|
|
)
|
|
output.flush.assert_called_once_with()
|
|
else:
|
|
assert output.getvalue() == ""
|
|
output.flush.assert_not_called()
|
|
|
|
|
|
def test_repeated_duplicates_are_silent(storage):
|
|
writer = PostgresMaterialEfficiencyWriter(SETTINGS)
|
|
assert writer.write(SNAPSHOT) is True
|
|
output = io.StringIO()
|
|
run_cycles(Mock(evaluate_current=Mock(return_value=SNAPSHOT)), writer, 3, stdout=output)
|
|
assert output.getvalue() == ""
|
|
|
|
|
|
def test_commit_failure_does_not_log_success(storage):
|
|
_, _, connection = storage
|
|
connection.__exit__.side_effect = [psycopg.OperationalError("secret driver SQL"), None]
|
|
output = io.StringIO()
|
|
errors = run_cycles(
|
|
Mock(evaluate_current=Mock(return_value=SNAPSHOT)),
|
|
PostgresMaterialEfficiencyWriter(SETTINGS),
|
|
1,
|
|
stdout=output,
|
|
)
|
|
assert output.getvalue() == ""
|
|
assert errors == "Material efficiency cycle failed: OperationalError\n"
|