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:
2026-10-18 23:38:45 +03:00
commit bff6d70b17
107 changed files with 21428 additions and 0 deletions
+131
View File
@@ -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]
+304
View File
@@ -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
+281
View File
@@ -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 "&lt;script&gt;" 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
+190
View File
@@ -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
+188
View File
@@ -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
+70
View File
@@ -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
+63
View File
@@ -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
+157
View File
@@ -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
+54
View File
@@ -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()
+168
View File
@@ -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": "{}",
})