from datetime import UTC, datetime from unittest.mock import Mock from production_analytics.calculations.material_consumption import ( MaterialIntegrationState, MaterialSample, ) from production_analytics.enlyze.gateway import EnlyzeProductionRun from production_analytics.service.material_polling import ( MaterialPollingService, MaterialPollingState, ) def test_poll_once_starts_at_run_start_without_saved_state() -> None: gateway = Mock() state_store = Mock() state_store.load.return_value = None run = EnlyzeProductionRun( uuid="run-1", machine_id="machine-1", product_id="product-1", production_order="ORDER-1", start=datetime(2026, 9, 3, 5, 7, 47, tzinfo=UTC), end=None, ) gateway.get_open_production_run.return_value = run samples = [ MaterialSample( timestamp=datetime(2026, 9, 3, 5, 7, 50, tzinfo=UTC), material_rate_kg_per_hour=3600.0, gate_value=1.0, ), MaterialSample( timestamp=datetime(2026, 9, 3, 5, 8, 0, tzinfo=UTC), material_rate_kg_per_hour=3600.0, gate_value=1.0, ), ] gateway.get_material_samples.return_value = samples service = MaterialPollingService( gateway=gateway, state_store=state_store, machine_id="machine-1", rate_variable_id="rate-variable", gate_variable_id="gate-variable", gate_threshold=0.5, max_sample_gap_seconds=20.0, ) result = service.poll_once( now=datetime(2026, 9, 3, 5, 8, 10, tzinfo=UTC), ) assert result is not None assert result.run == run assert result.state.cumulative_consumption_kg == 10.0 assert result.state.integrated_running_seconds == 10.0 gateway.get_material_samples.assert_called_once_with( machine_id="machine-1", rate_variable_id="rate-variable", gate_variable_id="gate-variable", start=run.start, end=datetime(2026, 9, 3, 5, 8, 10, tzinfo=UTC), ) state_store.save.assert_called_once_with( "machine-1", run.production_order, MaterialPollingState( run_id=run.uuid, integration_state=result.state, ), ) def test_poll_once_resumes_from_saved_timestamp_without_double_counting() -> None: gateway = Mock() state_store = Mock() run = EnlyzeProductionRun( uuid="run-1", machine_id="machine-1", product_id="product-1", production_order="ORDER-1", start=datetime(2026, 9, 3, 5, 7, 47, tzinfo=UTC), end=None, ) gateway.get_open_production_run.return_value = run saved_state = MaterialIntegrationState( cumulative_consumption_kg=10.0, integrated_running_seconds=10.0, last_processed_timestamp=datetime(2026, 9, 3, 5, 8, 0, tzinfo=UTC), last_material_rate_kg_per_hour=3600.0, last_gate_value=1.0, integration_active=True, ) state_store.load.return_value = MaterialPollingState( run_id=run.uuid, integration_state=saved_state, ) gateway.get_material_samples.return_value = [ MaterialSample( timestamp=datetime(2026, 9, 3, 5, 8, 0, tzinfo=UTC), material_rate_kg_per_hour=3600.0, gate_value=1.0, ), MaterialSample( timestamp=datetime(2026, 9, 3, 5, 8, 10, tzinfo=UTC), material_rate_kg_per_hour=3600.0, gate_value=1.0, ), ] service = MaterialPollingService( gateway=gateway, state_store=state_store, machine_id="machine-1", rate_variable_id="rate-variable", gate_variable_id="gate-variable", gate_threshold=0.5, max_sample_gap_seconds=20.0, ) result = service.poll_once( now=datetime(2026, 9, 3, 5, 8, 20, tzinfo=UTC), ) assert result is not None assert result.state.cumulative_consumption_kg == 20.0 assert result.state.integrated_running_seconds == 20.0 gateway.get_material_samples.assert_called_once_with( machine_id="machine-1", rate_variable_id="rate-variable", gate_variable_id="gate-variable", start=saved_state.last_processed_timestamp, end=datetime(2026, 9, 3, 5, 8, 20, tzinfo=UTC), ) def test_poll_once_without_open_run_does_nothing() -> None: gateway = Mock() state_store = Mock() gateway.get_open_production_run.return_value = None service = MaterialPollingService( gateway=gateway, state_store=state_store, machine_id="machine-1", rate_variable_id="rate-variable", gate_variable_id="gate-variable", gate_threshold=0.5, max_sample_gap_seconds=20.0, ) result = service.poll_once( now=datetime(2026, 9, 3, 5, 8, 20, tzinfo=UTC), ) assert result is None gateway.get_material_samples.assert_not_called() state_store.load.assert_not_called() state_store.save.assert_not_called() def test_new_run_of_same_order_keeps_total_but_restarts_integration_at_run_start() -> None: gateway = Mock() state_store = Mock() run = EnlyzeProductionRun( uuid="run-2", machine_id="machine-1", product_id="product-1", production_order="ORDER-1", start=datetime(2026, 9, 3, 8, 0, 0, tzinfo=UTC), end=None, ) gateway.get_open_production_run.return_value = run previous_integration_state = MaterialIntegrationState( cumulative_consumption_kg=100.0, integrated_running_seconds=360.0, last_processed_timestamp=datetime(2026, 9, 3, 6, 0, 0, tzinfo=UTC), last_material_rate_kg_per_hour=3600.0, last_gate_value=1.0, integration_active=True, ) state_store.load.return_value = MaterialPollingState( run_id="run-1", integration_state=previous_integration_state, ) gateway.get_material_samples.return_value = [ MaterialSample( timestamp=datetime(2026, 9, 3, 8, 0, 10, tzinfo=UTC), material_rate_kg_per_hour=3600.0, gate_value=1.0, ), MaterialSample( timestamp=datetime(2026, 9, 3, 8, 0, 20, tzinfo=UTC), material_rate_kg_per_hour=3600.0, gate_value=1.0, ), ] service = MaterialPollingService( gateway=gateway, state_store=state_store, machine_id="machine-1", rate_variable_id="rate-variable", gate_variable_id="gate-variable", gate_threshold=0.5, max_sample_gap_seconds=20.0, ) result = service.poll_once( now=datetime(2026, 9, 3, 8, 0, 30, tzinfo=UTC), ) assert result is not None assert result.state.cumulative_consumption_kg == 110.0 assert result.state.integrated_running_seconds == 370.0 state_store.load.assert_called_once_with( "machine-1", "ORDER-1", ) gateway.get_material_samples.assert_called_once_with( machine_id="machine-1", rate_variable_id="rate-variable", gate_variable_id="gate-variable", start=run.start, end=datetime(2026, 9, 3, 8, 0, 30, tzinfo=UTC), )