Initial commit: aplicatie de cantarire si etichetare
Aplicatie Flask + SQLite pentru productie alimentara: cantarirea componentelor unui produs finit si tiparirea etichetei prin QZ Tray. - produse finite (retete) formate din componente cantarite - designer vizual de etichete (mm/px, zona sigura, campuri variabile, cod QR cu continut din mai multe bucati) - legare produs finit -> sablon de eticheta - navigare pe doua taburi: Cantarire / Setari - administrare: componente, produse finite, sabloane, campuri, istoric - backup, resetare baza de date, checklist de pregatire, /healthz - CLI de administrare si serviciu systemd pentru productie - vendor/syncpulse-scale-bridge (GPL-3.0-or-later) pentru citirea cantarului
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from cantarire import create_app # noqa: E402
|
||||
|
||||
STABLE_READING = {
|
||||
"ok": True,
|
||||
"error": None,
|
||||
"profile": "test-scale",
|
||||
"port": "COM1",
|
||||
"protocol": "mt_sics",
|
||||
"open": True,
|
||||
"value": 250.0,
|
||||
"unit": "g",
|
||||
"stable": True,
|
||||
"stale": False,
|
||||
"overload": False,
|
||||
"age_ms": 120,
|
||||
"measured_at": "2026-10-18T10:00:00.000+03:00",
|
||||
"raw_hex": "53 20 53 20 20 20 20 32 35 30 2E 30",
|
||||
"fields": {},
|
||||
}
|
||||
|
||||
|
||||
class FakeScale:
|
||||
available = True
|
||||
error = None
|
||||
|
||||
def __init__(self):
|
||||
self.reading = dict(STABLE_READING)
|
||||
self.calls = 0
|
||||
self.probed = None
|
||||
|
||||
def profiles(self):
|
||||
return [{"name": "test-scale", "label": "Cântar test", "port": "COM1", "protocol": "mt_sics"}]
|
||||
|
||||
def read(self, profile_name="", **_kwargs):
|
||||
self.calls += 1
|
||||
return dict(self.reading)
|
||||
|
||||
def probe(self, port, baud=9600, framing="8N1", seconds=5.0):
|
||||
self.probed = {"port": port, "baud": baud, "framing": framing, "seconds": seconds}
|
||||
return {
|
||||
"ok": True,
|
||||
"port": port,
|
||||
"settings": f"{port} {baud} {framing}, listen only",
|
||||
"baud": baud,
|
||||
"framing": framing,
|
||||
"seconds": seconds,
|
||||
"error": None,
|
||||
"bytes": 13,
|
||||
"frames": [{"age_ms": 10, "hex": "02 2D 30 20 30 30 30 30 30 30 30 30 30 30 0D",
|
||||
"text": "<STX>-0 000000000000<CR>", "size": 13}],
|
||||
"guesses": [{"protocol": "toledo_cont", "name": "Toledo Continuous",
|
||||
"kind": "continuous", "count": 2, "value": 12.345,
|
||||
"unit": "kg", "stable": True, "specificity": 3}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app(tmp_path):
|
||||
application = create_app({
|
||||
"TESTING": True,
|
||||
"INSTANCE_DIR": tmp_path,
|
||||
"DATABASE": tmp_path / "test.db",
|
||||
"BRIDGE_CONFIG": tmp_path / "bridge.json",
|
||||
"SIMULATE": "",
|
||||
"AUTO_PRINT": False,
|
||||
"SECRET_KEY": "test",
|
||||
})
|
||||
application.extensions["scale"] = FakeScale()
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scale(app):
|
||||
return app.extensions["scale"]
|
||||
|
||||
|
||||
def _create_component(client, code: str, name: str, unit: str = "g") -> int:
|
||||
response = client.post("/componente/nou", data={
|
||||
"code": code, "name": name, "unit": unit, "active": "on",
|
||||
})
|
||||
assert response.status_code == 302, response.get_data(as_text=True)
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_component_by_code
|
||||
|
||||
return get_component_by_code(code)["id"]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fasole(client):
|
||||
return _create_component(client, "FAS", "Fasole")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def carne(client):
|
||||
return _create_component(client, "CAR", "Carne")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def recipe(client, fasole, carne):
|
||||
response = client.post("/retete/nou", data={
|
||||
"code": "PF-001",
|
||||
"name": "Fasole cu carne",
|
||||
"unit": "g",
|
||||
"target_weight": "450",
|
||||
"component_1_id": str(fasole),
|
||||
"component_1_target": "250",
|
||||
"component_2_id": str(carne),
|
||||
"component_2_target": "200",
|
||||
"active": "on",
|
||||
})
|
||||
assert response.status_code == 302, response.get_data(as_text=True)
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import list_recipes
|
||||
|
||||
return list_recipes()[0]
|
||||
@@ -0,0 +1,304 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cantarire.repositories import list_packages
|
||||
|
||||
|
||||
def test_pages_load(client):
|
||||
for path in ("/", "/componente", "/componente/nou", "/retete", "/retete/nou",
|
||||
"/istoric", "/setari", "/sabloane", "/sabloane/1/designer", "/monitor"):
|
||||
response = client.get(path)
|
||||
assert response.status_code == 200, path
|
||||
|
||||
|
||||
def test_html_css_pages_are_gone(client):
|
||||
"""Modul HTML/CSS a fost scos; rutele vechi nu mai exista."""
|
||||
for path in ("/sabloane/nou", "/sabloane/1/editare"):
|
||||
assert client.get(path).status_code == 404, path
|
||||
html = client.get("/sabloane").get_data(as_text=True)
|
||||
assert "HTML/CSS" not in html
|
||||
|
||||
|
||||
def test_dashboard_lists_recipes(client, recipe):
|
||||
html = client.get("/").get_data(as_text=True)
|
||||
assert "PF-001" in html
|
||||
assert "Fasole cu carne" in html
|
||||
assert "Începe pachetul" in html or "Pachet nou" in html
|
||||
|
||||
|
||||
def test_component_crud(client):
|
||||
created = client.post("/componente/nou", data={
|
||||
"code": "GAR", "name": "Garnitura", "unit": "g", "active": "on",
|
||||
})
|
||||
assert created.status_code == 302
|
||||
listing = client.get("/componente").get_data(as_text=True)
|
||||
assert "GAR" in listing and "Garnitura" in listing
|
||||
|
||||
duplicate = client.post("/componente/nou", data={"code": "gar", "name": "Alt", "unit": "g"})
|
||||
assert duplicate.status_code == 200
|
||||
assert "Exista deja un component" in duplicate.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_recipe_has_ordered_components(client, recipe):
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_recipe
|
||||
|
||||
stored = get_recipe(recipe["id"])
|
||||
assert [c["name"] for c in stored["components"]] == ["Fasole", "Carne"]
|
||||
assert [c["target_weight"] for c in stored["components"]] == [250, 200]
|
||||
|
||||
|
||||
def test_recipe_requires_a_component(client):
|
||||
response = client.post("/retete/nou", data={
|
||||
"code": "PF-EMPTY", "name": "Fara componente", "unit": "g",
|
||||
})
|
||||
assert response.status_code == 200
|
||||
assert "cel putin un component" in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_two_step_weighing_completes_and_builds_label(client, recipe):
|
||||
started = client.post("/api/packages", json={"recipe_id": recipe["id"], "lot": "LOT-1"})
|
||||
assert started.status_code == 201, started.get_json()
|
||||
data = started.get_json()
|
||||
package_id = data["package"]["id"]
|
||||
assert data["step"]["component_name"] == "Fasole"
|
||||
assert data["step"]["position"] == 1
|
||||
assert data["planned_total"] == 2
|
||||
|
||||
first = client.post(f"/api/packages/{package_id}/items", json={"weight": "250"})
|
||||
assert first.status_code == 201, first.get_json()
|
||||
body = first.get_json()
|
||||
assert body["step"]["component_name"] == "Carne"
|
||||
assert body["step"]["position"] == 2
|
||||
assert body["total_weight"] == 250
|
||||
|
||||
second = client.post(f"/api/packages/{package_id}/items", json={"weight": "200"})
|
||||
assert second.status_code == 201, second.get_json()
|
||||
done = second.get_json()
|
||||
assert done["complete"] is True
|
||||
assert done["step"] is None
|
||||
assert done["package"]["total_weight"] == 450
|
||||
assert done["package"]["status"] == "done"
|
||||
|
||||
label = done["label"]
|
||||
assert label is not None
|
||||
assert "Fasole cu carne" in label["html"]
|
||||
assert "250 g" in label["html"] and "Fasole" in label["html"]
|
||||
assert "200 g" in label["html"] and "Carne" in label["html"]
|
||||
assert label["fields"]["breakdown"] == "250 g Fasole, 200 g Carne"
|
||||
assert label["print_url"] == f"/labels/{package_id}/print"
|
||||
|
||||
page = client.get(label["print_url"] + "?auto=0")
|
||||
assert page.status_code == 200
|
||||
html = page.get_data(as_text=True)
|
||||
assert "Fasole cu carne" in html and "450" in html
|
||||
|
||||
|
||||
def test_scale_reading_is_used_when_no_weight_is_sent(client, recipe, scale):
|
||||
package_id = client.post("/api/packages", json={"recipe_id": recipe["id"]}).get_json()["package"]["id"]
|
||||
response = client.post(f"/api/packages/{package_id}/items", json={})
|
||||
assert response.status_code == 201, response.get_json()
|
||||
body = response.get_json()
|
||||
assert body["package"]["items"][0]["weight"] == 250
|
||||
assert body["package"]["items"][0]["source"] == "scale"
|
||||
assert scale.calls >= 1
|
||||
|
||||
|
||||
def test_unstable_scale_requires_force(client, recipe, scale):
|
||||
scale.reading.update({"stable": False})
|
||||
package_id = client.post("/api/packages", json={"recipe_id": recipe["id"]}).get_json()["package"]["id"]
|
||||
|
||||
refused = client.post(f"/api/packages/{package_id}/items", json={})
|
||||
assert refused.status_code == 422
|
||||
assert "stabila" in refused.get_json()["error"].lower()
|
||||
|
||||
forced = client.post(f"/api/packages/{package_id}/items", json={"force": True})
|
||||
assert forced.status_code == 201
|
||||
|
||||
|
||||
def test_stale_reading_is_rejected(client, recipe, scale):
|
||||
scale.reading.update({"stale": True})
|
||||
package_id = client.post("/api/packages", json={"recipe_id": recipe["id"]}).get_json()["package"]["id"]
|
||||
response = client.post(f"/api/packages/{package_id}/items", json={"force": True})
|
||||
assert response.status_code == 409
|
||||
assert "veche" in response.get_json()["error"].lower()
|
||||
|
||||
|
||||
def test_undo_removes_last_component(client, recipe):
|
||||
package_id = client.post("/api/packages", json={"recipe_id": recipe["id"]}).get_json()["package"]["id"]
|
||||
client.post(f"/api/packages/{package_id}/items", json={"weight": "250"})
|
||||
undone = client.post(f"/api/packages/{package_id}/undo", json={})
|
||||
assert undone.status_code == 200
|
||||
body = undone.get_json()
|
||||
assert body["items"] == []
|
||||
assert body["step"]["position"] == 1
|
||||
|
||||
|
||||
def test_void_package(client, recipe):
|
||||
package_id = client.post("/api/packages", json={"recipe_id": recipe["id"]}).get_json()["package"]["id"]
|
||||
voided = client.post(f"/api/packages/{package_id}/void", json={})
|
||||
assert voided.status_code == 200
|
||||
with client.application.app_context():
|
||||
rows = list_packages(status="void")
|
||||
assert [row["id"] for row in rows] == [package_id]
|
||||
assert client.get("/api/package").get_json()["state"] is None
|
||||
|
||||
|
||||
def test_only_one_open_package(client, recipe):
|
||||
client.post("/api/packages", json={"recipe_id": recipe["id"]})
|
||||
second = client.post("/api/packages", json={"recipe_id": recipe["id"]})
|
||||
assert second.status_code == 409
|
||||
assert "deja un pachet" in second.get_json()["error"].lower()
|
||||
|
||||
|
||||
def test_weight_is_converted_to_recipe_unit(client, fasole):
|
||||
client.post("/retete/nou", data={
|
||||
"code": "PF-KG", "name": "Amestec kg", "unit": "kg",
|
||||
"component_1_id": str(fasole), "active": "on",
|
||||
})
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import list_recipes
|
||||
|
||||
recipe = [r for r in list_recipes() if r["code"] == "PF-KG"][0]
|
||||
|
||||
package_id = client.post("/api/packages", json={"recipe_id": recipe["id"]}).get_json()["package"]["id"]
|
||||
response = client.post(f"/api/packages/{package_id}/items",
|
||||
json={"weight": "0.25", "unit": "kg"})
|
||||
assert response.status_code == 201
|
||||
assert response.get_json()["complete"] is True
|
||||
assert response.get_json()["package"]["total_weight"] == 0.25
|
||||
|
||||
|
||||
def test_invalid_weight(client, recipe):
|
||||
package_id = client.post("/api/packages", json={"recipe_id": recipe["id"]}).get_json()["package"]["id"]
|
||||
response = client.post(f"/api/packages/{package_id}/items", json={"weight": "abc"})
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_history_filter_and_csv(client, recipe):
|
||||
package_id = client.post("/api/packages", json={"recipe_id": recipe["id"], "lot": "LOT-A"}).get_json()["package"]["id"]
|
||||
client.post(f"/api/packages/{package_id}/items", json={"weight": "250"})
|
||||
client.post(f"/api/packages/{package_id}/items", json={"weight": "200"})
|
||||
|
||||
filtered = client.get("/istoric?q=LOT-A").get_data(as_text=True)
|
||||
assert "LOT-A" in filtered
|
||||
assert "Fasole cu carne" in filtered
|
||||
|
||||
empty = client.get("/istoric?q=NOPE").get_data(as_text=True)
|
||||
assert "Nicio înregistrare" in empty
|
||||
|
||||
export = client.get("/istoric/export.csv?q=LOT-A")
|
||||
assert export.status_code == 200
|
||||
assert export.mimetype.startswith("text/csv")
|
||||
body = export.get_data(as_text=True)
|
||||
assert "LOT-A" in body
|
||||
assert "Fasole" in body
|
||||
assert "Cod produs finit" in body
|
||||
|
||||
|
||||
def test_template_crud_and_preview(client):
|
||||
preview = client.get("/sabloane/1/previzualizare").get_data(as_text=True)
|
||||
assert "Fasole cu carne" in preview
|
||||
assert "250" in preview and "200" in preview
|
||||
|
||||
created = client.post("/sabloane/designer/nou", data={
|
||||
"name": "Etichetă mică",
|
||||
"width_mm": "60",
|
||||
"height_mm": "40",
|
||||
})
|
||||
assert created.status_code == 302
|
||||
assert "Etichetă mică" in client.get("/sabloane").get_data(as_text=True)
|
||||
|
||||
|
||||
def test_every_template_renders_a_label(client):
|
||||
"""Orice sablon produce o eticheta, chiar si unul vechi fara design."""
|
||||
with client.application.app_context():
|
||||
from cantarire.db import get_db
|
||||
|
||||
get_db().execute(
|
||||
"INSERT INTO label_templates(name, width_mm, height_mm, body_html, css,"
|
||||
" design_json) VALUES ('Vechi', 100, 60, '<div>x</div>', '', '')"
|
||||
)
|
||||
get_db().commit()
|
||||
|
||||
html = client.get("/sabloane").get_data(as_text=True)
|
||||
assert "Vechi" in html
|
||||
assert "fără design" in html
|
||||
|
||||
|
||||
def test_printed_acknowledgement(client, recipe):
|
||||
package_id = client.post("/api/packages", json={"recipe_id": recipe["id"]}).get_json()["package"]["id"]
|
||||
client.post(f"/api/packages/{package_id}/items", json={"weight": "250"})
|
||||
client.post(f"/api/packages/{package_id}/items", json={"weight": "200"})
|
||||
|
||||
ack = client.post(f"/api/packages/{package_id}/printed")
|
||||
assert ack.status_code == 200
|
||||
package = ack.get_json()["package"]
|
||||
assert package["print_count"] == 1
|
||||
assert package["printed_at"]
|
||||
|
||||
|
||||
def test_api_scale_status_and_weight(client):
|
||||
status = client.get("/api/scale").get_json()
|
||||
assert status["ok"] is True
|
||||
assert status["profiles"][0]["name"] == "test-scale"
|
||||
|
||||
reading = client.get("/api/weight").get_json()
|
||||
assert reading["ok"] is True
|
||||
assert reading["value"] == 250.0
|
||||
|
||||
|
||||
def test_qz_tray_assets_are_served(client):
|
||||
lib = client.get("/static/js/vendor/qz-tray.js")
|
||||
assert lib.status_code == 200
|
||||
assert "PATCHED" in lib.get_data(as_text=True)
|
||||
|
||||
module = client.get("/static/js/qz-print.js")
|
||||
assert module.status_code == 200
|
||||
script = module.get_data(as_text=True)
|
||||
assert "window.CantarirePrint" in script
|
||||
assert "qz.printers.find()" in script
|
||||
assert "type: 'pdf'" in script
|
||||
|
||||
|
||||
def test_qz_ui_is_wired(client):
|
||||
dashboard = client.get("/").get_data(as_text=True)
|
||||
assert "vendor/qz-tray.js" in dashboard
|
||||
assert "js/qz-print.js" in dashboard
|
||||
assert "data-qz-printer" in dashboard
|
||||
assert "js/weigh.js" in dashboard
|
||||
|
||||
|
||||
def test_monitor_page_runs_probe(client, scale):
|
||||
page = client.get("/monitor")
|
||||
assert page.status_code == 200
|
||||
assert "Monitor serial" in page.get_data(as_text=True)
|
||||
|
||||
result = client.post("/monitor", data={
|
||||
"port": "/dev/ttyUSB0", "baud": "9600", "framing": "8N1", "seconds": "2",
|
||||
})
|
||||
assert result.status_code == 200
|
||||
html = result.get_data(as_text=True)
|
||||
assert scale.probed == {"port": "/dev/ttyUSB0", "baud": 9600, "framing": "8N1",
|
||||
"seconds": 2.0}
|
||||
assert "toledo_cont" in html
|
||||
|
||||
|
||||
def test_monitor_requires_port(client):
|
||||
page = client.post("/monitor", data={"port": "", "baud": "9600", "framing": "8N1"})
|
||||
assert page.status_code == 200
|
||||
assert "Alege portul serial" in page.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_settings_save(client):
|
||||
response = client.post("/setari", data={
|
||||
"company_name": "Firma Test",
|
||||
"active_scale_profile": "test-scale",
|
||||
"default_operator": "Maria",
|
||||
"auto_print": "on",
|
||||
})
|
||||
assert response.status_code == 302
|
||||
page = client.get("/setari").get_data(as_text=True)
|
||||
assert "Firma Test" in page
|
||||
assert "Maria" in page
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Designer vizual: validare, salvare si randare pe eticheta."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from cantarire import designer
|
||||
|
||||
|
||||
def _designer_template(client, name="Designer test") -> int:
|
||||
response = client.post("/sabloane/designer/nou", data={"name": name})
|
||||
assert response.status_code == 302
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import list_templates
|
||||
|
||||
return [t for t in list_templates() if t["name"] == name][0]["id"]
|
||||
|
||||
|
||||
def test_default_design_has_expected_blocks():
|
||||
design = designer.default_design(100, 60)
|
||||
types = [block["type"] for block in design["blocks"]]
|
||||
assert "field" in types and "items" in types and "qr" in types
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width,height", [(100, 60), (80, 50), (60, 40), (150, 100)])
|
||||
def test_default_design_stays_inside_safe_margin(width, height):
|
||||
"""Elementele implicite trebuie sa fie in interiorul zonei sigure."""
|
||||
margin = designer.SAFE_MARGIN_MM
|
||||
design = designer.default_design(width, height)
|
||||
for block in design["blocks"]:
|
||||
assert block["x"] >= margin - 0.001, block
|
||||
assert block["y"] >= margin - 0.001, block
|
||||
assert block["x"] + block["w"] <= width - margin + 0.001, block
|
||||
assert block["y"] + block["h"] <= height - margin + 0.001, block
|
||||
|
||||
|
||||
def test_validate_design_clamps_out_of_bounds():
|
||||
raw = {
|
||||
"width_mm": 100,
|
||||
"height_mm": 60,
|
||||
"blocks": [
|
||||
{"type": "text", "x": 200, "y": -5, "w": 500, "h": 999, "text": "X"},
|
||||
{"type": "nope"},
|
||||
{"type": "field", "field": "does.not.exist"},
|
||||
],
|
||||
}
|
||||
design = designer.validate_design(raw, 100, 60)
|
||||
assert len(design["blocks"]) == 2 # tipul necunoscut este ignorat
|
||||
block = design["blocks"][0]
|
||||
assert block["x"] <= 100 and block["y"] >= 0
|
||||
assert block["w"] <= 100 and block["h"] <= 60
|
||||
assert design["blocks"][1]["field"] == "recipe.name"
|
||||
|
||||
|
||||
def test_validate_design_rejects_bad_json():
|
||||
with pytest.raises(designer.ValidationError):
|
||||
designer.validate_design("{not json", 100, 60)
|
||||
|
||||
|
||||
def test_render_design_escapes_text_and_builds_fields():
|
||||
design = designer.validate_design({
|
||||
"width_mm": 100, "height_mm": 60,
|
||||
"blocks": [
|
||||
{"type": "text", "x": 1, "y": 1, "w": 50, "h": 5, "text": "<script>x</script>"},
|
||||
{"type": "field", "x": 1, "y": 7, "w": 50, "h": 8,
|
||||
"field": "recipe.name", "prefix": "", "suffix": ""},
|
||||
{"type": "items", "x": 1, "y": 16, "w": 50, "h": 20},
|
||||
{"type": "qr", "x": 60, "y": 16, "w": 20, "h": 20, "field": "package.qr"},
|
||||
],
|
||||
}, 100, 60)
|
||||
html = designer.render_design(design, designer.demo_fields(), designer.demo_items())
|
||||
assert "<script>" in html and "<script>" not in html
|
||||
assert "Fasole cu carne" in html
|
||||
assert "250 g" in html and "Carne" in html
|
||||
assert "<svg" in html # QR inline
|
||||
|
||||
|
||||
# -- continutul codului QR ---------------------------------------------------
|
||||
|
||||
def _qr_block(**extra):
|
||||
block = {"type": "qr", "x": 2, "y": 2, "w": 20, "h": 20}
|
||||
block.update(extra)
|
||||
return block
|
||||
|
||||
|
||||
def _qr(**extra):
|
||||
design = designer.validate_design(
|
||||
{"width_mm": 100, "height_mm": 60, "blocks": [_qr_block(**extra)]}, 100, 60)
|
||||
return design["blocks"][0]
|
||||
|
||||
|
||||
def test_qr_parts_can_combine_fields_and_literal_text():
|
||||
block = _qr(separator="dash", parts=[
|
||||
{"field": "recipe.code"},
|
||||
{"text": "LOT"},
|
||||
{"field": "package.lot"},
|
||||
])
|
||||
assert block["separator"] == "dash"
|
||||
payload = designer.qr_payload(block, designer.demo_fields())
|
||||
assert payload == "PF-001 - LOT - LOT-2610-A"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("separator,expected", [
|
||||
("pipe", "PF-001 | LOT-2610-A"),
|
||||
("dash", "PF-001 - LOT-2610-A"),
|
||||
("slash", "PF-001/LOT-2610-A"),
|
||||
("space", "PF-001 LOT-2610-A"),
|
||||
("newline", "PF-001\nLOT-2610-A"),
|
||||
("none", "PF-001LOT-2610-A"),
|
||||
])
|
||||
def test_qr_separator_variants(separator, expected):
|
||||
block = _qr(separator=separator, parts=[
|
||||
{"field": "recipe.code"}, {"field": "package.lot"},
|
||||
])
|
||||
assert designer.qr_payload(block, designer.demo_fields()) == expected
|
||||
|
||||
|
||||
def test_qr_unknown_separator_falls_back_to_pipe():
|
||||
block = _qr(separator="paranteze", parts=[{"field": "recipe.code"}])
|
||||
assert block["separator"] == "pipe"
|
||||
|
||||
|
||||
def test_qr_empty_field_parts_are_skipped():
|
||||
block = _qr(separator="pipe", parts=[
|
||||
{"field": "recipe.code"},
|
||||
{"field": "package.lot"},
|
||||
{"field": "company.address"},
|
||||
])
|
||||
fields = dict(designer.demo_fields())
|
||||
fields["package.lot"] = ""
|
||||
assert designer.qr_payload(block, fields) == "PF-001 | Str. Exemplu 1"
|
||||
|
||||
|
||||
def test_qr_parts_reject_unknown_fields_and_empty_text():
|
||||
block = _qr(parts=[
|
||||
{"field": "does.not.exist"},
|
||||
{"text": ""},
|
||||
{"field": "package.lot"},
|
||||
])
|
||||
assert block["parts"] == [{"field": "package.lot"}]
|
||||
|
||||
|
||||
def test_qr_legacy_field_becomes_a_single_part():
|
||||
block = _qr(field="package.lot")
|
||||
assert block["parts"] == [{"field": "package.lot"}]
|
||||
assert designer.qr_payload(block, designer.demo_fields()) == "LOT-2610-A"
|
||||
|
||||
|
||||
def test_qr_legacy_full_content_still_works():
|
||||
block = _qr(field="package.qr")
|
||||
assert block["parts"] == [{"field": "package.qr"}]
|
||||
payload = designer.qr_payload(block, designer.demo_fields())
|
||||
assert payload.startswith("PF-001 | LOT-2610-A | 450 g")
|
||||
|
||||
|
||||
def test_qr_parts_are_limited():
|
||||
parts = [{"field": "package.lot"} for _ in range(designer.MAX_QR_PARTS + 8)]
|
||||
block = _qr(parts=parts)
|
||||
assert len(block["parts"]) == designer.MAX_QR_PARTS
|
||||
|
||||
|
||||
def test_qr_accepts_custom_fields():
|
||||
block = _qr(parts=[{"field": "custom.contact"}])
|
||||
assert block["parts"] == [{"field": "custom.contact"}]
|
||||
assert designer.qr_payload(block, {"custom.contact": "Tel. 0700"}) == "Tel. 0700"
|
||||
|
||||
|
||||
def test_qr_without_parts_uses_full_content():
|
||||
block = {"type": "qr", "x": 1, "y": 1, "w": 20, "h": 20, "parts": []}
|
||||
assert designer.qr_payload(block, designer.demo_fields()).startswith("PF-001 |")
|
||||
|
||||
|
||||
def test_qr_payload_is_html_escaped_in_render():
|
||||
design = designer.validate_design({
|
||||
"width_mm": 100, "height_mm": 60,
|
||||
"blocks": [_qr_block(parts=[{"text": "</svg><script>x</script>"}])],
|
||||
}, 100, 60)
|
||||
html = designer.render_design(design, designer.demo_fields(), designer.demo_items())
|
||||
assert "<script>x" not in html
|
||||
assert "<svg" in html
|
||||
|
||||
|
||||
def test_qr_svg_differs_for_different_payloads():
|
||||
a = designer.qr_svg("PF-001 | LOT-A")
|
||||
b = designer.qr_svg("PF-002 | LOT-B")
|
||||
assert a != b
|
||||
assert "<svg" in a and "<svg" in b
|
||||
|
||||
|
||||
def test_designer_page_loads(client):
|
||||
template_id = _designer_template(client)
|
||||
page = client.get(f"/sabloane/{template_id}/designer")
|
||||
assert page.status_code == 200
|
||||
html = page.get_data(as_text=True)
|
||||
assert "designer" in html
|
||||
assert "package.total_weight" in html # catalog de campuri
|
||||
assert "canvas-blocks" in html
|
||||
|
||||
|
||||
def test_designer_save_persists_and_renders(client):
|
||||
template_id = _designer_template(client)
|
||||
payload = {
|
||||
"design": {
|
||||
"version": 1, "width_mm": 80, "height_mm": 50,
|
||||
"blocks": [
|
||||
{"id": "b1", "type": "field", "x": 2, "y": 2, "w": 60, "h": 12,
|
||||
"field": "recipe.name", "style": {"size": 6, "bold": True}},
|
||||
{"id": "b2", "type": "field", "x": 2, "y": 16, "w": 60, "h": 12,
|
||||
"field": "package.total_weight", "suffix": " g"},
|
||||
],
|
||||
}
|
||||
}
|
||||
response = client.post(f"/sabloane/{template_id}/designer", json=payload)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()["ok"] is True
|
||||
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_template
|
||||
|
||||
stored = get_template(template_id)
|
||||
assert stored["design_json"]
|
||||
assert stored["width_mm"] == 80 and stored["height_mm"] == 50
|
||||
saved = json.loads(stored["design_json"])
|
||||
assert len(saved["blocks"]) == 2
|
||||
|
||||
|
||||
def test_designer_save_rejects_invalid_payload(client):
|
||||
template_id = _designer_template(client)
|
||||
response = client.post(f"/sabloane/{template_id}/designer", json={"design": "nope"})
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["ok"] is False
|
||||
|
||||
|
||||
def test_designer_qr_endpoint(client):
|
||||
response = client.get("/sabloane/designer/qr.svg?data=PF-001|LOT-1")
|
||||
assert response.status_code == 200
|
||||
assert response.mimetype == "image/svg+xml"
|
||||
assert b"<svg" in response.data
|
||||
|
||||
|
||||
def test_label_uses_design_when_present(client, recipe):
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_default_template, save_template_design
|
||||
|
||||
template = get_default_template()
|
||||
design = designer.default_design(template["width_mm"], template["height_mm"])
|
||||
save_template_design(template["id"], designer.dump_design(design))
|
||||
|
||||
package_id = client.post("/api/packages", json={"recipe_id": recipe["id"]}).get_json()["package"]["id"]
|
||||
client.post(f"/api/packages/{package_id}/items", json={"weight": "250"})
|
||||
done = client.post(f"/api/packages/{package_id}/items", json={"weight": "200"}).get_json()
|
||||
|
||||
label = done["label"]
|
||||
assert "design-label" in label["html"]
|
||||
assert "Fasole cu carne" in label["html"]
|
||||
assert "450" in label["html"]
|
||||
assert ".d-block" in label["css"]
|
||||
|
||||
page = client.get(label["print_url"] + "?auto=0")
|
||||
assert page.status_code == 200
|
||||
assert "design-label" in page.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_reset_design_restores_default(client):
|
||||
template_id = _designer_template(client)
|
||||
client.post(f"/sabloane/{template_id}/designer", json={
|
||||
"design": {"width_mm": 100, "height_mm": 60,
|
||||
"blocks": [{"type": "text", "x": 1, "y": 1, "w": 20, "h": 4, "text": "X"}]}
|
||||
})
|
||||
reset = client.post(f"/sabloane/{template_id}/designer/reset")
|
||||
assert reset.status_code == 302
|
||||
|
||||
with client.application.app_context():
|
||||
from cantarire import designer
|
||||
from cantarire.repositories import get_template
|
||||
|
||||
stored = get_template(template_id)
|
||||
design = designer.load_design(stored["design_json"], 100, 60)
|
||||
assert len(design["blocks"]) == len(designer.default_design(100, 60)["blocks"])
|
||||
assert stored["design_json"] != "" # sablonul nu rămâne fara eticheta
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Campuri variabile proprii (custom.*): CRUD, validare si randare pe eticheta."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from cantarire import designer
|
||||
|
||||
|
||||
def _create_field(client, label="Contact firmă", template="Tel. {{company.phone}}",
|
||||
active="on"):
|
||||
return client.post("/campuri/nou", data={
|
||||
"label": label, "template": template, "active": active,
|
||||
}, follow_redirects=True)
|
||||
|
||||
|
||||
def test_fields_page_and_nav(client):
|
||||
page = client.get("/campuri")
|
||||
assert page.status_code == 200
|
||||
html = page.get_data(as_text=True)
|
||||
assert "Câmpuri variabile" in html
|
||||
assert 'href="/campuri/nou"' in html
|
||||
# link in subnavigatia din Setari, nu in bara principala
|
||||
flat = " ".join(html.split())
|
||||
assert 'href="/campuri"' in flat
|
||||
assert ">Câmpuri</a>" in flat
|
||||
|
||||
|
||||
def test_create_field_generates_key_and_shows_preview(client):
|
||||
_create_field(client)
|
||||
html = client.get("/campuri").get_data(as_text=True)
|
||||
assert "Contact firmă" in html
|
||||
assert "custom.contact_firma" in html
|
||||
assert "Tel. 0700 000 000" in html # rezultatul demo
|
||||
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_label_field_by_key
|
||||
|
||||
row = get_label_field_by_key("custom.contact_firma")
|
||||
assert row is not None and row["active"] == 1
|
||||
|
||||
|
||||
def test_duplicate_labels_get_unique_keys(client):
|
||||
_create_field(client, label="Alergeni", template="Poate conține gluten")
|
||||
_create_field(client, label="Alergeni", template="Poate conține lactoză")
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import list_label_fields
|
||||
|
||||
keys = [row["key"] for row in list_label_fields()]
|
||||
assert keys == ["custom.alergeni", "custom.alergeni_2"]
|
||||
|
||||
|
||||
def test_unknown_placeholder_is_rejected(client):
|
||||
response = _create_field(client, label="Greșit", template="X {{does.not.exist}}")
|
||||
html = response.get_data(as_text=True)
|
||||
assert "Campuri necunoscute" in html
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import list_label_fields
|
||||
|
||||
assert list_label_fields() == []
|
||||
|
||||
|
||||
def test_empty_template_is_rejected(client):
|
||||
response = _create_field(client, label="Gol", template=" ")
|
||||
assert "Textul campului este obligatoriu." in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_update_keeps_key_and_can_deactivate(client):
|
||||
_create_field(client)
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_label_field_by_key
|
||||
|
||||
field_id = get_label_field_by_key("custom.contact_firma")["id"]
|
||||
|
||||
client.post(f"/campuri/{field_id}/editare", data={
|
||||
"label": "Contact (nou)", "template": "{{company.phone}}",
|
||||
}, follow_redirects=True)
|
||||
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_label_field, list_label_fields
|
||||
|
||||
row = get_label_field(field_id)
|
||||
assert row["label"] == "Contact (nou)"
|
||||
assert row["key"] == "custom.contact_firma" # cheia ramane stabila
|
||||
assert row["active"] == 0 # fara bifa = inactiv
|
||||
assert [f["key"] for f in list_label_fields()] == [] # ascuns din designer
|
||||
|
||||
|
||||
def test_delete_field(client):
|
||||
_create_field(client, label="Șters", template="text")
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_label_field_by_key
|
||||
|
||||
field_id = get_label_field_by_key("custom.sters")["id"]
|
||||
response = client.post(f"/campuri/{field_id}/stergere", follow_redirects=True)
|
||||
assert "a fost sters" in response.get_data(as_text=True)
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_label_field_by_key
|
||||
|
||||
assert get_label_field_by_key("custom.sters") is None
|
||||
|
||||
|
||||
def test_preview_endpoint(client):
|
||||
ok = client.get("/campuri/previzualizare",
|
||||
query_string={"template": "{{recipe.name}} · {{package.lot}}"})
|
||||
assert ok.get_json() == {"ok": True, "value": "Fasole cu carne · LOT-2610-A"}
|
||||
|
||||
bad = client.get("/campuri/previzualizare", query_string={"template": "{{nope.x}}"})
|
||||
data = bad.get_json()
|
||||
assert data["ok"] is False and "Campuri necunoscute" in data["error"]
|
||||
|
||||
|
||||
def test_custom_field_is_offered_in_designer_picker(client):
|
||||
_create_field(client, label="Contact firmă", template="Tel. {{company.phone}}")
|
||||
response = client.post("/sabloane/designer/nou", data={"name": "Cu camp propriu"},
|
||||
follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
html = response.get_data(as_text=True)
|
||||
assert "custom.contact_firma" in html
|
||||
assert "Tel. 0700 000 000" in html # sample pentru previzualizare
|
||||
# denumirea trece prin tojson, deci diacriticele sunt escapate
|
||||
assert "Contact firm\\u0103" in html
|
||||
|
||||
|
||||
def test_custom_field_renders_on_label(client):
|
||||
_create_field(client, label="Contact", template="Tel. {{company.phone}}")
|
||||
design = designer.validate_design({
|
||||
"width_mm": 100, "height_mm": 60,
|
||||
"blocks": [
|
||||
{"type": "field", "x": 3, "y": 3, "w": 70, "h": 6, "field": "custom.contact"},
|
||||
],
|
||||
}, 100, 60)
|
||||
assert design["blocks"][0]["field"] == "custom.contact"
|
||||
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import list_label_fields
|
||||
|
||||
fields = designer.apply_custom_fields(designer.demo_fields(), list_label_fields())
|
||||
html = designer.render_design(design, fields, [])
|
||||
assert "Tel. 0700 000 000" in html
|
||||
|
||||
|
||||
def test_deleted_custom_field_renders_empty_not_wrong(client):
|
||||
"""Un design care refera un camp sters nu trebuie sa cada sau sa afiseze altceva."""
|
||||
design = designer.validate_design({
|
||||
"width_mm": 100, "height_mm": 60,
|
||||
"blocks": [{"type": "field", "x": 3, "y": 3, "w": 70, "h": 6, "field": "custom.sters"}],
|
||||
}, 100, 60)
|
||||
assert design["blocks"][0]["field"] == "custom.sters"
|
||||
html = designer.render_design(design, designer.demo_fields(), [])
|
||||
assert "Fasole cu carne" not in html # nu cade pe alt camp
|
||||
assert "> <" in html or html.count("d-field") == 1
|
||||
|
||||
|
||||
def test_custom_field_template_rejects_custom_reference():
|
||||
"""Campurile proprii se pot referi doar la campuri fixe (fara bucle)."""
|
||||
errors = designer.custom_template_errors("X {{custom.altceva}}")
|
||||
assert errors and "Campuri necunoscute" in errors[0]
|
||||
assert designer.custom_template_errors("{{company.phone}}") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,expected", [
|
||||
("", ""),
|
||||
("text simplu", "text simplu"),
|
||||
("{{company.name}}", "Moto Adv SRL"),
|
||||
("{{ package.lot }}/{{package.operator}}", "LOT-2610-A/Maria"),
|
||||
])
|
||||
def test_expand_custom(text, expected):
|
||||
assert designer.expand_custom(text, designer.demo_fields()) == expected
|
||||
|
||||
|
||||
def test_usage_counter_reports_templates(client):
|
||||
_create_field(client, label="Folosit", template="X")
|
||||
client.post("/sabloane/designer/nou", data={"name": "Cu folosit"})
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import (count_label_field_usage, get_default_template,
|
||||
get_label_field_by_key, list_templates,
|
||||
save_template_design)
|
||||
|
||||
field = get_label_field_by_key("custom.folosit")
|
||||
target = [t for t in list_templates() if t["name"] == "Cu folosit"][0]
|
||||
payload = {"version": 1, "width_mm": 100, "height_mm": 60, "blocks": [
|
||||
{"id": "b1", "type": "field", "x": 1, "y": 1, "w": 50, "h": 6,
|
||||
"field": "custom.folosit"},
|
||||
]}
|
||||
save_template_design(target["id"], json.dumps(payload))
|
||||
assert count_label_field_usage(field["key"]) == 1
|
||||
assert count_label_field_usage("custom.inexistent") == 0
|
||||
assert get_default_template() is not None
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Pregatire pentru productie: backup, reset, verificare, healthcheck."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from cantarire import maintenance
|
||||
|
||||
|
||||
def test_status_reports_counts_and_integrity(client):
|
||||
with client.application.app_context():
|
||||
status = maintenance.database_status()
|
||||
assert status["exists"] is True
|
||||
assert status["integrity"] == "ok"
|
||||
assert status["schema_version"] == maintenance.SCHEMA_VERSION
|
||||
assert set(status["counts"]) == {
|
||||
"components", "recipes", "packages", "package_items", "templates", "fields"
|
||||
}
|
||||
assert status["backups"] == 0
|
||||
|
||||
|
||||
def test_backup_creates_file_and_is_listed(client):
|
||||
with client.application.app_context():
|
||||
path = maintenance.backup_database("test")
|
||||
assert path.exists() and path.read_bytes()[:15] == b"SQLite format 3"
|
||||
listed = maintenance.list_backups()
|
||||
assert [item["name"] for item in listed] == [path.name]
|
||||
assert listed[0]["size"] > 0
|
||||
|
||||
|
||||
def test_backup_prune_keeps_newest(client):
|
||||
with client.application.app_context():
|
||||
first = maintenance.backup_database("a")
|
||||
second = maintenance.backup_database("b")
|
||||
removed = maintenance.prune_backups(keep=1)
|
||||
listed = maintenance.list_backups()
|
||||
assert removed == 1
|
||||
assert len(listed) == 1
|
||||
assert listed[0]["path"] in (str(first), str(second))
|
||||
|
||||
|
||||
def test_readiness_lists_missing_setup(client):
|
||||
with client.application.app_context():
|
||||
summary = maintenance.readiness_summary()
|
||||
keys = {item["key"]: item for item in summary["checks"]}
|
||||
assert set(keys) == {
|
||||
"database", "company", "components", "recipes", "template",
|
||||
"company_contact", "scale", "secret",
|
||||
}
|
||||
# baza e goala intr-un test nou
|
||||
assert keys["components"]["ok"] is False
|
||||
assert keys["recipes"]["ok"] is False
|
||||
assert keys["template"]["ok"] is True # sablonul implicit exista
|
||||
assert summary["ready"] is False
|
||||
assert "Componente" in {item["label"] for item in summary["missing"]}
|
||||
|
||||
|
||||
def test_readiness_becomes_true_after_setup(client, recipe):
|
||||
client.post("/setari", data={
|
||||
"company_name": "Fabrica SRL",
|
||||
"company_phone": "0700 111 222",
|
||||
"active_scale_profile": "",
|
||||
"default_template_id": "",
|
||||
"default_operator": "Ana",
|
||||
"auto_print": "on",
|
||||
})
|
||||
with client.application.app_context():
|
||||
summary = maintenance.readiness_summary()
|
||||
assert summary["ready"] is True, [i for i in summary["missing"]]
|
||||
assert summary["missing"] == []
|
||||
|
||||
|
||||
def test_reset_clears_data_and_restores_defaults(client, recipe):
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import list_components, list_recipes, list_templates
|
||||
|
||||
assert list_components() and list_recipes()
|
||||
result = maintenance.reset_database()
|
||||
assert result["backup"] and maintenance.list_backups()
|
||||
assert list_components() == []
|
||||
assert list_recipes() == []
|
||||
templates = list_templates()
|
||||
assert len(templates) == 1 # sablonul standard recreat
|
||||
status = maintenance.database_status()
|
||||
from cantarire.db import all_settings
|
||||
|
||||
settings = all_settings()
|
||||
assert status["counts"]["components"] == 0
|
||||
assert settings["company_name"] == "Firma SRL" # valoarea implicita
|
||||
assert settings["schema_version"] == str(maintenance.SCHEMA_VERSION)
|
||||
assert result["removed"]["components"] == 2
|
||||
|
||||
|
||||
def test_reset_without_backup(client, recipe):
|
||||
with client.application.app_context():
|
||||
before = len(maintenance.list_backups())
|
||||
result = maintenance.reset_database(keep_backup=False)
|
||||
after = len(maintenance.list_backups())
|
||||
assert result["backup"] is None
|
||||
assert after == before
|
||||
|
||||
|
||||
def test_seed_demo_creates_data(client):
|
||||
with client.application.app_context():
|
||||
result = maintenance.seed_demo()
|
||||
from cantarire.repositories import get_component_by_code, list_recipes
|
||||
|
||||
assert result["created"]["components"] == 3
|
||||
assert result["created"]["recipes"] == 2
|
||||
assert get_component_by_code("FAS") is not None
|
||||
codes = {r["code"] for r in list_recipes()}
|
||||
assert {"PF-001", "PF-002"} <= codes
|
||||
|
||||
# idempotent: a doua rulare nu dubleaza nimic
|
||||
again = maintenance.seed_demo()
|
||||
assert again["created"] == {"components": 0, "recipes": 0}
|
||||
|
||||
|
||||
def test_healthz_endpoint(client):
|
||||
response = client.get("/healthz")
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data["ok"] is True
|
||||
assert data["database"]["integrity"] == "ok"
|
||||
assert data["setup"]["ready"] is False
|
||||
assert "Componente" in data["setup"]["missing"]
|
||||
|
||||
|
||||
def test_settings_page_shows_system_card(client):
|
||||
html = client.get("/setari").get_data(as_text=True)
|
||||
assert "<h2>Sistem</h2>" in html
|
||||
assert "Descarcă backup" in html
|
||||
assert "/healthz" in html
|
||||
|
||||
|
||||
def test_backup_download_route(client):
|
||||
response = client.get("/setari/backup")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["Content-Disposition"].startswith("attachment")
|
||||
assert response.data[:15] == b"SQLite format 3"
|
||||
with client.application.app_context():
|
||||
assert len(maintenance.list_backups()) == 1
|
||||
|
||||
|
||||
def test_dashboard_shows_setup_checklist_when_empty(client):
|
||||
html = client.get("/").get_data(as_text=True)
|
||||
assert "Pregătire pentru prima cântărire" in html
|
||||
assert "Adaugă component" in html
|
||||
assert "Adaugă produs finit" in html
|
||||
|
||||
|
||||
def test_dashboard_hides_checklist_when_ready(client, recipe):
|
||||
client.post("/setari", data={
|
||||
"company_name": "Fabrica SRL", "company_phone": "0700 111 222",
|
||||
"active_scale_profile": "", "default_template_id": "", "auto_print": "on",
|
||||
})
|
||||
html = client.get("/").get_data(as_text=True)
|
||||
assert "Pregătire pentru prima cântărire" not in html
|
||||
|
||||
|
||||
def test_admin_cli_status_and_check(client):
|
||||
"""CLI-ul ruleaza pe aplicatia de test, fara sa atinga baza reala."""
|
||||
from cantarire.tools import admin
|
||||
|
||||
app = client.application
|
||||
assert admin.main(["status"], app=app) == 0
|
||||
# pe baza de test goala, check semnaleaza lipsuri
|
||||
assert admin.main(["check", "--json"], app=app) == 1
|
||||
|
||||
|
||||
def test_ensure_instance_dirs(client):
|
||||
with client.application.app_context():
|
||||
maintenance.ensure_instance_dirs()
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(client.application.config["INSTANCE_DIR"])
|
||||
assert (root / "backups").is_dir()
|
||||
assert (root / "captures").is_dir()
|
||||
|
||||
|
||||
def test_reset_report_is_readable(client):
|
||||
with client.application.app_context():
|
||||
result = maintenance.reset_database(keep_backup=False)
|
||||
text = maintenance.reset_report(result)
|
||||
assert "Date sterse" in text
|
||||
assert "Backup: (nu s-a facut)" in text
|
||||
assert "schema v" in text
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Navigatia: doua taburi principale (Cantarire, Setari) si subnavigatia de administrare."""
|
||||
from __future__ import annotations
|
||||
|
||||
MAIN_TABS = ("Cântărire", "Setări")
|
||||
|
||||
ADMIN_LINKS = (
|
||||
("/componente", "Componente"),
|
||||
("/retete", "Produse finite"),
|
||||
("/sabloane", "Șabloane"),
|
||||
("/campuri", "Câmpuri"),
|
||||
("/istoric", "Istoric"),
|
||||
("/monitor", "Monitor serial"),
|
||||
)
|
||||
|
||||
|
||||
def _html(client, path):
|
||||
return client.get(path).get_data(as_text=True)
|
||||
|
||||
|
||||
def _flat(html: str) -> str:
|
||||
"""Colapseaza spatiile, ca sa nu depinda de indentarea Jinja."""
|
||||
return " ".join(html.split())
|
||||
|
||||
|
||||
def test_dashboard_has_only_two_main_tabs(client):
|
||||
html = _html(client, "/")
|
||||
assert 'class="nav"' in html
|
||||
for label in MAIN_TABS:
|
||||
assert f">{label}</a>" in _flat(html)
|
||||
|
||||
# sectiunile de administrare nu mai sunt in bara principala
|
||||
nav = _flat(html).split('class="nav"', 1)[1].split("</nav>", 1)[0]
|
||||
for _href, label in ADMIN_LINKS:
|
||||
assert f">{label}</a>" not in nav
|
||||
|
||||
# dashboard-ul nu arata subnavigatia
|
||||
assert 'class="subnav"' not in html
|
||||
|
||||
|
||||
def test_settings_page_is_hub_and_shows_subnav(client):
|
||||
html = _html(client, "/setari")
|
||||
flat = _flat(html)
|
||||
assert 'class="subnav"' in html
|
||||
# cardurile din hub
|
||||
assert 'class="hub-grid"' in html
|
||||
for href, label in ADMIN_LINKS:
|
||||
assert f'href="{href}"' in flat
|
||||
assert label in flat
|
||||
|
||||
# ambele taburi principale sunt prezente, Setari e activ
|
||||
for label in MAIN_TABS:
|
||||
assert f">{label}</a>" in flat
|
||||
assert 'class="active">Setări</a>' in flat
|
||||
|
||||
|
||||
def test_admin_pages_keep_subnav_and_mark_active(client):
|
||||
for path, label in ADMIN_LINKS:
|
||||
html = _flat(_html(client, path))
|
||||
assert 'class="subnav"' in html, path
|
||||
assert f'class="active">{label}</a>' in html, path
|
||||
|
||||
|
||||
def test_designer_page_is_under_templates_tab(client):
|
||||
response = client.post("/sabloane/designer/nou", data={"name": "Nav test"},
|
||||
follow_redirects=True)
|
||||
flat = _flat(response.get_data(as_text=True))
|
||||
assert 'class="subnav"' in flat
|
||||
assert 'class="active">Șabloane</a>' in flat
|
||||
# "Cantarire" nu e activ aici
|
||||
assert 'class="active">Cântărire</a>' not in flat
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
VENDOR = ROOT / "vendor" / "syncpulse-scale-bridge"
|
||||
for path in (str(ROOT), str(VENDOR)):
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
from cantarire import probe # noqa: E402
|
||||
from scale_bridge.serialio import open_real # noqa: E402
|
||||
from scale_bridge.simulator import SimulatorBank, frame_for # noqa: E402
|
||||
|
||||
|
||||
def test_decode_recognises_toledo_stream():
|
||||
frame = frame_for("toledo_cont", 12.345, True)
|
||||
guesses = probe.decode(frame * 4)
|
||||
ids = [g["protocol"] for g in guesses]
|
||||
assert "toledo_cont" in ids
|
||||
best = guesses[0]
|
||||
assert best["protocol"] == "toledo_cont"
|
||||
assert best["value"] == pytest.approx(12.345, abs=1e-3)
|
||||
assert best["unit"] == "kg"
|
||||
|
||||
|
||||
def test_decode_unknown_binary_returns_nothing():
|
||||
assert probe.decode(b"\xf0\x01\x02\x03") == []
|
||||
|
||||
|
||||
def test_probe_port_reads_simulated_scale():
|
||||
opener = SimulatorBank("toledo_cont").opener(open_real)
|
||||
result = probe.probe_port("COM90", 9600, "8N1", 1.2, opener=opener)
|
||||
assert result["ok"] is True, result
|
||||
assert result["bytes"] > 0
|
||||
assert result["frames"]
|
||||
assert any(g["protocol"] == "toledo_cont" for g in result["guesses"])
|
||||
assert result["settings"].startswith("COM90 9600 8N1")
|
||||
|
||||
|
||||
def test_probe_port_rejects_bad_framing():
|
||||
with pytest.raises(probe.ProbeError):
|
||||
probe.probe_port("COM90", 9600, "9X9", 1)
|
||||
|
||||
|
||||
def test_probe_port_sweep_reports_hits():
|
||||
opener = SimulatorBank("toledo_cont").opener(open_real)
|
||||
rows = probe.sweep("COM90", seconds=0.6, framings=("8N1",), bauds=(9600, 4800),
|
||||
opener=opener)
|
||||
hits = [row for row in rows if row.get("bytes")]
|
||||
assert hits
|
||||
assert any(g["protocol"] == "toledo_cont" for g in hits[0]["guesses"])
|
||||
|
||||
|
||||
def test_save_capture_uses_one_frame_per_line(tmp_path):
|
||||
frames = [{"hex": "01 02 03"}, {"hex": "AA BB"}]
|
||||
target = probe.save_capture(tmp_path, "bizerba bc800", frames)
|
||||
assert target.exists()
|
||||
assert target.read_text(encoding="ascii").splitlines() == ["01 02 03", "AA BB"]
|
||||
assert "bizerba-bc800" in target.name
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Legatura produs finit -> sablon de eticheta."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cantarire.repositories import ValidationError
|
||||
|
||||
|
||||
def _template_id(client, name: str) -> int:
|
||||
response = client.post("/sabloane/designer/nou", data={"name": name},
|
||||
follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import list_templates
|
||||
|
||||
return [t for t in list_templates() if t["name"] == name][0]["id"]
|
||||
|
||||
|
||||
def _full_recipe(client, recipe_id: int) -> dict:
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_recipe
|
||||
|
||||
return get_recipe(recipe_id)
|
||||
|
||||
|
||||
def _recipe_form(recipe: dict, template_id: str = "", code: str = "PF-001") -> dict:
|
||||
components = recipe.get("components") or []
|
||||
data = {
|
||||
"code": code,
|
||||
"name": recipe["name"],
|
||||
"unit": "g",
|
||||
"template_id": template_id,
|
||||
"active": "on",
|
||||
}
|
||||
for slot, item in enumerate(components, start=1):
|
||||
data[f"component_{slot}_id"] = str(item["component_id"])
|
||||
data[f"component_{slot}_target"] = "100"
|
||||
return data
|
||||
|
||||
|
||||
def _bind(client, recipe_id: int, template_id) -> None:
|
||||
"""Salveaza produsul finit cu sablonul dat (componentele raman neschimbate)."""
|
||||
full = _full_recipe(client, recipe_id)
|
||||
response = client.post(f"/retete/{recipe_id}/editare",
|
||||
data=_recipe_form(full, str(template_id or "")))
|
||||
assert response.status_code == 302, response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_recipe_form_lists_templates(client, fasole):
|
||||
page = client.get("/retete/nou")
|
||||
html = page.get_data(as_text=True)
|
||||
assert 'name="template_id"' in html
|
||||
assert "Șablon de etichetă" in html
|
||||
assert "Eticheta produsului" in html
|
||||
|
||||
|
||||
def test_recipe_can_be_bound_to_template(client, recipe):
|
||||
template_id = _template_id(client, "Eticheta PF")
|
||||
_bind(client, recipe["id"], template_id)
|
||||
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_recipe
|
||||
|
||||
stored = get_recipe(recipe["id"])
|
||||
assert stored["template_id"] == template_id
|
||||
assert stored["template_name"] == "Eticheta PF"
|
||||
|
||||
|
||||
def test_recipe_without_template_stays_empty(client, recipe):
|
||||
_bind(client, recipe["id"], "")
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_recipe
|
||||
|
||||
stored = get_recipe(recipe["id"])
|
||||
assert stored["template_id"] is None
|
||||
assert stored["template_name"] is None
|
||||
|
||||
|
||||
def test_unknown_template_is_rejected(client, recipe):
|
||||
full = _full_recipe(client, recipe["id"])
|
||||
response = client.post(f"/retete/{recipe['id']}/editare",
|
||||
data=_recipe_form(full, "9999"), follow_redirects=True)
|
||||
assert "nu exista" in response.get_data(as_text=True)
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_recipe
|
||||
|
||||
assert get_recipe(recipe["id"])["template_id"] is None
|
||||
|
||||
|
||||
def test_recipe_list_shows_template_name(client, recipe):
|
||||
template_id = _template_id(client, "Eticheta listă")
|
||||
_bind(client, recipe["id"], template_id)
|
||||
html = client.get("/retete").get_data(as_text=True)
|
||||
assert "Eticheta listă" in html
|
||||
assert "Etichetă" in html # coloana noua
|
||||
|
||||
|
||||
def test_package_uses_recipe_template(client, recipe):
|
||||
"""La pornirea unui pachet, sablonul produsului finit are prioritate."""
|
||||
bound = _template_id(client, "Eticheta legata")
|
||||
other = _template_id(client, "Eticheta implicita")
|
||||
_bind(client, recipe["id"], bound)
|
||||
client.post("/setari", data={
|
||||
"company_name": "Test SRL", "default_template_id": str(other),
|
||||
"active_scale_profile": "", "auto_print": "",
|
||||
})
|
||||
|
||||
response = client.post("/api/packages", json={
|
||||
"recipe_id": recipe["id"], "operator": "Ana", "lot": "L1",
|
||||
})
|
||||
assert response.status_code == 201, response.get_json()
|
||||
package_id = response.get_json()["package"]["id"]
|
||||
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_package
|
||||
|
||||
assert get_package(package_id)["template_id"] == bound
|
||||
|
||||
|
||||
def test_explicit_template_still_wins(client, recipe):
|
||||
bound = _template_id(client, "Eticheta legata 2")
|
||||
explicit = _template_id(client, "Eticheta explicita")
|
||||
_bind(client, recipe["id"], bound)
|
||||
|
||||
response = client.post("/api/packages", json={
|
||||
"recipe_id": recipe["id"], "template_id": explicit,
|
||||
})
|
||||
assert response.status_code == 201
|
||||
package_id = response.get_json()["package"]["id"]
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_package
|
||||
|
||||
assert get_package(package_id)["template_id"] == explicit
|
||||
|
||||
|
||||
def test_template_bound_to_active_recipe_cannot_be_deleted(client, recipe):
|
||||
template_id = _template_id(client, "Eticheta protejata")
|
||||
_bind(client, recipe["id"], template_id)
|
||||
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import delete_template
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
delete_template(template_id)
|
||||
assert "produse finite active" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_template_can_be_deleted_after_recipe_detached(client, recipe):
|
||||
template_id = _template_id(client, "Eticheta detasata")
|
||||
_bind(client, recipe["id"], template_id)
|
||||
_bind(client, recipe["id"], "")
|
||||
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import delete_template, get_template
|
||||
|
||||
delete_template(template_id)
|
||||
assert get_template(template_id) is None
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Integrare reala cu pachetul vendorizat `scale_bridge` (cântar simulat)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
VENDOR = ROOT / "vendor" / "syncpulse-scale-bridge"
|
||||
if str(VENDOR) not in sys.path:
|
||||
sys.path.insert(0, str(VENDOR))
|
||||
|
||||
from cantarire.scale import ScaleService # noqa: E402
|
||||
from scale_bridge.config import Config as BridgeConfig # noqa: E402
|
||||
|
||||
|
||||
def test_scale_service_reads_simulated_scale(tmp_path):
|
||||
bridge_config = tmp_path / "bridge.json"
|
||||
config = BridgeConfig(bridge_config)
|
||||
config.save_profile({
|
||||
"name": "sim-toledo",
|
||||
"label": "Simulator Toledo",
|
||||
"port": "COM90",
|
||||
"protocol": "toledo_cont",
|
||||
"serial": {"port": "COM90", "baudrate": 9600, "bytesize": 8,
|
||||
"parity": "N", "stopbits": 1},
|
||||
})
|
||||
|
||||
service = ScaleService(bridge_config, simulate="toledo_cont")
|
||||
try:
|
||||
assert service.available, service.error
|
||||
names = [p["name"] for p in service.profiles()]
|
||||
assert names == ["sim-toledo"]
|
||||
|
||||
result = service.read("sim-toledo", max_age_ms=3000, wait_ms=5000)
|
||||
assert result["ok"] is True, result
|
||||
assert result["value"] is not None
|
||||
assert result["unit"] == "kg"
|
||||
assert result["profile"] == "sim-toledo"
|
||||
assert result["port"] == "COM90"
|
||||
assert result["raw_hex"]
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_scale_service_without_profiles(tmp_path):
|
||||
bridge_config = tmp_path / "bridge.json"
|
||||
BridgeConfig(bridge_config)
|
||||
service = ScaleService(bridge_config)
|
||||
try:
|
||||
result = service.read("")
|
||||
assert result["ok"] is False
|
||||
assert "profil" in result["error"].lower()
|
||||
finally:
|
||||
service.close()
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Numele sabloanelor: creare cu nume propriu si redenumire."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cantarire.repositories import ValidationError
|
||||
|
||||
|
||||
def _names(client):
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import list_templates
|
||||
|
||||
return [t["name"] for t in list_templates()]
|
||||
|
||||
|
||||
def _template_by_name(client, name):
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import list_templates
|
||||
|
||||
found = [t for t in list_templates() if t["name"] == name]
|
||||
assert found, f"{name!r} nu exista; gasit: {_names(client)}"
|
||||
return found[0]
|
||||
|
||||
|
||||
def test_create_with_custom_name(client):
|
||||
response = client.post("/sabloane/designer/nou", data={"name": "Etichetă 60x40"},
|
||||
follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
template = _template_by_name(client, "Etichetă 60x40")
|
||||
assert template["design_json"]
|
||||
# mergem direct in designer, nu in lista
|
||||
assert "Trage elementele pe etichetă" in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_create_with_custom_dimensions(client):
|
||||
client.post("/sabloane/designer/nou",
|
||||
data={"name": "Mică", "width_mm": "60", "height_mm": "40"},
|
||||
follow_redirects=True)
|
||||
template = _template_by_name(client, "Mică")
|
||||
assert template["width_mm"] == 60 and template["height_mm"] == 40
|
||||
with client.application.app_context():
|
||||
from cantarire import designer
|
||||
|
||||
design = designer.load_design(template["design_json"], 60, 40)
|
||||
# elementele implicite incap in 60x40
|
||||
for block in design["blocks"]:
|
||||
assert block["x"] + block["w"] <= 60.001
|
||||
assert block["y"] + block["h"] <= 40.001
|
||||
|
||||
|
||||
def test_create_without_name_generates_unique_name(client):
|
||||
client.post("/sabloane/designer/nou", data={}, follow_redirects=True)
|
||||
client.post("/sabloane/designer/nou", data={}, follow_redirects=True)
|
||||
names = _names(client)
|
||||
assert "Sablon designer" in names
|
||||
assert "Sablon designer 2" in names
|
||||
|
||||
|
||||
def test_duplicate_name_is_rejected(client):
|
||||
client.post("/sabloane/designer/nou", data={"name": "Dublura"}, follow_redirects=True)
|
||||
response = client.post("/sabloane/designer/nou", data={"name": "Dublura"},
|
||||
follow_redirects=True)
|
||||
assert "Exista deja un sablon numit" in response.get_data(as_text=True)
|
||||
assert _names(client).count("Dublura") == 1
|
||||
|
||||
|
||||
def test_name_is_case_insensitive_unique(client):
|
||||
client.post("/sabloane/designer/nou", data={"name": "Eticheta Mare"},
|
||||
follow_redirects=True)
|
||||
response = client.post("/sabloane/designer/nou", data={"name": "eticheta mare"},
|
||||
follow_redirects=True)
|
||||
assert "Exista deja un sablon numit" in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_rename_from_designer(client):
|
||||
client.post("/sabloane/designer/nou", data={"name": "Nume vechi"},
|
||||
follow_redirects=True)
|
||||
template = _template_by_name(client, "Nume vechi")
|
||||
|
||||
response = client.post(f"/sabloane/{template['id']}/redenumire",
|
||||
data={"name": "Nume nou", "next": "designer"},
|
||||
follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
assert "a fost redenumit" in response.get_data(as_text=True)
|
||||
assert "Nume nou" in response.get_data(as_text=True)
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_template
|
||||
|
||||
stored = get_template(template["id"])
|
||||
assert stored["name"] == "Nume nou"
|
||||
assert stored["design_json"] # designul nu se pierde
|
||||
|
||||
|
||||
def test_rename_to_existing_name_is_rejected(client):
|
||||
client.post("/sabloane/designer/nou", data={"name": "Primul"}, follow_redirects=True)
|
||||
client.post("/sabloane/designer/nou", data={"name": "Al doilea"}, follow_redirects=True)
|
||||
second = _template_by_name(client, "Al doilea")
|
||||
|
||||
response = client.post(f"/sabloane/{second['id']}/redenumire",
|
||||
data={"name": "Primul"}, follow_redirects=True)
|
||||
assert "Exista deja un sablon" in response.get_data(as_text=True)
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_template
|
||||
|
||||
assert get_template(second["id"])["name"] == "Al doilea"
|
||||
|
||||
|
||||
def test_rename_to_same_name_is_noop(client):
|
||||
client.post("/sabloane/designer/nou", data={"name": "Neschimbat"},
|
||||
follow_redirects=True)
|
||||
template = _template_by_name(client, "Neschimbat")
|
||||
response = client.post(f"/sabloane/{template['id']}/redenumire",
|
||||
data={"name": "Neschimbat", "next": "designer"},
|
||||
follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
assert _names(client).count("Neschimbat") == 1
|
||||
|
||||
|
||||
def test_rename_with_empty_name_is_rejected(client):
|
||||
client.post("/sabloane/designer/nou", data={"name": "Are nume"},
|
||||
follow_redirects=True)
|
||||
template = _template_by_name(client, "Are nume")
|
||||
response = client.post(f"/sabloane/{template['id']}/redenumire",
|
||||
data={"name": " ", "next": "designer"},
|
||||
follow_redirects=True)
|
||||
assert "Numele sablonului este obligatoriu" in response.get_data(as_text=True)
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import get_template
|
||||
|
||||
assert get_template(template["id"])["name"] == "Are nume"
|
||||
|
||||
|
||||
def test_rename_unknown_template(client):
|
||||
response = client.post("/sabloane/9999/redenumire", data={"name": "X"},
|
||||
follow_redirects=True)
|
||||
assert "Sabloul nu exista" in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_list_page_has_name_input(client):
|
||||
html = client.get("/sabloane").get_data(as_text=True)
|
||||
assert 'name="name"' in html
|
||||
assert "Nume șablon nou" in html
|
||||
assert "+ Șablon nou" in html
|
||||
# modul HTML/CSS nu mai exista
|
||||
assert "HTML/CSS" not in html
|
||||
|
||||
|
||||
def test_designer_page_has_rename_form(client):
|
||||
client.post("/sabloane/designer/nou", data={"name": "Cu redenumire"},
|
||||
follow_redirects=True)
|
||||
template = _template_by_name(client, "Cu redenumire")
|
||||
html = client.get(f"/sabloane/{template['id']}/designer").get_data(as_text=True)
|
||||
assert 'class="rename-form"' in html
|
||||
assert "Redenumește" in html
|
||||
assert 'value="Cu redenumire"' in html
|
||||
assert f'action="/sabloane/{template["id"]}/redenumire"' in html
|
||||
|
||||
|
||||
def test_validation_error_still_raised_for_blank_name(client):
|
||||
"""Guard la nivel de repository, independent de ruta."""
|
||||
with client.application.app_context():
|
||||
from cantarire.repositories import create_template
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
create_template({
|
||||
"name": " ", "width_mm": 100, "height_mm": 60,
|
||||
"body_html": " ", "design_json": "{}",
|
||||
})
|
||||
Reference in New Issue
Block a user