Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,19 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
# - curl: HEALTHCHECK
# - tini: PID 1 signal handler so uvicorn shuts down cleanly on SIGTERM
# - ca-certificates: outbound HTTPS to Snipe-IT / Grocy / Spoolman / printer web UIs
# - fonts-dejavu-core: DejaVuSans.ttf — required so ImageFont.truetype('DejaVuSans.ttf', N)
# succeeds in label_renderer.py. Without this, Pillow falls back to load_default()
# which is a fixed-size bitmap font that ignores the size argument — all templates
# render identically large text regardless of font_size. Installs to:
# /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libjpeg62-turbo \
zlib1g \
curl \
tini \
ca-certificates \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean

Expand Down
15 changes: 12 additions & 3 deletions backend/app/printer_backends/ptouch_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,17 @@ def _ptouch_print( # pragma: no cover - real-hardware-only, tests monkeypatch t
auto_cut: bool,
high_resolution: bool,
half_cut: bool = False,
last_page: bool = True,
) -> None:
"""Synchronous helper — module-level so tests can monkeypatch it.

Model-aware: uses _PTOUCH_PRINTER_CLASSES[model_id] so the same code
serves PT-P750W, PT-P900, PT-E550W, etc.

last_page maps to ptouch-py LabelPrinter.print(feed=last_page):
- feed=True (last_page=True, default) → voller Tape-Feed (~22.5mm Pre-Roll)
- feed=False (last_page=False) → minimaler Feed (~5mm) für Batch-Items

Excluded from coverage: the function only runs against the real ptouch
library against actual hardware. Every unit test in
`tests/unit/printer_backends/test_ptouch_backend.py` replaces it via
Expand All @@ -92,9 +97,10 @@ def _ptouch_print( # pragma: no cover - real-hardware-only, tests monkeypatch t
auto_cut=auto_cut,
high_resolution=high_resolution,
half_cut=half_cut,
feed=last_page,
)
except TypeError:
# Older ptouch lib doesn't support half_cut — fall back to full cut
# Older ptouch lib doesn't support half_cut/feed — fall back to full cut
printer.print(label, auto_cut=auto_cut, high_resolution=high_resolution)


Expand Down Expand Up @@ -187,7 +193,7 @@ async def print_image(
auto_cut: bool = True,
high_resolution: bool = False,
half_cut: bool = False,
last_page: bool = True, # noqa: ARG002
last_page: bool = True,
) -> None:
"""Pre-print validation via SNMP, then dispatch ptouch.print.

Expand All @@ -199,7 +205,9 @@ async def print_image(

Phase 1i C-Fix:
- half_cut: passed through to _ptouch_print (PT-Series supports it).
- last_page: accepted for Protocol compliance but unused by PT-Series.
- last_page: maps to ptouch-py feed= parameter.
last_page=True → feed=True → voller Tape-Feed (~22.5mm Pre-Roll)
last_page=False → feed=False → minimaler Feed (~5mm) zwischen Batch-Items
"""
# SNMP preflight — replaces the broken ESC i S query_status path for
# PT-Series. preflight_check raises TapeEmptyError / PrinterCoverOpenError
Expand All @@ -222,6 +230,7 @@ async def print_image(
auto_cut=auto_cut,
high_resolution=high_resolution,
half_cut=half_cut,
last_page=last_page,
)
except (ptouch.PrinterWriteError, ptouch.PrinterPermissionError) as exc:
# These are subclasses of PrinterConnectionError — must be caught first.
Expand Down
204 changes: 201 additions & 3 deletions backend/tests/unit/printer_backends/test_ptouch_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,16 @@ async def fake_preflight(*_a, **_kw):
captured: dict[str, Any] = {}

def fake_print(
host, port, image, tape_mm, *, model_id, auto_cut, high_resolution, half_cut=False
host,
port,
image,
tape_mm,
*,
model_id,
auto_cut,
high_resolution,
half_cut=False,
last_page=True,
):
captured["host"] = host
captured["port"] = port
Expand Down Expand Up @@ -410,7 +419,16 @@ async def fake_preflight(*_a, **_kw):
captured: dict[str, Any] = {}

def fake_print(
host, port, image, tape_mm, *, model_id, auto_cut, high_resolution, half_cut=False
host,
port,
image,
tape_mm,
*,
model_id,
auto_cut,
high_resolution,
half_cut=False,
last_page=True,
):
captured["half_cut"] = half_cut
captured["auto_cut"] = auto_cut
Expand Down Expand Up @@ -447,7 +465,16 @@ async def fake_preflight(*_a, **_kw):
captured: dict[str, Any] = {}

def fake_print(
host, port, image, tape_mm, *, model_id, auto_cut, high_resolution, half_cut=False
host,
port,
image,
tape_mm,
*,
model_id,
auto_cut,
high_resolution,
half_cut=False,
last_page=True,
):
captured["half_cut"] = half_cut

Expand All @@ -458,3 +485,174 @@ def fake_print(
backend = PTouchBackend(host="192.0.2.10")
await backend.print_image(img_128, tape_24)
assert captured["half_cut"] is False


# ---------------------------------------------------------------------------
# Phase 1i smoke-test live-bugs: last_page → feed propagation
# ---------------------------------------------------------------------------


async def test_print_image_passes_last_page_false_to_ptouch(
monkeypatch: pytest.MonkeyPatch,
img_128: Image.Image,
tape_24: TapeSpec,
) -> None:
"""last_page=False muss an _ptouch_print als last_page=False durchgereicht werden.

batch_dispatch berechnet use_last_page=False für alle außer dem letzten Item.
Dieser Wert muss _ptouch_print erreichen, damit feed=False → ~5mm Half-Cut statt
22.5mm Pre-Roll (ptouch-py LabelPrinter.print(feed=False)).
"""
from app.printer_backends.snmp_helper import PreflightStatus

async def fake_preflight(*_a, **_kw):
return PreflightStatus(
hr_printer_status="idle",
loaded_tape_mm=24,
error_flags=[],
)

monkeypatch.setattr(
"app.printer_backends.ptouch_backend.query_preflight",
fake_preflight,
)
captured: dict[str, Any] = {}

def fake_ptouch_print(
host,
port,
image,
tape_mm,
*,
model_id,
auto_cut,
high_resolution,
half_cut=False,
last_page=True,
):
captured["last_page"] = last_page

monkeypatch.setattr(
"app.printer_backends.ptouch_backend._ptouch_print",
fake_ptouch_print,
)
backend = PTouchBackend(host="192.0.2.10")

# Intermediate batch item — last_page=False → minimal feed
await backend.print_image(img_128, tape_24, last_page=False)
assert captured["last_page"] is False, (
"last_page=False muss an _ptouch_print durchgereicht werden "
"(feed=False in ptouch-py → ~5mm Halbschnitt statt 22.5mm Pre-Roll)"
)

# Final batch item — last_page=True → full feed
captured.clear()
await backend.print_image(img_128, tape_24, last_page=True)
assert captured["last_page"] is True


def test_ptouch_print_calls_printer_with_feed_false(monkeypatch: pytest.MonkeyPatch) -> None:
"""_ptouch_print(last_page=False) → printer.print(feed=False).

Verifiziert dass _ptouch_print das last_page Keyword korrekt als feed=last_page
an die ptouch-Lib weitergibt. Das ist der direkte Fix für den 22.5mm Pre-Roll Bug.
"""
import app.printer_backends.ptouch_backend as _mod
import ptouch as _ptouch
from app.printer_backends.ptouch_backend import _ptouch_print

captured: dict[str, Any] = {}

class FakeLabel:
pass

class FakePrinter:
def __init__(self, *_a: Any, **_kw: Any) -> None:
pass

def print(self, label: Any, **kwargs: Any) -> None:
captured.update(kwargs)

monkeypatch.setattr(
"app.printer_backends.ptouch_backend._PTOUCH_PRINTER_CLASSES",
{"PT-P750W": FakePrinter},
)

# Patch ptouch.ConnectionNetwork, ptouch.Label so no real socket is opened
fake_connection_cls = MagicMock(return_value=MagicMock())
monkeypatch.setattr(_ptouch, "ConnectionNetwork", fake_connection_cls)
monkeypatch.setattr(_ptouch, "Label", MagicMock(return_value=FakeLabel()))
# Patch tape class to something callable
monkeypatch.setattr(
_mod,
"_PTOUCH_TAPE_CLASSES",
{24: MagicMock(return_value=object())},
)

img = Image.new("1", (200, 128))
_ptouch_print(
"192.0.2.10",
9100,
img,
24,
model_id="PT-P750W",
auto_cut=True,
high_resolution=False,
half_cut=False,
last_page=False,
)
assert captured.get("feed") is False, (
f"_ptouch_print(last_page=False) muss feed=False an printer.print() weitergeben, "
f"got: {captured}"
)


def test_ptouch_print_calls_printer_with_feed_true_by_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""_ptouch_print ohne last_page (default=True) → printer.print(feed=True).

Sicherstellt dass der Default-Wert (last_page=True → feed=True → voller Feed)
für bestehende Aufrufe unverändert bleibt.
"""
import app.printer_backends.ptouch_backend as _mod
import ptouch as _ptouch
from app.printer_backends.ptouch_backend import _ptouch_print

captured: dict[str, Any] = {}

class FakePrinter:
def __init__(self, *_a: Any, **_kw: Any) -> None:
pass

def print(self, label: Any, **kwargs: Any) -> None:
captured.update(kwargs)

monkeypatch.setattr(
"app.printer_backends.ptouch_backend._PTOUCH_PRINTER_CLASSES",
{"PT-P750W": FakePrinter},
)
fake_connection_cls = MagicMock(return_value=MagicMock())
monkeypatch.setattr(_ptouch, "ConnectionNetwork", fake_connection_cls)
monkeypatch.setattr(_ptouch, "Label", MagicMock(return_value=object()))
monkeypatch.setattr(
_mod,
"_PTOUCH_TAPE_CLASSES",
{24: MagicMock(return_value=object())},
)

img = Image.new("1", (200, 128))
_ptouch_print(
"192.0.2.10",
9100,
img,
24,
model_id="PT-P750W",
auto_cut=True,
high_resolution=False,
half_cut=False,
# last_page defaults to True
)
assert captured.get("feed") is True, (
f"_ptouch_print() ohne last_page muss feed=True (Default) senden, got: {captured}"
)
42 changes: 42 additions & 0 deletions backend/tests/unit/services/test_label_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,48 @@ def counting_truetype(font: object, size: object, **kwargs: object) -> ImageFont
)


# ---------------------------------------------------------------------------
# Phase 1i smoke-test live-bug: DejaVuSans TTF verfügbar (font_size honored)
# ---------------------------------------------------------------------------


def test_label_renderer_uses_truetype_font() -> None:
"""font_size-Parameter muss tatsächlich die Textgröße skalieren (kein Bitmap-Fallback).

Root Cause: Container ohne fonts-dejavu-core → ImageFont.truetype('DejaVuSans.ttf', N)
schlägt mit OSError fehl → _load_font_cached fällt auf load_default() zurück.
Pillow's load_default() ist eine fixe-Größe Bitmap-Font die size IGNORIERT.
Fix: fonts-dejavu-core im Dockerfile installieren.

Dieser Test schlägt im Container FEHL bis Dockerfile gefixt ist.
Auf Dev-Rechnern mit installierten Fonts läuft er grün.
"""
from app.services.label_renderer import _load_font_cached
from PIL import ImageFont

# Cache leeren damit der Test sauber von Null startet
_load_font_cached.cache_clear()

font = _load_font_cached(22)
assert isinstance(font, ImageFont.FreeTypeFont), (
f"Erwartet FreeTypeFont (TTF), bekommen {type(font).__name__}. "
"Stelle sicher dass fonts-dejavu-core im Dockerfile installiert ist "
"(/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf muss existieren)."
)
Comment on lines +199 to +222

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Der Test test_label_renderer_uses_truetype_font schlägt auf Entwickler-Rechnern (z. B. macOS/Windows) fehl, wenn die Schriftart DejaVuSans.ttf nicht systemweit installiert ist. Dies beeinträchtigt den lokalen TDD-Workflow außerhalb von Docker.

Um dies zu beheben, ohne die Validierung in der CI/Docker-Umgebung zu schwächen, können wir den Test überspringen (pytest.skip), falls die Schriftart fehlt, wir uns aber nicht in einer CI- oder Container-Umgebung befinden.

Suggested change
from app.services.label_renderer import _load_font_cached
from PIL import ImageFont
# Cache leeren damit der Test sauber von Null startet
_load_font_cached.cache_clear()
font = _load_font_cached(22)
assert isinstance(font, ImageFont.FreeTypeFont), (
f"Erwartet FreeTypeFont (TTF), bekommen {type(font).__name__}. "
"Stelle sicher dass fonts-dejavu-core im Dockerfile installiert ist "
"(/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf muss existieren)."
)
from app.services.label_renderer import _load_font_cached
from PIL import ImageFont
import os
# Cache leeren damit der Test sauber von Null startet
_load_font_cached.cache_clear()
font = _load_font_cached(22)
if not isinstance(font, ImageFont.FreeTypeFont):
is_ci_or_docker = os.path.exists("/.dockerenv") or os.environ.get("CI") == "true"
if not is_ci_or_docker:
pytest.skip("DejaVuSans.ttf ist lokal nicht verfügbar (außerhalb von Docker/CI übersprungen)")
assert isinstance(font, ImageFont.FreeTypeFont), (
f"Erwartet FreeTypeFont (TTF), bekommen {type(font).__name__}. "
"Stelle sicher dass fonts-dejavu-core im Dockerfile installiert ist "
"(/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf muss existieren)."
)


# Tatsächliche Skalierung verifizieren: größere font_size → größere Glyphen
font10 = _load_font_cached(10)
font22 = _load_font_cached(22)
bbox10 = font10.getbbox("X")
bbox22 = font22.getbbox("X")
h10 = bbox10[3] - bbox10[1]
h22 = bbox22[3] - bbox22[1]
assert h22 > h10 * 1.5, (
f"Font-Skalierung defekt: h10={h10}px, h22={h22}px (Faktor {h22 / h10:.1f}x < 1.5x). "
"Bitmap-Fallback liefert immer dieselbe Höhe unabhängig von font_size."
)


class TestWhitespaceTrim:
"""Cropping the inked content to save tape material on the length axis."""

Expand Down
Loading