Harden live material polling state

This commit is contained in:
2026-09-05 05:50:01 +02:00
parent 98a92c3739
commit efc563512f
9 changed files with 513 additions and 40 deletions
+71
View File
@@ -1,6 +1,8 @@
from datetime import UTC, datetime
from unittest.mock import Mock
import pytest
from production_analytics.enlyze.gateway import EnlyzeApiGateway
@@ -131,3 +133,72 @@ def test_get_material_samples_uses_column_names_not_fixed_positions() -> None:
assert samples[0].material_rate_kg_per_hour == 1028.5
assert samples[0].gate_value == 2.1
@pytest.fixture
def timeseries():
client = Mock()
client.post_json.return_value.body = {"data": {
"columns": ["time", "rate", "gate"], "records": [],
}}
args = dict(machine_id="m", rate_variable_id="rate", gate_variable_id="gate",
start=datetime(2026, 9, 3, tzinfo=UTC), end=datetime(2026, 9, 4, tzinfo=UTC))
return EnlyzeApiGateway(client), client, args
@pytest.mark.parametrize("name", ["start", "end"])
def test_naive_window_rejected(timeseries, name) -> None:
gateway, client, args = timeseries
args[name] = args[name].replace(tzinfo=None)
with pytest.raises(ValueError, match="timezone-aware"):
gateway.get_material_samples(**args)
client.post_json.assert_not_called()
def test_reversed_window_rejected(timeseries) -> None:
gateway, client, args = timeseries
args["start"], args["end"] = args["end"], args["start"]
with pytest.raises(ValueError, match="precede"):
gateway.get_material_samples(**args)
client.post_json.assert_not_called()
def test_empty_timeseries(timeseries) -> None:
gateway, client, args = timeseries
assert gateway.get_material_samples(**args) == []
@pytest.mark.parametrize("column", ["time", "rate", "gate"])
def test_missing_required_column(timeseries, column) -> None:
gateway, client, args = timeseries
client.post_json.return_value.body["data"]["columns"].remove(column)
with pytest.raises(ValueError, match="required column"):
gateway.get_material_samples(**args)
@pytest.mark.parametrize("record", [[], {}, ["bad", 1, 1], [None, 1, 1],
["2026-09-03T00:00:00", 1, 1], ["2026-09-03T00:00:00Z", None, 1],
["2026-09-03T00:00:00Z", 1, float("inf")], ["2026-09-03T00:00:00Z", True, 1]])
def test_malformed_records_fail_clearly(timeseries, record) -> None:
gateway, client, args = timeseries
client.post_json.return_value.body["data"]["records"] = [record]
with pytest.raises(ValueError, match="malformed record 0"):
gateway.get_material_samples(**args)
def test_multiple_open_runs_rejected() -> None:
client = Mock()
run = dict(uuid="r", machine="m", production_order="o", start="2026-09-03T00:00:00Z",
end=None)
client.get.return_value.body = {"data": [run, {**run, "uuid": "r2"}]}
with pytest.raises(ValueError, match="multiple open Production Runs"):
EnlyzeApiGateway(client).get_open_production_run("m")
@pytest.mark.parametrize("item", [{}, None, {"end": None},
dict(uuid="r", machine="m", production_order="o", start="bad", end=None)])
def test_malformed_run_fails_clearly(item) -> None:
client = Mock()
client.get.return_value.body = {"data": [item]}
with pytest.raises(ValueError, match="Invalid production-run response"):
EnlyzeApiGateway(client).get_open_production_run("m")
+84
View File
@@ -1,6 +1,8 @@
from datetime import UTC, datetime
from unittest.mock import Mock
import pytest
from production_analytics.calculations.material_consumption import (
MaterialIntegrationState,
MaterialSample,
@@ -241,3 +243,85 @@ def test_new_run_of_same_order_keeps_total_but_restarts_integration_at_run_start
start=run.start,
end=datetime(2026, 9, 3, 8, 0, 30, tzinfo=UTC),
)
@pytest.fixture
def polling(tmp_path):
from production_analytics.service.material_state_store import JsonMaterialStateStore
gateway = Mock()
gateway.get_open_production_run.return_value = EnlyzeProductionRun(
"run", "machine", None, "order", datetime(2026, 9, 3, tzinfo=UTC), None,
)
gateway.get_material_samples.return_value = []
store = JsonMaterialStateStore(tmp_path)
service = MaterialPollingService(
gateway=gateway, state_store=store, machine_id="machine",
rate_variable_id="rate", gate_variable_id="gate", gate_threshold=0.5,
max_sample_gap_seconds=20,
)
return service, gateway, store
def test_empty_response_persists_state_without_inferred_consumption(polling) -> None:
service, gateway, store = polling
now = datetime(2026, 9, 3, 1, tzinfo=UTC)
assert service.poll_once(now=now).state == MaterialIntegrationState()
saved = MaterialPollingState("run", MaterialIntegrationState(
cumulative_consumption_kg=10, integrated_running_seconds=10,
last_processed_timestamp=now, last_material_rate_kg_per_hour=3600,
last_gate_value=1, integration_active=True,
))
store.save("machine", "order", saved)
assert service.poll_once(now=now).state == saved.integration_state
assert store.load("machine", "order") == saved
def test_different_order_does_not_reuse_state(polling) -> None:
service, gateway, store = polling
store.save("machine", "other-order", MaterialPollingState(
"old", MaterialIntegrationState(cumulative_consumption_kg=100),
))
result = service.poll_once(now=datetime(2026, 9, 3, 1, tzinfo=UTC))
assert result.state == MaterialIntegrationState()
assert gateway.get_material_samples.call_args.kwargs["start"] == result.run.start
assert store.load("machine", "other-order").integration_state.cumulative_consumption_kg == 100
def test_now_before_run_start_does_not_query_or_save(polling) -> None:
service, gateway, store = polling
assert service.poll_once(now=datetime(2026, 9, 2, tzinfo=UTC)) is None
gateway.get_material_samples.assert_not_called()
assert store.load("machine", "order") is None
def test_naive_now_is_rejected_before_io(polling) -> None:
service, gateway, store = polling
with pytest.raises(ValueError, match="now must be timezone-aware"):
service.poll_once(now=datetime(2026, 9, 3))
gateway.get_open_production_run.assert_not_called()
def test_new_run_empty_response_resets_baseline_and_retains_totals(polling) -> None:
service, gateway, store = polling
store.save("machine", "order", MaterialPollingState("previous", MaterialIntegrationState(
cumulative_consumption_kg=100, integrated_running_seconds=50,
last_processed_timestamp=datetime(2026, 9, 2, 23, 59, 59, tzinfo=UTC),
last_material_rate_kg_per_hour=3600, last_gate_value=1, integration_active=True,
)))
result = service.poll_once(now=datetime(2026, 9, 3, 1, tzinfo=UTC))
assert result.state == MaterialIntegrationState(
cumulative_consumption_kg=100, integrated_running_seconds=50,
)
assert store.load("machine", "order") == MaterialPollingState("run", result.state)
def test_now_before_saved_timestamp_leaves_state_unchanged(polling) -> None:
service, gateway, store = polling
saved = MaterialPollingState("run", MaterialIntegrationState(
last_processed_timestamp=datetime(2026, 9, 3, 2, tzinfo=UTC),
))
store.save("machine", "order", saved)
assert service.poll_once(now=datetime(2026, 9, 3, 1, tzinfo=UTC)) is None
gateway.get_material_samples.assert_not_called()
assert store.load("machine", "order") == saved
+141
View File
@@ -0,0 +1,141 @@
from datetime import UTC, datetime
import pytest
from production_analytics.calculations.material_consumption import (
MaterialIntegrationState,
)
from production_analytics.service.material_polling import MaterialPollingState
from production_analytics.service.material_state_store import JsonMaterialStateStore
def test_json_state_store_returns_none_for_missing_state(tmp_path) -> None:
store = JsonMaterialStateStore(tmp_path)
assert store.load("machine-1", "ORDER-1") is None
def test_json_state_store_roundtrip(tmp_path) -> None:
store = JsonMaterialStateStore(tmp_path)
original = MaterialPollingState(
run_id="run-1",
integration_state=MaterialIntegrationState(
cumulative_consumption_kg=123.4,
integrated_running_seconds=456.0,
last_processed_timestamp=datetime(
2026, 9, 3, 5, 10, 50, tzinfo=UTC
),
last_material_rate_kg_per_hour=1028.5,
last_gate_value=2.1,
integration_active=True,
),
)
store.save("machine-1", "ORDER-1", original)
restored = store.load("machine-1", "ORDER-1")
assert restored == original
def test_json_state_store_separates_orders(tmp_path) -> None:
store = JsonMaterialStateStore(tmp_path)
state_a = MaterialPollingState(
run_id="run-a",
integration_state=MaterialIntegrationState(
cumulative_consumption_kg=10.0,
),
)
state_b = MaterialPollingState(
run_id="run-b",
integration_state=MaterialIntegrationState(
cumulative_consumption_kg=20.0,
),
)
store.save("machine-1", "ORDER-1", state_a)
store.save("machine-1", "ORDER-2", state_b)
assert store.load("machine-1", "ORDER-1") == state_a
assert store.load("machine-1", "ORDER-2") == state_b
def test_identifiers_are_isolated_safe_and_deterministic(tmp_path) -> None:
store = JsonMaterialStateStore(tmp_path)
pairs = [("a/b", "x"), ("a_b", "x"), ("a", "b__x"), ("a__b", "x"),
("../..", "/tmp/escape"), ("", ""), ("a/b", "y")]
for index, pair in enumerate(pairs):
store.save(*pair, MaterialPollingState(str(index), MaterialIntegrationState()))
assert len(list(tmp_path.iterdir())) == len(pairs)
for index, pair in enumerate(pairs):
assert JsonMaterialStateStore(tmp_path).load(*pair).run_id == str(index)
assert all(path.parent == tmp_path and len(path.name) == 69 for path in tmp_path.iterdir())
def test_failed_write_preserves_primary_and_cleans_temporary_file(tmp_path, monkeypatch) -> None:
import production_analytics.service.material_state_store as module
store = JsonMaterialStateStore(tmp_path)
state = MaterialPollingState("old", MaterialIntegrationState())
store.save("machine", "order", state)
path = next(tmp_path.iterdir())
original = path.read_bytes()
def fail_dump(payload, file, **kwargs):
file.write('{"partial":')
raise OSError("disk full")
monkeypatch.setattr(module.json, "dump", fail_dump)
with pytest.raises(OSError, match="disk full"):
store.save("machine", "order", MaterialPollingState("new", MaterialIntegrationState()))
assert path.read_bytes() == original
assert list(tmp_path.iterdir()) == [path]
def test_replace_failure_preserves_primary(tmp_path, monkeypatch) -> None:
import production_analytics.service.material_state_store as module
store = JsonMaterialStateStore(tmp_path)
state = MaterialPollingState("old", MaterialIntegrationState())
store.save("m", "o", state)
path = next(tmp_path.iterdir())
def fail_replace(source, target):
assert source.parent == target.parent == tmp_path
assert store.load("m", "o") == state
raise OSError("replace failed")
monkeypatch.setattr(module.os, "replace", fail_replace)
with pytest.raises(OSError, match="replace failed"):
store.save("m", "o", MaterialPollingState("new", MaterialIntegrationState()))
assert store.load("m", "o") == state
assert list(tmp_path.iterdir()) == [path]
@pytest.mark.parametrize("contents", ['{', '[]', '{}', '{"run_id": null}',
'{"run_id": "r", "integration_state": {}}'])
def test_corrupt_state_fails_clearly(tmp_path, contents) -> None:
store = JsonMaterialStateStore(tmp_path)
store.save("m", "o", MaterialPollingState("r", MaterialIntegrationState()))
next(tmp_path.iterdir()).write_text(contents)
with pytest.raises(ValueError, match="Invalid material polling state"):
store.load("m", "o")
@pytest.mark.parametrize("field,value", [
("integration_active", "false"), ("cumulative_consumption_kg", float("nan")),
("integrated_running_seconds", -1), ("last_processed_timestamp", "2026-09-03T05:00:00"),
("integration_active", True), ("last_gate_value", True),
])
def test_malformed_integration_state_fails(tmp_path, field, value) -> None:
import json
store = JsonMaterialStateStore(tmp_path)
store.save("m", "o", MaterialPollingState("r", MaterialIntegrationState()))
path = next(tmp_path.iterdir())
payload = json.loads(path.read_text())
payload["integration_state"][field] = value
path.write_text(json.dumps(payload))
with pytest.raises(ValueError, match="Invalid material polling state"):
store.load("m", "o")