feat(production): dai una vita propria alla produzione, lato server
Punto 1 del documento modifiche del 28/07, il prerequisito su cui poggiano i punti 3, 4 e 6. Lo stato di una produzione viveva dentro una pagina del browser: timer, conteggio cicli e flag "produzione avviata" erano variabili Alpine di task_execute.html, e la navigazione fra task e' un ricaricamento completo, quindi cambiando task si perdeva tutto. Da qui il loop di misura che non reggeva, il fermo linea che non aveva nulla da fermare e l'assenza di storico. Nuove tabelle production_runs e production_events (migrazione 005), endpoint REST senza stato in memoria di processo - con un'app di stazione installata su ogni PC il database e' l'unico posto condiviso - e il frontend che legge lo stato all'apertura invece di tenerlo in memoria. Tre scelte di modello: - la scadenza e' un timestamp assoluto (next_measurement_at), non un contatore: il countdown si ricalcola da li' a ogni caricamento, e lasciarne andare la differenza sotto zero dara' gratis il contatore del ritardo del punto 3. Al client vanno i secondi gia' calcolati, non il timestamp: un datetime naive verrebbe letto nel fuso del browser e il conto sarebbe sfasato dell'offset UTC; - l'intervallo di misura e' copiato sulla produzione, non referenziato: modificare la ricetta a produzione avviata non deve spostare una scadenza in corso; - active_station_id rispecchia la stazione finche' la produzione e' aperta e va a NULL alla chiusura. Con un vincolo unico sopra, "una stazione = una produzione aperta" e' una garanzia del database e non un controllo soggetto a race; i NULL non collidono, quindi le produzioni chiuse si accumulano senza disturbo. Il fermo linea congela il conto alla rovescia e alla ripresa la scadenza viene traslata della durata del fermo, non ricalcolata: un fermo non regala ne' toglie tempo all'operatore. L'autorizzazione del capoturno passa da authenticate_user e non da un login, che rigenererebbe la sua API key buttando giu' la sessione che ha aperta altrove. La migrazione e' stata eseguita davvero, non solo scritta, su uno SQLite usa e getta: upgrade e downgrade girano e le colonne coincidono con i modelli. La prova ha trovato un difetto - create_unique_constraint dopo create_table e' un ALTER, che SQLite rifiuta - ora il vincolo e' dichiarato dentro create_table. Fuori da questo commit, per stare nei confini del punto 1: l'API espone gia' pause, resume e close, ma i pulsanti fermo linea e fine produzione restano da collegare (punto 6), e il rientro forzato sulla misura allo scadere e' il punto 3. Corretti due difetti trovati strada facendo: env.py non importava ne' Station ne' ProductionRun, quindi l'autogenerate di Alembic era gia' cieco sulle stazioni; e task_execute.html, lo schermo con piu' JavaScript dell'applicazione, non era coperto dal test di sintassi. Aggiungerlo ha richiesto di correggere l'helper, che validava le espressioni Alpine solo come espressione singola e bocciava @click="a = false; b = true", forma che Alpine accetta: ora prova entrambe le letture e fallisce solo se cadono tutte e due. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
"""Tests for the production-run proxy routes.
|
||||
|
||||
These are what let task_execute.html read its state back from the server instead of
|
||||
keeping the timer in an Alpine variable that a change of task destroys.
|
||||
"""
|
||||
import importlib
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_measure_module():
|
||||
"""Reload config/measure after each test so env tweaks don't leak."""
|
||||
yield
|
||||
import config
|
||||
importlib.reload(config)
|
||||
import blueprints.measure
|
||||
importlib.reload(blueprints.measure)
|
||||
|
||||
|
||||
def _with_station(monkeypatch, station_code="ST-PROD"):
|
||||
if station_code is None:
|
||||
monkeypatch.delenv("STATION_CODE", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("STATION_CODE", station_code)
|
||||
monkeypatch.setenv("STATION_SWITCH_ENABLED", "0")
|
||||
import config
|
||||
importlib.reload(config)
|
||||
import blueprints.measure
|
||||
importlib.reload(blueprints.measure)
|
||||
from blueprints import measure as measure_mod
|
||||
return measure_mod
|
||||
|
||||
|
||||
RUN = {
|
||||
"id": 7,
|
||||
"station_id": 1,
|
||||
"recipe_id": 3,
|
||||
"version_id": 5,
|
||||
"operator_id": 2,
|
||||
"lot_number": "LOT-1",
|
||||
"serial_number": None,
|
||||
"status": "running",
|
||||
"measurement_interval_minutes": 30,
|
||||
"next_measurement_at": "2026-07-28T17:00:00",
|
||||
"cycle_count": 2,
|
||||
"started_at": "2026-07-28T16:00:00",
|
||||
"paused_at": None,
|
||||
"closed_at": None,
|
||||
"closed_by": None,
|
||||
"seconds_to_next_measurement": 1500,
|
||||
"overdue": False,
|
||||
"server_time": "2026-07-28T16:35:00",
|
||||
}
|
||||
|
||||
|
||||
def test_current_production_passes_the_station(logged_in_client, monkeypatch):
|
||||
measure_mod = _with_station(monkeypatch)
|
||||
with patch.object(measure_mod, "api_client") as mock_api:
|
||||
mock_api.get.return_value = RUN
|
||||
resp = logged_in_client.get("/measure/api/production/current")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["id"] == 7
|
||||
mock_api.get.assert_called_once_with(
|
||||
"/api/production-runs/current", params={"station_code": "ST-PROD"},
|
||||
)
|
||||
|
||||
|
||||
def test_current_production_returns_null_when_idle(logged_in_client, monkeypatch):
|
||||
"""No production open is a normal answer, not an error."""
|
||||
measure_mod = _with_station(monkeypatch)
|
||||
with patch.object(measure_mod, "api_client") as mock_api:
|
||||
mock_api.get.return_value = None
|
||||
resp = logged_in_client.get("/measure/api/production/current")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() is None
|
||||
|
||||
|
||||
def test_current_production_without_station_is_503(logged_in_client, monkeypatch):
|
||||
_with_station(monkeypatch, station_code=None)
|
||||
resp = logged_in_client.get("/measure/api/production/current")
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
def test_start_production_sends_station_and_recipe(logged_in_client, monkeypatch):
|
||||
measure_mod = _with_station(monkeypatch)
|
||||
with patch.object(measure_mod, "api_client") as mock_api:
|
||||
mock_api.post.return_value = RUN
|
||||
resp = logged_in_client.post(
|
||||
"/measure/api/production/start",
|
||||
json={"recipe_id": 3, "version_id": 5, "lot_number": "LOT-1"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
endpoint, kwargs = mock_api.post.call_args
|
||||
assert endpoint[0] == "/api/production-runs"
|
||||
sent = kwargs["data"]
|
||||
assert sent["station_code"] == "ST-PROD"
|
||||
assert sent["recipe_id"] == 3
|
||||
assert sent["version_id"] == 5
|
||||
assert sent["lot_number"] == "LOT-1"
|
||||
|
||||
|
||||
def test_start_production_falls_back_to_session_traceability(logged_in_client, monkeypatch):
|
||||
"""Lot and serial already captured in the session must reach the run."""
|
||||
measure_mod = _with_station(monkeypatch)
|
||||
with logged_in_client.session_transaction() as sess:
|
||||
sess["lot_number"] = "LOT-SESSION"
|
||||
sess["serial_number"] = "SN-SESSION"
|
||||
with patch.object(measure_mod, "api_client") as mock_api:
|
||||
mock_api.post.return_value = RUN
|
||||
logged_in_client.post("/measure/api/production/start", json={"recipe_id": 3})
|
||||
sent = mock_api.post.call_args[1]["data"]
|
||||
assert sent["lot_number"] == "LOT-SESSION"
|
||||
assert sent["serial_number"] == "SN-SESSION"
|
||||
|
||||
|
||||
def test_start_production_propagates_conflict(logged_in_client, monkeypatch):
|
||||
"""A station already running a production must not silently open a second."""
|
||||
measure_mod = _with_station(monkeypatch)
|
||||
with patch.object(measure_mod, "api_client") as mock_api:
|
||||
mock_api.post.return_value = {
|
||||
"error": True, "status_code": 409,
|
||||
"detail": "Station 'ST-PROD' already has an open production run",
|
||||
}
|
||||
resp = logged_in_client.post(
|
||||
"/measure/api/production/start", json={"recipe_id": 3},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_complete_cycle_hits_the_run(logged_in_client, monkeypatch):
|
||||
measure_mod = _with_station(monkeypatch)
|
||||
with patch.object(measure_mod, "api_client") as mock_api:
|
||||
mock_api.post.return_value = {**RUN, "cycle_count": 3}
|
||||
resp = logged_in_client.post("/measure/api/production/7/cycle", json={})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["cycle_count"] == 3
|
||||
endpoint, kwargs = mock_api.post.call_args
|
||||
assert endpoint[0] == "/api/production-runs/7/cycle"
|
||||
|
||||
|
||||
def test_complete_cycle_propagates_paused_conflict(logged_in_client, monkeypatch):
|
||||
measure_mod = _with_station(monkeypatch)
|
||||
with patch.object(measure_mod, "api_client") as mock_api:
|
||||
mock_api.post.return_value = {
|
||||
"error": True, "status_code": 409,
|
||||
"detail": "Production run is paused: resume it before measuring",
|
||||
}
|
||||
resp = logged_in_client.post("/measure/api/production/7/cycle", json={})
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_production_routes_require_login(client, monkeypatch):
|
||||
_with_station(monkeypatch)
|
||||
for method, url in (
|
||||
("get", "/measure/api/production/current"),
|
||||
("post", "/measure/api/production/start"),
|
||||
("post", "/measure/api/production/7/cycle"),
|
||||
):
|
||||
resp = getattr(client, method)(url)
|
||||
assert resp.status_code in (302, 401), f"{method} {url} -> {resp.status_code}"
|
||||
@@ -79,10 +79,8 @@ def _alpine_attribute_expressions(html: str):
|
||||
yield name, value
|
||||
|
||||
|
||||
def _node_check(source: str, label: str) -> None:
|
||||
"""Fail the test if `node --check` rejects `source`."""
|
||||
if not source.strip():
|
||||
return
|
||||
def _node_syntax_error(source: str) -> str | None:
|
||||
"""Return node's complaint about `source`, or None if it parses."""
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
pytest.skip("node binary not found on PATH; cannot validate JS syntax")
|
||||
@@ -100,31 +98,56 @@ def _node_check(source: str, label: str) -> None:
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
if result.returncode != 0:
|
||||
snippet = source.strip()
|
||||
if len(snippet) > 600:
|
||||
snippet = snippet[:600] + "\n…(truncated)…"
|
||||
pytest.fail(
|
||||
f"{label}: node rejected this code as invalid JS.\n"
|
||||
f"--- node stderr ---\n{result.stderr.strip()}\n"
|
||||
f"--- source (first 600 chars) ---\n{snippet}"
|
||||
)
|
||||
return None if result.returncode == 0 else result.stderr.strip()
|
||||
|
||||
|
||||
def _fail(label: str, source: str, stderr: str) -> None:
|
||||
snippet = source.strip()
|
||||
if len(snippet) > 600:
|
||||
snippet = snippet[:600] + "\n…(truncated)…"
|
||||
pytest.fail(
|
||||
f"{label}: node rejected this code as invalid JS.\n"
|
||||
f"--- node stderr ---\n{stderr}\n"
|
||||
f"--- source (first 600 chars) ---\n{snippet}"
|
||||
)
|
||||
|
||||
|
||||
def _node_check(source: str, label: str) -> None:
|
||||
"""Fail the test if `node --check` rejects `source`."""
|
||||
if not source.strip():
|
||||
return
|
||||
stderr = _node_syntax_error(source)
|
||||
if stderr is not None:
|
||||
_fail(label, source, stderr)
|
||||
|
||||
|
||||
def _check_alpine_attributes(html: str, page_label: str) -> None:
|
||||
"""Validate every Alpine expression attribute on the page.
|
||||
|
||||
Wraps each value in `void (…)` so node parses it as an expression rather
|
||||
than a statement. Function-body forms like `async () => { … }` parse fine
|
||||
inside that wrapper too.
|
||||
Alpine accepts an attribute either as a single expression or as a sequence of
|
||||
statements, so the check accepts a value that parses as either and only fails
|
||||
when both readings are rejected. Neither form alone is enough:
|
||||
|
||||
:class="{ 'dark': $store.theme.dark }" only as an expression
|
||||
@click="showOverlay = false; confirmed = true" only as statements
|
||||
|
||||
An unterminated string literal - the bug this file exists for - is a syntax
|
||||
error under both, so the guard is unchanged.
|
||||
"""
|
||||
for name, value in _alpine_attribute_expressions(html):
|
||||
# Some Alpine attrs accept a function call shorthand (e.g.
|
||||
# x-data="myComponent(window.__x)"); those parse fine as expressions.
|
||||
wrapper = f"void ({value});\n"
|
||||
_node_check(
|
||||
wrapper,
|
||||
f"{page_label} attribute {name}=\"…\" did not parse as JS",
|
||||
as_expression = f"void (\n{value}\n);\n"
|
||||
as_statements = f"(() => {{\n{value}\n}});\n"
|
||||
|
||||
expression_error = _node_syntax_error(as_expression)
|
||||
if expression_error is None:
|
||||
continue
|
||||
if _node_syntax_error(as_statements) is None:
|
||||
continue
|
||||
_fail(
|
||||
f"{page_label} attribute {name}=\"…\" parsed neither as an expression "
|
||||
"nor as statements",
|
||||
as_expression,
|
||||
expression_error,
|
||||
)
|
||||
|
||||
|
||||
@@ -192,3 +215,52 @@ def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
|
||||
_node_check(body, f"/admin/users script[{i}]")
|
||||
|
||||
_check_alpine_attributes(html, "/admin/users")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_measure_api():
|
||||
"""Patch api_client used inside the measure blueprint."""
|
||||
mock = MagicMock()
|
||||
with patch("blueprints.measure.api_client", mock):
|
||||
yield mock
|
||||
|
||||
|
||||
def test_task_execute_inline_js_is_valid(logged_in_client, mock_measure_api):
|
||||
"""The measurement screen carries the most inline JS in the app.
|
||||
|
||||
It drives the numpad, the caliper burst detection, the supervisor modal and -
|
||||
since the production run moved server-side - the countdown. A broken literal
|
||||
here kills every binding on the screen the operator actually works in.
|
||||
"""
|
||||
_force_italian(logged_in_client)
|
||||
task = {
|
||||
"id": 11,
|
||||
"recipe_id": 3,
|
||||
"version_id": 5,
|
||||
"title": "Quota d'ingresso",
|
||||
"description": "Misura l'altezza",
|
||||
"file_path": None,
|
||||
"file_type": None,
|
||||
"annotations_json": None,
|
||||
"subtasks": [{
|
||||
"id": 21, "marker_number": 1, "name": "Altezza", "order_index": 0,
|
||||
"nominal": 10.0, "utl": 10.5, "uwl": 10.2, "lwl": 9.8, "ltl": 9.5,
|
||||
"unit": "mm",
|
||||
}],
|
||||
}
|
||||
mock_measure_api.get.side_effect = [
|
||||
task, # /api/tasks/11
|
||||
[{"id": 11, "order_index": 0}], # /api/recipes/3/tasks
|
||||
{"id": 3, "measurement_interval_minutes": 30}, # /api/recipes/3
|
||||
]
|
||||
|
||||
resp = logged_in_client.get("/measure/execute/11")
|
||||
assert resp.status_code == 200
|
||||
html = resp.get_data(as_text=True)
|
||||
|
||||
scripts = _INLINE_SCRIPT_RX.findall(html)
|
||||
assert scripts, "expected at least one inline <script> on /measure/execute"
|
||||
for i, body in enumerate(scripts):
|
||||
_node_check(body, f"/measure/execute script[{i}]")
|
||||
|
||||
_check_alpine_attributes(html, "/measure/execute")
|
||||
|
||||
Reference in New Issue
Block a user