Add channel material and downtime analytics

This commit is contained in:
2026-10-01 12:27:47 +02:00
parent 5452dc035b
commit 640ec53330
24 changed files with 2362 additions and 76 deletions
+21
View File
@@ -0,0 +1,21 @@
"""Shared test adaptations for the production PostgreSQL schema."""
import re
from pathlib import Path
import pytest
@pytest.fixture(scope="session")
def sqlite_schema() -> str:
"""Keep PostgreSQL DDL authoritative; adapt JSON storage and clock for SQLite.
SQLite stores serialized JSON as text. These tests exercise schema and INSERT
semantics, not PostgreSQL JSONB operators or TimescaleDB behavior.
"""
schema = (Path(__file__).resolve().parents[1] / "db" / "schema.sql").read_text(
encoding="utf-8"
)
schema = re.sub(r"::jsonb\b", "", schema)
schema = re.sub(r"\bjsonb\b", "text", schema)
return schema.replace("DEFAULT now()", "DEFAULT CURRENT_TIMESTAMP")
+158
View File
@@ -0,0 +1,158 @@
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import Mock
import pytest
from production_analytics.calculations.channel_config import load_channel_material_calculation
from production_analytics.calculations.channel_material import (
MATERIAL_AMBIGUOUS,
MATERIAL_UNAVAILABLE,
DosingChannelSample,
channel_rates,
validate_active_percentage_sum,
)
from production_analytics.enlyze.gateway import EnlyzeApiGateway, EnlyzeProductionRun
from production_analytics.service.channel_material import (
ChannelDefinition,
ChannelMaterialPollingService,
)
from production_analytics.service.channel_material_state_store import JsonChannelMaterialStateStore
NOW = datetime(2026, 9, 1, tzinfo=UTC)
E1_CONFIG = (
Path(__file__).resolve().parents[1] / "config/e1-channel-material-consumption.example.yaml"
)
def sample(*, on=True, percent=100, material=None, timestamp=NOW):
return DosingChannelSample(timestamp, 120, on, percent, 10, material)
def test_off_channel_with_nonzero_displayed_percentage_contributes_zero():
assert channel_rates((sample(on=False, percent=40),))[0].sample.material_rate_kg_per_hour == 0
def test_e1_config_dry_run_resolves_every_signal_through_gateway_without_sampling():
config = load_channel_material_calculation(E1_CONFIG)
origins = {
reference
for channel in config.channels
for reference in (
channel.total_rate_signal_ref,
channel.status_signal_ref,
channel.percentage_signal_ref,
channel.screw_speed_signal_ref,
)
}
client = Mock()
client.get.return_value.body = {
"data": [
{
"uuid": f"variable-{index}",
"details": {"origin_identifier": {"code": origin}},
}
for index, origin in enumerate(sorted(origins))
]
}
resolved = EnlyzeApiGateway(client).resolve_dosing_channel_signal_refs(
machine_id=config.machine_ref,
channels=config.channels,
)
assert config.machine_ref == "8302e3d1-b1e5-42f1-8540-615eb6c73e08"
assert len(config.channels) == 14
assert len(origins) == 44
assert len(resolved) == 14
assert all(len(refs) == 5 and refs[-1] is None for refs in resolved.values())
client.get.assert_called_once_with("/v2/variables", {"machine": config.machine_ref})
client.post_json.assert_not_called()
def test_active_percentages_sum_to_100_and_deviation_is_reported_not_normalized():
active = (sample(percent=40), sample(percent=60))
assert validate_active_percentage_sum(active, tolerance=0.1)
assert not validate_active_percentage_sum(
(sample(percent=40), sample(percent=50)), tolerance=0.1
)
assert [x.sample.material_rate_kg_per_hour for x in channel_rates(active)] == [48, 72]
def test_null_and_duplicate_material_assignments_are_not_inferred():
assert (
channel_rates((sample(material=None),))[0].material_mapping_status == MATERIAL_UNAVAILABLE
)
rates = channel_rates((sample(material="123"), sample(material="123")))
assert all(rate.material_mapping_status == MATERIAL_AMBIGUOUS for rate in rates)
assert all(rate.material_number is None and rate.material_name is None for rate in rates)
def test_run_change_and_json_restart_preserve_channel_total(tmp_path):
channels = (ChannelDefinition("Ex1", "C1", "rate", "on", "pct", "rpm"),)
store, gateway = JsonChannelMaterialStateStore(tmp_path), Mock()
gateway.get_production_runs.return_value = []
gateway.get_open_production_run.return_value = EnlyzeProductionRun(
"r1", "m", None, "order", NOW, None
)
key = "C1\0Ex1"
gateway.get_dosing_channel_samples.return_value = {
key: [sample(timestamp=NOW), sample(timestamp=NOW + timedelta(seconds=10))]
}
first = ChannelMaterialPollingService(
gateway=gateway,
state_store=store,
machine_id="m",
channels=channels,
max_sample_gap_seconds=20,
)
assert first.poll_once(now=NOW + timedelta(seconds=10))[
0
].state.cumulative_consumption_kg == pytest.approx(10 / 30)
gateway.get_open_production_run.return_value = EnlyzeProductionRun(
"r2", "m", None, "order", NOW + timedelta(seconds=20), None
)
gateway.get_dosing_channel_samples.return_value = {
key: [
sample(timestamp=NOW + timedelta(seconds=20)),
sample(timestamp=NOW + timedelta(seconds=30)),
]
}
restarted = ChannelMaterialPollingService(
gateway=gateway,
state_store=store,
machine_id="m",
channels=channels,
max_sample_gap_seconds=20,
)
assert restarted.poll_once(now=NOW + timedelta(seconds=30))[
0
].state.cumulative_consumption_kg == pytest.approx(20 / 30)
def test_bootstrap_replays_closed_runs_without_integrating_the_inter_run_gap(tmp_path):
channels = (ChannelDefinition("Ex1", "C1", "rate", "on", "pct", "rpm"),)
closed = EnlyzeProductionRun("r1", "m", None, "order", NOW, NOW + timedelta(seconds=10))
current = EnlyzeProductionRun("r2", "m", None, "order", NOW + timedelta(seconds=20), None)
gateway = Mock()
gateway.get_open_production_run.return_value = current
gateway.get_production_runs.return_value = [closed, current]
key = "C1\0Ex1"
gateway.get_dosing_channel_samples.side_effect = [
{key: [sample(timestamp=NOW), sample(timestamp=NOW + timedelta(seconds=10))]},
{
key: [
sample(timestamp=NOW + timedelta(seconds=20)),
sample(timestamp=NOW + timedelta(seconds=30)),
]
},
]
result = ChannelMaterialPollingService(
gateway=gateway,
state_store=JsonChannelMaterialStateStore(tmp_path),
machine_id="m",
channels=channels,
max_sample_gap_seconds=20,
).poll_once(now=NOW + timedelta(seconds=30))
assert result is not None
assert result[0].state.cumulative_consumption_kg == pytest.approx(20 / 30)
+141
View File
@@ -0,0 +1,141 @@
import io
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import Mock, patch
from production_analytics.calculations.channel_config import load_channel_material_calculation
from production_analytics.calculations.material_consumption import MaterialIntegrationState
from production_analytics.cli.__main__ import _parser, main
from production_analytics.enlyze.gateway import EnlyzeProductionRun
from production_analytics.service.channel_material import ChannelPollResult
from production_analytics.service.channel_material_runner import ChannelMaterialPollingRunner
from production_analytics.service.channel_material_runtime import build_channel_material_runner
EXAMPLE = (
Path(__file__).resolve().parents[1] / "config/e1-channel-material-consumption.example.yaml"
)
NOW = datetime(2026, 9, 1, tzinfo=UTC)
def test_cli_channel_material_arguments():
args = _parser().parse_args(
[
"run",
"channel-material-poll",
"--config",
str(EXAMPLE),
"--poll-interval-seconds",
"15",
"--state-directory",
"state",
"--secrets-file",
"secrets.env",
"--once",
]
)
assert args.command == "channel-material-poll"
assert args.config == EXAMPLE
assert args.poll_interval_seconds == 15
assert args.state_directory == Path("state")
assert args.secrets_file == Path("secrets.env")
assert args.once
def test_runtime_constructs_channel_service(tmp_path, monkeypatch):
monkeypatch.setenv("ENLYZE_BASE_URL", "https://example.invalid/api/")
for key, value in {
"HOST": "localhost",
"PORT": "5432",
"DB": "analytics",
"USER": "user",
"PASSWORD": "environment-password",
}.items():
monkeypatch.setenv(f"POSTGRES_{key}", value)
secrets = tmp_path / "secrets.env"
secrets.write_text("ENLYZE_API_KEY=secret-from-file\nPOSTGRES_PASSWORD=file-password\n")
calculation = load_channel_material_calculation(EXAMPLE)
with patch(
"production_analytics.service.channel_material_runtime.ChannelMaterialPollingService"
) as service:
runner = build_channel_material_runner(
calculation,
poll_interval_seconds=7,
state_directory=tmp_path / "state",
secrets_file=secrets,
)
kwargs = service.call_args.kwargs
assert kwargs["machine_id"] == calculation.machine_ref
assert kwargs["channels"] == calculation.channels
assert kwargs["max_sample_gap_seconds"] == calculation.max_sample_gap_seconds
assert kwargs["percentage_tolerance"] == calculation.percentage_tolerance
assert kwargs["material_names"] == calculation.material_names
assert kwargs["gateway"]._client._settings.api_key == "secret-from-file"
assert kwargs["state_store"].directory == tmp_path / "state"
assert runner.service is service.return_value
assert runner.interval == 7
assert runner.snapshot_writer.settings.password == "file-password"
def test_once_executes_one_cycle_and_exits(capsys):
with patch(
"production_analytics.service.channel_material_runtime.build_channel_material_runner"
) as build:
assert main(["run", "channel-material-poll", "--config", str(EXAMPLE), "--once"]) == 0
build.return_value.run_once.assert_called_once_with()
build.return_value.run.assert_not_called()
assert capsys.readouterr().err == ""
def test_no_snapshots_are_written_without_eligible_production_run():
writer = Mock()
output = io.StringIO()
runner = ChannelMaterialPollingRunner(
Mock(poll_once=Mock(return_value=None)),
machine_id="machine",
calculation_id="calculation",
snapshot_writer=writer,
poll_interval_seconds=10,
clock=lambda: NOW,
stdout=output,
)
assert not runner.run_once()
writer.write.assert_not_called()
assert "snapshots=0" in output.getvalue()
def test_one_cycle_writes_each_channel_snapshot():
run = EnlyzeProductionRun("run", "machine", None, "order", NOW, None)
first = ChannelPollResult(
run, "Ex1", "C1", MaterialIntegrationState(1.5, 10), "42", "Material", "MAPPED", True
)
second = ChannelPollResult(
run, "Ex1", "C2", MaterialIntegrationState(2.5, 10), None, None, "AMBIGUOUS", False
)
writer = Mock()
output = io.StringIO()
runner = ChannelMaterialPollingRunner(
Mock(poll_once=Mock(return_value=(first, second))),
machine_id="machine",
calculation_id="calculation",
snapshot_writer=writer,
poll_interval_seconds=10,
clock=lambda: NOW,
stdout=output,
)
assert runner.run_once()
assert writer.write.call_count == 2
assert writer.write.call_args_list[0].kwargs == {
"timestamp": NOW,
"calculation_id": "calculation",
"machine_id": "machine",
"extruder": "Ex1",
"channel": "C1",
"production_order": "order",
"run_id": "run",
"cumulative_consumption_kg": 1.5,
"material_number": "42",
"material_name": "Material",
"material_mapping_status": "MAPPED",
"percentage_sum_valid": True,
}
assert "channel snapshots=2 written" in output.getvalue()
+140
View File
@@ -0,0 +1,140 @@
from datetime import UTC, datetime
from production_analytics.enlyze.gateway import EnlyzeDowntime
from production_analytics.erp import CurrentWorkplaceStatus
from production_analytics.service.downtime import (
DowntimeCategory,
DowntimeReconciliationService,
ProductionOrderBoundary,
)
NOW = datetime(2026, 9, 16, 12, tzinfo=UTC)
def source(*, identifier="d", end=NOW, category=None):
return EnlyzeDowntime(
identifier,
"machine",
"THRESHOLD",
datetime(2026, 9, 16, 10, tzinfo=UTC),
end,
None,
"reason" if category else None,
"reason" if category else None,
None,
None,
category,
None,
)
def status(order="1", *, remaining=3, good=7, target=10, feedback=NOW):
return CurrentWorkplaceStatus(
"WP", order, None, None, feedback, target, good, remaining, None, None
)
class Gateway:
def __init__(self, events):
self.events = events
def get_downtimes(self, machine_id, *, start=None):
return self.events
class Erp:
def __init__(self, current):
self.current = current
def get_current_workplace_status(self, workplace):
return self.current
class Repo:
def __init__(self):
self.boundary = None
self.events = {}
self.closures = []
def current_boundary(self, machine_id):
return self.boundary
def save_boundary(self, boundary):
self.boundary = boundary
def close_order_attribution(self, machine_id, production_order, ended_at):
self.closures.append((production_order, ended_at))
for event in self.events.values():
if event.production_order == production_order and event.attributed_end is None:
event # database-specific clipping is covered by SQL contract
def upsert(self, event):
self.events[event.external_id] = event
def service(repo, events, current):
return DowntimeReconciliationService(
Gateway(events),
Erp(current),
repo,
machine_id="machine",
workplace="WP",
production_order_format="FA-{production_order}",
)
def test_categories_open_and_reconciliation_upsert() -> None:
repo = Repo()
runner = service(
repo,
[
source(identifier="p", category="PLANNED"),
source(identifier="u", category="UNPLANNED"),
source(identifier="x", end=None),
],
status(),
)
assert runner.reconcile_once(NOW) == 3
assert {key: event.category for key, event in repo.events.items()} == {
"p": DowntimeCategory.PLANNED,
"u": DowntimeCategory.UNPLANNED,
"x": DowntimeCategory.UNKNOWN,
}
assert repo.events["x"].source_end is None
# Same UUID is overwritten, never duplicated; delayed classification is accepted.
runner.gateway.events = [source(identifier="x", end=NOW, category="PLANNED")]
runner.reconcile_once(NOW)
assert len(repo.events) == 3
assert repo.events["x"].category is DowntimeCategory.PLANNED
assert repo.events["x"].source_end == NOW
def test_completion_clips_attribution_and_new_order_closes_previous() -> None:
repo = Repo()
repo.boundary = ProductionOrderBoundary(
"machine",
"FA-1",
datetime(2026, 9, 16, 9, tzinfo=UTC),
None,
)
event = source(identifier="open", end=None)
first = service(repo, [event], status(remaining=0, feedback=NOW))
first.reconcile_once(NOW)
assert repo.boundary is not None and repo.boundary.ended_at == NOW
assert repo.events["open"].production_order == "FA-1"
assert repo.events["open"].attributed_end == NOW
repo.boundary = ProductionOrderBoundary(
"machine",
"FA-1",
datetime(2026, 9, 16, 8, tzinfo=UTC),
None,
)
service(repo, [], status(order="2", feedback=NOW)).reconcile_once(NOW)
assert repo.closures == [("FA-1", NOW), ("FA-1", NOW)]
assert repo.boundary.production_order == "FA-2"
def test_unknown_is_not_unplanned_and_no_schedule_is_used() -> None:
repo = Repo()
service(repo, [source(end=datetime(2026, 9, 20, tzinfo=UTC))], status()).reconcile_once(NOW)
assert repo.events["d"].category is DowntimeCategory.UNKNOWN
+206 -59
View File
@@ -6,6 +6,52 @@ import pytest
from production_analytics.enlyze.gateway import EnlyzeApiGateway
def test_downtime_pagination_and_nullable_reason() -> None:
client = Mock()
base = {
"machine": "machine-1",
"type": "THRESHOLD",
"comment": None,
"start": "2026-09-03T04:00:00Z",
"end": None,
"reason": None,
"updated": None,
}
client.get.side_effect = [
Mock(body={"data": [{**base, "uuid": "d1"}], "metadata": {"next_cursor": "next"}}),
Mock(
body={
"data": [
{
**base,
"uuid": "d2",
"end": "2026-09-03T05:00:00Z",
"reason": {
"uuid": "r",
"name": "Break",
"description": None,
"group": "General",
"category": "PLANNED",
},
}
],
"metadata": {"next_cursor": None},
}
),
]
result = EnlyzeApiGateway(client).get_downtimes(
"machine-1",
start=datetime(2026, 9, 3, tzinfo=UTC),
)
assert [item.uuid for item in result] == ["d1", "d2"]
assert result[0].end is None and result[0].reason_category is None
assert result[1].reason_category == "PLANNED"
assert client.get.call_args_list[1].args == (
"/v2/downtimes",
{"machine": "machine-1", "start": "2026-09-03T00:00:00+00:00", "cursor": "next"},
)
def test_get_open_production_run_returns_current_run() -> None:
client = Mock()
client.get.return_value.body = {
@@ -138,11 +184,19 @@ def test_get_material_samples_uses_column_names_not_fixed_positions() -> None:
@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))
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
@@ -176,9 +230,19 @@ def test_missing_required_column(timeseries, column) -> None:
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]])
@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]
@@ -188,15 +252,21 @@ def test_malformed_records_fail_clearly(timeseries, record) -> None:
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)
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)])
@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]}
@@ -219,16 +289,24 @@ def test_timeseries_with_null_cursor(timeseries) -> None:
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},
}),
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] == [
@@ -237,16 +315,32 @@ def test_timeseries_pagination_resolves_columns_per_page(timeseries) -> None:
]
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 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": {}}])
@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
@@ -267,25 +361,38 @@ def test_repeated_pagination_cursor(timeseries, cursors) -> None:
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"),
])
@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}}),
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)
@@ -294,8 +401,13 @@ def test_malformed_later_page(timeseries, data, error) -> None:
def test_production_runs_follow_pages_and_find_open_run() -> None:
client = Mock()
closed = dict(uuid="closed", machine="m", production_order="opaque-order",
start="2026-09-01T00:00:00Z", end="2026-09-01T01:00:00Z")
closed = dict(
uuid="closed",
machine="m",
production_order="opaque-order",
start="2026-09-01T00:00:00Z",
end="2026-09-01T01:00:00Z",
)
opened = {**closed, "uuid": "open", "end": None}
pages = [
Mock(body={"data": [closed], "metadata": {"next_cursor": "a"}}),
@@ -314,10 +426,20 @@ def test_production_runs_follow_pages_and_find_open_run() -> None:
assert gateway.get_open_production_run("m") == runs[1]
@pytest.mark.parametrize("metadata", [
None, [], "bad", {}, {"next_cursor": ""}, {"next_cursor": 1},
{"next_cursor": False}, {"next_cursor": []}, {"next_cursor": {}},
])
@pytest.mark.parametrize(
"metadata",
[
None,
[],
"bad",
{},
{"next_cursor": ""},
{"next_cursor": 1},
{"next_cursor": False},
{"next_cursor": []},
{"next_cursor": {}},
],
)
def test_production_run_invalid_pagination(metadata) -> None:
client = Mock()
client.get.return_value.body = {"data": [], "metadata": metadata}
@@ -329,25 +451,50 @@ def test_production_run_invalid_pagination(metadata) -> None:
def test_production_run_cyclic_pagination(cursors) -> None:
client = Mock()
client.get.side_effect = [
Mock(body={"data": [], "metadata": {"next_cursor": cursor}})
for cursor in cursors
Mock(body={"data": [], "metadata": {"next_cursor": cursor}}) for cursor in cursors
]
with pytest.raises(ValueError, match="next_cursor has already been followed"):
EnlyzeApiGateway(client).get_production_runs("m")
assert client.get.call_count == len(cursors)
@pytest.mark.parametrize("body", [
None, [], {}, {"data": {}}, {"data": [None]},
{"data": [dict(uuid="r", machine="wrong", production_order="o",
start="2026-09-03T00:00:00Z", end=None)]},
{"data": [dict(uuid="r", machine="m", production_order="o",
start="2026-09-03T00:00:00Z", end="2026-09-02T00:00:00Z")]},
])
@pytest.mark.parametrize(
"body",
[
None,
[],
{},
{"data": {}},
{"data": [None]},
{
"data": [
dict(
uuid="r",
machine="wrong",
production_order="o",
start="2026-09-03T00:00:00Z",
end=None,
)
]
},
{
"data": [
dict(
uuid="r",
machine="m",
production_order="o",
start="2026-09-03T00:00:00Z",
end="2026-09-02T00:00:00Z",
)
]
},
],
)
def test_production_run_malformed_later_page(body) -> None:
client = Mock()
client.get.side_effect = [
Mock(body={"data": [], "metadata": {"next_cursor": "a"}}), Mock(body=body),
Mock(body={"data": [], "metadata": {"next_cursor": "a"}}),
Mock(body=body),
]
with pytest.raises(ValueError, match="Invalid production-run response on page 2"):
EnlyzeApiGateway(client).get_production_runs("m")
+2 -2
View File
@@ -117,11 +117,11 @@ def test_persistence_before_checkpoint_retries_and_disjoint_runs():
store.save.assert_called_once()
def test_sql_persistence_idempotent_and_inactive_omitted(monkeypatch):
def test_sql_persistence_idempotent_and_inactive_omitted(monkeypatch, sqlite_schema):
import sqlite3
database = sqlite3.connect(':memory:')
database.executescript(Path('db/schema.sql').read_text())
database.executescript(sqlite_schema)
connection = MagicMock()
cursor = connection.__enter__.return_value.cursor.return_value.__enter__.return_value
cursor.executemany.side_effect = lambda sql, rows: database.executemany(
@@ -48,10 +48,10 @@ SETTINGS = PostgresSettings("localhost", 5432, "analytics", "writer", "secret")
@pytest.fixture
def storage(monkeypatch):
# Real schema and INSERT semantics, with only driver transport adapted for SQLite.
def storage(monkeypatch, sqlite_schema):
# Production schema and INSERT semantics, adapted for the SQLite dialect/transport.
database = sqlite3.connect(":memory:")
database.executescript(Path("db/schema.sql").read_text())
database.executescript(sqlite_schema)
connection = MagicMock()
def execute(sql, params):
+61
View File
@@ -0,0 +1,61 @@
"""Regression coverage for SQLite initialization from production DDL."""
import json
import sqlite3
import pytest
def test_schema_initialization_and_power_meter_json(sqlite_schema):
with sqlite3.connect(":memory:") as database:
database.executescript(sqlite_schema)
database.executescript(sqlite_schema)
tables = {
row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
assert {
"material_consumption_snapshots",
"material_efficiency_snapshots",
"material_application_snapshots",
"channel_material_consumption_snapshots",
"production_downtime_events",
"production_order_attribution_state",
"power_meter_readings",
"power_meter_monthly_reports",
} <= tables
insert = """INSERT INTO power_meter_readings
(meter, timestamp, energy_total_kwh, energy_delta_kwh,
active_power_total_kw, grid_frequency_hz)
VALUES (?, ?, 100, 1, 4, 50)"""
database.execute(insert, ("B2", "2026-09-01T00:00:00Z"))
phases = {"L1": 1.25, "L2": 2.75}
database.execute(insert, ("B2", "2026-09-01T00:01:00Z"))
database.execute(
"UPDATE power_meter_readings SET phase_active_power_kw=? WHERE timestamp=?",
(json.dumps(phases), "2026-09-01T00:01:00Z"),
)
rows = database.execute(
"SELECT phase_active_power_kw FROM power_meter_readings ORDER BY timestamp"
).fetchall()
assert [json.loads(row[0]) for row in rows] == [{}, phases]
with pytest.raises(sqlite3.IntegrityError):
database.execute(insert, ("B2", "2026-09-01T00:00:00Z"))
summary = {"consumption_kwh": 42.5, "first_timestamp": None, "outages": 0}
database.execute(
"""INSERT INTO power_meter_monthly_reports (meter, month, report_path, summary)
VALUES (?, ?, ?, ?)""",
("B2", "2026-09", "/reports/B2.pdf", json.dumps(summary)),
)
path, stored_summary, generated_at = database.execute(
"SELECT report_path, summary, generated_at FROM power_meter_monthly_reports"
).fetchone()
assert path == "/reports/B2.pdf"
assert json.loads(stored_summary) == summary
assert generated_at is not None
with pytest.raises(sqlite3.IntegrityError):
database.execute(
"""INSERT INTO power_meter_monthly_reports (meter, month, report_path, summary)
VALUES ('B2', '2026-10', '/reports/B2.pdf', NULL)"""
)