Bootstrap material state across production runs

This commit is contained in:
2026-09-05 15:50:45 +02:00
parent ae13c3c35a
commit b89d57d1c1
4 changed files with 235 additions and 46 deletions
+82
View File
@@ -16,6 +16,7 @@ from production_analytics.service.material_polling import (
def test_poll_once_starts_at_run_start_without_saved_state() -> None:
gateway = Mock()
gateway.get_production_runs.return_value = []
state_store = Mock()
state_store.load.return_value = None
@@ -28,6 +29,7 @@ def test_poll_once_starts_at_run_start_without_saved_state() -> None:
end=None,
)
gateway.get_open_production_run.return_value = run
gateway.get_production_runs.return_value = [run]
samples = [
MaterialSample(
@@ -82,6 +84,7 @@ def test_poll_once_starts_at_run_start_without_saved_state() -> None:
def test_poll_once_resumes_from_saved_timestamp_without_double_counting() -> None:
gateway = Mock()
gateway.get_production_runs.return_value = []
state_store = Mock()
run = EnlyzeProductionRun(
@@ -135,6 +138,7 @@ def test_poll_once_resumes_from_saved_timestamp_without_double_counting() -> Non
)
assert result is not None
gateway.get_production_runs.assert_not_called()
assert result.state.cumulative_consumption_kg == 20.0
assert result.state.integrated_running_seconds == 20.0
@@ -149,6 +153,7 @@ def test_poll_once_resumes_from_saved_timestamp_without_double_counting() -> Non
def test_poll_once_without_open_run_does_nothing() -> None:
gateway = Mock()
gateway.get_production_runs.return_value = []
state_store = Mock()
gateway.get_open_production_run.return_value = None
@@ -174,6 +179,7 @@ def test_poll_once_without_open_run_does_nothing() -> None:
def test_new_run_of_same_order_keeps_total_but_restarts_integration_at_run_start() -> None:
gateway = Mock()
gateway.get_production_runs.return_value = []
state_store = Mock()
run = EnlyzeProductionRun(
@@ -228,6 +234,7 @@ def test_new_run_of_same_order_keeps_total_but_restarts_integration_at_run_start
)
assert result is not None
gateway.get_production_runs.assert_not_called()
assert result.state.cumulative_consumption_kg == 110.0
assert result.state.integrated_running_seconds == 370.0
@@ -250,6 +257,7 @@ def polling(tmp_path):
from production_analytics.service.material_state_store import JsonMaterialStateStore
gateway = Mock()
gateway.get_production_runs.return_value = []
gateway.get_open_production_run.return_value = EnlyzeProductionRun(
"run", "machine", None, "order", datetime(2026, 9, 3, tzinfo=UTC), None,
)
@@ -291,6 +299,7 @@ def test_different_order_does_not_reuse_state(polling) -> None:
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_production_runs.assert_not_called()
gateway.get_material_samples.assert_not_called()
assert store.load("machine", "order") is None
@@ -325,3 +334,76 @@ def test_now_before_saved_timestamp_leaves_state_unchanged(polling) -> None:
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
@pytest.mark.parametrize("order", [
"K 7-12026000815", "K 7-12025000074-K 7-12025000075",
])
@pytest.mark.parametrize("gap_seconds", [5, 86400])
def test_bootstrap_replays_exact_order_segments_and_then_resumes(
polling, order, gap_seconds,
) -> None:
from dataclasses import replace
from datetime import timedelta
service, gateway, store = polling
base = datetime(2026, 9, 3, tzinfo=UTC)
runs = [
EnlyzeProductionRun(
f"run-{i}", "machine", None, order,
base + timedelta(seconds=i * (10 + gap_seconds)),
base + timedelta(seconds=i * (10 + gap_seconds) + 10) if i < 4 else None,
)
for i in range(5)
]
current = runs[-1]
now = current.start + timedelta(seconds=10)
gateway.get_open_production_run.return_value = current
gateway.get_production_runs.return_value = [
current, runs[2], runs[0], runs[3], runs[1],
replace(runs[0], uuid="unrelated", production_order=order + "-suffix"),
replace(runs[0], uuid="component", production_order="K 7-12025000074"),
replace(runs[0], uuid="whitespace", production_order=order + " "),
replace(current, uuid="future", start=now + timedelta(days=1)),
]
def samples(**kwargs):
return [
MaterialSample(kwargs["start"], 3600, 1),
MaterialSample(kwargs["end"], 3600, 1),
]
gateway.get_material_samples.side_effect = samples
result = service.poll_once(now=now)
assert result.state.cumulative_consumption_kg == 50
assert result.state.integrated_running_seconds == 50
assert [
(call.kwargs["start"], call.kwargs["end"])
for call in gateway.get_material_samples.call_args_list
] == [(run.start, run.end or now) for run in runs]
assert store.load("machine", order) == MaterialPollingState(current.uuid, result.state)
gateway.get_production_runs.reset_mock()
gateway.get_material_samples.reset_mock()
result = service.poll_once(now=now + timedelta(seconds=10))
gateway.get_production_runs.assert_not_called()
assert gateway.get_material_samples.call_args.kwargs["start"] == now
assert result.state.cumulative_consumption_kg == 60
assert result.state.integrated_running_seconds == 60
def test_bootstrap_failure_does_not_persist_partial_totals(polling) -> None:
from dataclasses import replace
from datetime import timedelta
service, gateway, store = polling
current = gateway.get_open_production_run.return_value
gateway.get_production_runs.return_value = [
replace(current, uuid="old", start=current.start - timedelta(hours=1),
end=current.start - timedelta(minutes=30)),
current,
]
gateway.get_material_samples.side_effect = [[], ValueError("failed page")]
with pytest.raises(ValueError, match="failed page"):
service.poll_once(now=current.start + timedelta(seconds=10))
assert store.load("machine", "order") is None