293 lines
11 KiB
Python
293 lines
11 KiB
Python
from datetime import UTC, datetime
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from production_analytics.enlyze.gateway import EnlyzeApiGateway
|
|
|
|
|
|
def test_get_open_production_run_returns_current_run() -> None:
|
|
client = Mock()
|
|
client.get.return_value.body = {
|
|
"data": [
|
|
{
|
|
"uuid": "closed-run",
|
|
"machine": "machine-1",
|
|
"product": "product-a",
|
|
"production_order": "ORDER-1",
|
|
"start": "2026-09-03T04:00:00Z",
|
|
"end": "2026-09-03T05:00:00Z",
|
|
},
|
|
{
|
|
"uuid": "open-run",
|
|
"machine": "machine-1",
|
|
"product": "product-b",
|
|
"production_order": "ORDER-2",
|
|
"start": "2026-09-03T05:07:47Z",
|
|
"end": None,
|
|
},
|
|
]
|
|
}
|
|
|
|
gateway = EnlyzeApiGateway(client)
|
|
|
|
run = gateway.get_open_production_run("machine-1")
|
|
|
|
assert run is not None
|
|
assert run.uuid == "open-run"
|
|
assert run.machine_id == "machine-1"
|
|
assert run.product_id == "product-b"
|
|
assert run.production_order == "ORDER-2"
|
|
assert run.start == datetime(2026, 9, 3, 5, 7, 47, tzinfo=UTC)
|
|
assert run.end is None
|
|
|
|
client.get.assert_called_once_with(
|
|
"/v2/production-runs",
|
|
{"machine": "machine-1"},
|
|
)
|
|
|
|
|
|
def test_get_open_production_run_returns_none_without_open_run() -> None:
|
|
client = Mock()
|
|
client.get.return_value.body = {
|
|
"data": [
|
|
{
|
|
"uuid": "closed-run",
|
|
"machine": "machine-1",
|
|
"product": "product-a",
|
|
"production_order": "ORDER-1",
|
|
"start": "2026-09-03T04:00:00Z",
|
|
"end": "2026-09-03T05:00:00Z",
|
|
}
|
|
]
|
|
}
|
|
|
|
gateway = EnlyzeApiGateway(client)
|
|
|
|
assert gateway.get_open_production_run("machine-1") is None
|
|
|
|
|
|
def test_get_material_samples_requests_rate_and_gate_together() -> None:
|
|
client = Mock()
|
|
client.post_json.return_value.body = {
|
|
"data": {
|
|
"columns": ["time", "rate-variable", "gate-variable"],
|
|
"records": [
|
|
["2026-09-03T05:09:50Z", 1028.5, 2.1],
|
|
["2026-09-03T05:10:00Z", 1029.0, 2.2],
|
|
],
|
|
}
|
|
}
|
|
|
|
gateway = EnlyzeApiGateway(client)
|
|
|
|
samples = gateway.get_material_samples(
|
|
machine_id="machine-1",
|
|
rate_variable_id="rate-variable",
|
|
gate_variable_id="gate-variable",
|
|
start=datetime(2026, 9, 3, 5, 9, 50, tzinfo=UTC),
|
|
end=datetime(2026, 9, 3, 5, 10, 10, tzinfo=UTC),
|
|
)
|
|
|
|
assert len(samples) == 2
|
|
assert samples[0].timestamp == datetime(2026, 9, 3, 5, 9, 50, tzinfo=UTC)
|
|
assert samples[0].material_rate_kg_per_hour == 1028.5
|
|
assert samples[0].gate_value == 2.1
|
|
assert samples[1].material_rate_kg_per_hour == 1029.0
|
|
assert samples[1].gate_value == 2.2
|
|
|
|
client.post_json.assert_called_once_with(
|
|
"/v2/timeseries",
|
|
{
|
|
"machine": "machine-1",
|
|
"start": "2026-09-03T05:09:50+00:00",
|
|
"end": "2026-09-03T05:10:10+00:00",
|
|
"variables": [
|
|
{"uuid": "rate-variable"},
|
|
{"uuid": "gate-variable"},
|
|
],
|
|
},
|
|
)
|
|
|
|
|
|
def test_get_material_samples_uses_column_names_not_fixed_positions() -> None:
|
|
client = Mock()
|
|
client.post_json.return_value.body = {
|
|
"data": {
|
|
"columns": ["time", "gate-variable", "rate-variable"],
|
|
"records": [
|
|
["2026-09-03T05:09:50Z", 2.1, 1028.5],
|
|
],
|
|
}
|
|
}
|
|
|
|
gateway = EnlyzeApiGateway(client)
|
|
|
|
samples = gateway.get_material_samples(
|
|
machine_id="machine-1",
|
|
rate_variable_id="rate-variable",
|
|
gate_variable_id="gate-variable",
|
|
start=datetime(2026, 9, 3, 5, 9, 50, tzinfo=UTC),
|
|
end=datetime(2026, 9, 3, 5, 10, 0, tzinfo=UTC),
|
|
)
|
|
|
|
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")
|
|
|
|
|
|
def test_timeseries_with_null_cursor(timeseries) -> None:
|
|
gateway, client, args = timeseries
|
|
client.post_json.return_value.body["metadata"] = {"next_cursor": None}
|
|
client.post_json.return_value.body["data"]["records"] = [
|
|
["2026-09-03T00:00:00Z", 10, 2],
|
|
]
|
|
samples = gateway.get_material_samples(**args)
|
|
assert len(samples) == 1
|
|
assert samples[0].material_rate_kg_per_hour == 10
|
|
client.post_json.assert_called_once()
|
|
|
|
|
|
def test_timeseries_pagination_resolves_columns_per_page(timeseries) -> None:
|
|
gateway, client, args = timeseries
|
|
client.post_json.side_effect = [
|
|
Mock(body={
|
|
"data": {"columns": ["time", "rate", "gate"],
|
|
"records": [["2026-09-03T00:00:00Z", 10, 2]]},
|
|
"metadata": {"next_cursor": "continuation"},
|
|
}),
|
|
Mock(body={
|
|
"data": {"columns": ["gate", "time", "rate"],
|
|
"records": [[3, "2026-09-03T01:00:00Z", 20]]},
|
|
"metadata": {"next_cursor": None},
|
|
}),
|
|
]
|
|
samples = gateway.get_material_samples(**args)
|
|
assert [(s.timestamp, s.material_rate_kg_per_hour, s.gate_value) for s in samples] == [
|
|
(datetime(2026, 9, 3, tzinfo=UTC), 10, 2),
|
|
(datetime(2026, 9, 3, 1, tzinfo=UTC), 20, 3),
|
|
]
|
|
assert client.post_json.call_count == 2
|
|
first, second = client.post_json.call_args_list
|
|
assert first.args == ("/v2/timeseries", {
|
|
"machine": "m", "start": args["start"].isoformat(), "end": args["end"].isoformat(),
|
|
"variables": [{"uuid": "rate"}, {"uuid": "gate"}],
|
|
})
|
|
assert second.args == ("/v2/timeseries", {**first.args[1], "cursor": "continuation"})
|
|
|
|
|
|
@pytest.mark.parametrize("metadata", [None, [], "bad", {},
|
|
{"next_cursor": ""}, {"next_cursor": 1}, {"next_cursor": False},
|
|
{"next_cursor": []}, {"next_cursor": {}}])
|
|
def test_invalid_pagination_metadata(timeseries, metadata) -> None:
|
|
gateway, client, args = timeseries
|
|
client.post_json.return_value.body["metadata"] = metadata
|
|
with pytest.raises(ValueError, match="Invalid timeseries response.*next_cursor"):
|
|
gateway.get_material_samples(**args)
|
|
client.post_json.assert_called_once()
|
|
|
|
|
|
@pytest.mark.parametrize("cursors", [["a", "a"], ["a", "b", "a"]])
|
|
def test_repeated_pagination_cursor(timeseries, cursors) -> None:
|
|
gateway, client, args = timeseries
|
|
data = client.post_json.return_value.body["data"]
|
|
client.post_json.side_effect = [
|
|
Mock(body={"data": data, "metadata": {"next_cursor": cursor}}) for cursor in cursors
|
|
]
|
|
with pytest.raises(ValueError, match="next_cursor has already been followed"):
|
|
gateway.get_material_samples(**args)
|
|
assert client.post_json.call_count == len(cursors)
|
|
|
|
|
|
@pytest.mark.parametrize("data, error", [
|
|
({"columns": ["time", "rate", "gate"], "records": [[]]}, "malformed record 0"),
|
|
({"columns": ["time", "rate", "rate", "gate"], "records": []}, "required column"),
|
|
({"columns": ["time", "rate"], "records": []}, "required column"),
|
|
({"columns": None, "records": []}, "columns must be a list"),
|
|
({"columns": ["time", "rate", "gate"], "records": None}, "records must be a list"),
|
|
({"columns": ["time", "rate", "gate"],
|
|
"records": [["2026-09-03T01:00:00", 10, 2]]}, "timezone-aware"),
|
|
({"columns": ["time", "rate", "gate"],
|
|
"records": [["2026-09-03T01:00:00Z", float("nan"), 2]]}, "finite"),
|
|
({"columns": ["time", "rate", "gate"],
|
|
"records": [["2026-09-03T01:00:00Z", 10, True]]}, "numeric"),
|
|
])
|
|
def test_malformed_later_page(timeseries, data, error) -> None:
|
|
gateway, client, args = timeseries
|
|
first_body = client.post_json.return_value.body
|
|
first_body["metadata"] = {"next_cursor": "continuation"}
|
|
client.post_json.side_effect = [
|
|
Mock(body=first_body), Mock(body={"data": data, "metadata": {"next_cursor": None}}),
|
|
]
|
|
with pytest.raises(ValueError, match=f"Invalid timeseries response on page 2:.*{error}"):
|
|
gateway.get_material_samples(**args)
|
|
assert client.post_json.call_count == 2
|