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,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
|
||||
Reference in New Issue
Block a user