From 96e53c9ac0ed5b844e2d4cf5eaf8e6a209560aee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 16:26:52 +0000 Subject: [PATCH 01/21] docs(spec): Phase 1k.2 Multi-Label-Batch via ptouch.print_multi (#102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-approved design aus superpowers:brainstorming Session 2026-06-04. 4 Sections: Scope, Architektur, Komponenten+Datenmodell, Fehlerbehandlung+Tests. Atomic Failure Semantik (Option 1 — User-Entscheidung), API-Vertrag bleibt unverändert, QL out-of-scope (kein print_multi-Equivalent in brother_ql Lib). Referenziert Issues #101 (Umbrella), #102 (1k.2), #103 (1k.1 Auto-Scale Insight). Refs #102 --- .../2026-06-04-multi-label-batch-design.md | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-04-multi-label-batch-design.md diff --git a/docs/superpowers/specs/2026-06-04-multi-label-batch-design.md b/docs/superpowers/specs/2026-06-04-multi-label-batch-design.md new file mode 100644 index 0000000..713987f --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-multi-label-batch-design.md @@ -0,0 +1,282 @@ +# Phase 1k.2: Multi-Label-Batch via `ptouch.print_multi()` — Design Spec + +**Datum:** 2026-06-04 +**Issue:** [#102 Phase 1k.2 Multi-Label-Batch](https://github.com/strausmann/Label-Printer-Hub/issues/102) +**Parent:** [#101 Phase 1k Umbrella](https://github.com/strausmann/Label-Printer-Hub/issues/101) +**Status:** Approved (User-Bestätigung in Brainstorming-Session 2026-06-04) +**Naechstes Skill:** `superpowers:writing-plans` + +## Hintergrund + +Phase 1i Smoke-Test (2026-06-04) deckte einen Live-Bug auf: Multi-Label-Batches drucken 22.5mm leeres Tape zwischen jedem Label statt der 5mm Half-Cut die Brother iOS App produziert. Root cause: + +`batch_dispatch.dispatch_batch()` erstellt N separate `PrintJob`s. Jeder wird vom Queue-Worker als eigener `ptouch.LabelPrinter.print()` Call ausgeführt. Jeder Call öffnet eine eigene Connection, sendet eine eigene Init-Sequence (die ein 22.5mm Pre-Roll triggert) und schliesst danach. `feed=False` (in PR #100 nachgereicht) unterdrückt nur den End-Feed des aktuellen Calls — der nächste Call macht trotzdem volle Init. + +**Entscheidende Entdeckung:** `ptouch-py 1.1.0` hat bereits eine `LabelPrinter.print_multi(labels: list[Label], half_cut: bool=True)` Methode die mehrere Labels in EINER Connection mit korrekten 5mm Half-Cuts zwischen Items abarbeitet. Wir müssen sie nur aufrufen. + +## Ziel + +Multi-Label-Batches an PT-Series Drucker produzieren das gleiche Tape-Verhalten wie die Brother iOS App: + +- **Erstes Label:** 22.5mm Pre-Roll am Anfang (einmalig pro Batch) +- **Zwischen Labels:** ~5mm Half-Cut (taktile Trennung, kein leeres Tape) +- **Letztes Label:** voller Cut zur Trennung vom nächsten Batch + +API-Vertrag (`POST /api/print/{slug}/batch` → 202 + `batch_id` + `job_ids[]`) bleibt unverändert. Hangar braucht keine Anpassung. + +## Scope + +### In Scope + +- PT-P750W Multi-Label-Batches via `print_multi()` (PTouchBackend) +- Neue `PrinterBackend.print_images()` Protocol-Methode mit Default-Loop-Impl +- Neuer `PrintQueue.BatchJob` Typ +- Atomic Failure-Semantik (Option 1 — User-Entscheidung): bei Fehler werden alle Job-IDs gemeinsam als failed markiert +- Single-Item-Batches funktionieren weiter (`print_multi` mit 1 Element ist äquivalent zu `print`) +- Tape-Mismatch-Check (existing `print_service.py:92`) 1x am Batch-Anfang +- Tests: unit + integration + manueller Hardware-Smoke + +### Out of Scope + +- **QL-820NWB Batching** — `brother_ql` Lib hat kein `print_multi` Equivalent. QL ist Endless-Tape (kein Half-Cut zwischen Labels). `BrotherQLBackend` erbt Default-Loop-Impl der Base-Class. Status quo bleibt. +- **Layout-Engine** — separater Scope ([#103 Phase 1k.1](https://github.com/strausmann/Label-Printer-Hub/issues/103)) +- **Auto-Scale Tape-Mismatch** — kommt mit 1k.1 ([#103 Kommentar 4624051365](https://github.com/strausmann/Label-Printer-Hub/issues/103#issuecomment-4624051365)) +- **Online Template-Editor** — separater Scope ([#104 Phase 1k.3](https://github.com/strausmann/Label-Printer-Hub/issues/104)) +- **API-Migration zu batch-only-Semantik (Option 2)** — verworfen. Per-item job_ids bleiben Teil des Contracts. +- **Per-Drucker konfigurierbare Semantik (Option 3)** — verworfen. Atomic für PT, Loop für QL ist Backend-Implementation-Detail, nicht API-Verhalten. + +## Architektur + +### Render-Phase (synchron in `batch_dispatch`) + +``` +HTTP Request POST /api/print/brother-p750w/batch +{items: [V1, V2, V3, V4]} + | + v +batch.py route handler (unveraendert) +- Resolve printer by slug +- ACL-Check (auth.has_print scope) +- backend_router.get(slug) -> PTouchBackend instance + | + v +batch_dispatch.dispatch_batch(items, backend) (refactored) +- fuer jedes Item: + - TemplateLoader.get(template_id) + - LabelRenderer.render(template, data) -> Image[i] + - JobRecord(job_id=uuid(), status="queued") in DB +- collect images[] + per-item PrintOptions[] +- preflight = await backend.preflight_check() (1x am Batch-Anfang) +- check: alle items haben gleichen template.tape_mm -> sonst 400 mixed_tape_sizes +- check: preflight.loaded_tape_mm == template.tape_mm -> sonst tape_mismatch +- enqueue: PrintQueue.enqueue_batch( + BatchJob( + images=[Image1, Image2, Image3, Image4], + options=[Options1, ..., Options4], + job_ids=[j1, j2, j3, j4], + batch_id=abc, + backend_slug="brother-p750w", + ) + ) + | + v +HTTP Response 202 (unveraendert) +{batch_id: abc, job_ids: [j1, j2, j3, j4], errors: []} +``` + +### Print-Phase (asynchron im Queue-Worker) + +``` +PrintQueue worker.run() + | + v +Dequeue next item — check Union-Type: +- PrintJob (single item, existierender Pfad) -> backend.print_image(image, options) +- BatchJob (neu) -> backend.print_images(images[], options[]) + | + v +PTouchBackend.print_images(images, options) (neu) +- Convert each PIL Image -> ptouch.label.Label +- Determine collective options (half_cut=True zwischen Items, auto_cut=True am Ende) +- await asyncio.to_thread( + _ptouch_print_multi, + labels=[label1, label2, label3, label4], + model_id="PT-P750W", + half_cut=True, + high_resolution=..., + ) + | + v +_ptouch_print_multi(labels, model_id, ...) (neu, module-level fuer Test-Mocks) +- LabelPrinter = _PTOUCH_PRINTER_CLASSES[model_id] +- printer = LabelPrinter(connection) +- printer.print_multi(labels, half_cut=half_cut, high_resolution=high_resolution) + -> ptouch Lib: 1 Connection, 1 Pre-Roll, 5mm Half-Cut zwischen Labels, voller Cut am Ende + | + v +On success -> loop job_ids: JobRecord[i].status = "completed" +On failure -> loop job_ids: JobRecord[i].status = "failed", error_message gemeinsam + | + v +SSE Event Bus -> Hangar pollt /api/jobs/{j1..j4} -> bekommt finalen Status +``` + +## Komponenten-Änderungen + +### Neue Komponenten + +| Komponente | Datei | Verantwortlichkeit | +|---|---|---| +| `BatchJob` (dataclass) | `app/services/print_queue.py` | Queue-Item mit `images: list[Image]`, `options: list[PrintOptions]`, `job_ids: list[UUID]`, `batch_id: UUID`, `backend_slug: str` | +| `PrinterBackend.print_images(images, options)` | `app/printer_backends/base.py` (Protocol) | Neue Default-Methode mit Loop-Impl. Backend kann überschreiben. | +| `PTouchBackend.print_images()` | `app/printer_backends/ptouch_backend.py` | Konvertiert Images -> Labels, ruft `_ptouch_print_multi` | +| `_ptouch_print_multi(labels, model_id, ...)` | gleich | Module-level helper analog zu `_ptouch_print`. Test-Monkeypatchability. | + +### Modifizierte Komponenten + +| Komponente | Datei | Änderung | +|---|---|---| +| `batch_dispatch.dispatch_batch()` | `app/services/batch_dispatch.py` | Statt N `enqueue_job()` jetzt 1 `enqueue_batch()`. Mixed-tape-Check vor Queue. | +| `PrintQueue.enqueue_batch()` | `app/services/print_queue.py` | Neue Methode, akzeptiert `BatchJob` | +| `PrintQueue` Worker-Loop | gleich | `isinstance(item, BatchJob)` Branch der `backend.print_images()` aufruft | + +### Unveränderte Komponenten + +- API-Endpoint `POST /api/print/{slug}/batch` +- `BatchRequest` / `BatchResponse` Pydantic-Schemas +- `JobRecord` DB-Modell (1 Row pro Item bleibt) +- `PrintService.enqueue_job()` (für True-Single-Calls weiterhin genutzt, z.B. `POST /api/print/{slug}` ohne batch) +- `BrotherQLBackend` (erbt Default-Loop-Impl, QL bleibt per-item) +- Hangar-Code (Frontend, Routes, Templates) + +## Datenmodell + +### `BatchJob` + +```python +@dataclass(frozen=True) +class BatchJob: + """Queue-Item das mehrere Labels in einer Backend-Operation druckt.""" + batch_id: UUID + backend_slug: str # zur Validierung dass Backend-Router noch übereinstimmt + images: list[Image] # bereits gerenderte PIL Images + options: list[PrintOptions] # per-item (copies, auto_cut etc.) + job_ids: list[UUID] # tracking — 1 zu 1 mit images/options + tape_mm: int # einheitlich, vor Queue validiert +``` + +### `PrinterBackend` Protocol + +```python +class PrinterBackend(Protocol): + backend_id: str + half_cut_supported: bool + + async def preflight_check(self, ...) -> PreflightStatus: ... + async def print_image(self, image, tape_spec, *, ...) -> None: ... + + # NEU — Default-Impl in Base, Backend überschreibt für native batching + async def print_images( + self, + images: list[Image], + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + half_cut: bool = True, # Standard: Half-Cut zwischen Batch-Items + ) -> None: + """Print N images as a batch. Default loops over print_image.""" + for i, img in enumerate(images): + is_last = i == len(images) - 1 + await self.print_image( + img, tape_spec, + auto_cut=auto_cut, + high_resolution=high_resolution, + half_cut=half_cut and not is_last, + last_page=is_last, + ) +``` + +`PTouchBackend.print_images()` überschreibt diese Default-Impl und ruft direkt `_ptouch_print_multi`. + +`BrotherQLBackend` erbt Default und macht Status-quo-Verhalten (jedes Label einzeln). Damit ist Phase 1k.2 transparent für QL. + +## Failure Modes + +| Failure | Wo erkannt | API-Response | Job-Status | +|---|---|---|---| +| Mixed `tape_mm` im Batch | `batch_dispatch` (vor Queue) | 400 `mixed_tape_sizes` | keine Jobs erstellt | +| Template not found | `batch_dispatch` (vor Queue) | per-item `errors[i].error_code=template_not_found` | per-item `failed` | +| Tape-Mismatch (loaded != template.tape_mm) | `batch_dispatch` (Preflight) | 409 `tape_mismatch` (oder queued, je `on_tape_mismatch`) | alle Jobs als `failed`/`paused` | +| Printer offline | `preflight_check` | 503 `printer_offline` | keine Jobs erstellt | +| Hardware-Fehler mid-print_multi | `_ptouch_print_multi` Exception | — (Worker setzt) | **alle Jobs als `failed`** `batch_failed` mit gemeinsamer Message | +| Single-Item-Batch (len(items)=1) | — | normales 202 | normaler print_multi mit 1 Element | + +### Atomic Failure (Option 1) + +`ptouch.LabelPrinter.print_multi()` ist atomar. Bei Exception kennt die Lib nicht welches Item gescheitert ist: + +```python +try: + await asyncio.to_thread(_ptouch_print_multi, labels=..., ...) + for job_id in batch.job_ids: + await job_repo.mark_completed(job_id) +except PtouchError as exc: + error_msg = f"batch print_multi failed: {exc}" + for job_id in batch.job_ids: + await job_repo.mark_failed( + job_id, + error_code="batch_failed", + error_message=error_msg, + ) +``` + +**Hangar-Sicht:** Wenn ein Item-Job-Status `failed` zeigt, weiss Hangar nicht ob NUR dieses Item gescheitert ist oder der ganze Batch. Lösung: Job-Record bekommt zusätzliches Feld `batch_failure_mode: atomic | individual` damit Hangar die UI entsprechend gestalten kann ("Batch failed — 4 items affected"). + +## Test-Strategie + +| Layer | Test | +|---|---| +| `_ptouch_print_multi()` unit | Monkeypatch `LabelPrinter.print_multi`, verify labels-Array + half_cut + high_resolution forwarded korrekt | +| `PTouchBackend.print_images()` unit | Mock `_ptouch_print_multi`, verify Image-zu-Label-Konvertierung + collective options | +| `PrintQueue.BatchJob` worker | Submit BatchJob, verify worker ruft `backend.print_images()` (nicht `print_image()`), verify alle job_ids als completed/failed markiert | +| `batch_dispatch.dispatch_batch()` | Submit Batch mit gemischten tape_mm -> 400 vor Queue; mit gleichen tape_mm -> BatchJob in Queue | +| Integration | Echte `POST /api/print/.../batch` mit 4 Items, mock backend, verify atomic behavior | +| Mixed-Backend | Batch an QL-Drucker (`brother_ql`) -> `BrotherQLBackend.print_images()` Default-Loop, verify pro-item Verhalten unverändert | +| Hardware-Smoke (manuell) | Echter PT-P750W, 4 Labels in Batch, optisch verifizieren: 5mm Half-Cut zwischen, voller Cut am Ende | + +## Backward-Compatibility + +- API-Endpoints: unverändert (`POST /api/print/{slug}/batch`, `POST /api/print/{slug}` single) +- `BatchRequest`/`BatchResponse` Schemas: unverändert +- Single-Item-Batches: `print_multi` mit 1 Element funktioniert — keine Sonderfall-Logik nötig +- Mixed Batches mit existing `on_tape_mismatch=queue`: bleibt erhalten — Worker beim Dequeue prüft, ggf. pausiert Job +- QL-Backend: erbt Default-Loop-Impl in `PrinterBackend` Base-Class — identisches Verhalten wie heute +- Hangar: KEINE Änderung am Frontend-Code + +## Akzeptanzkriterien + +- [ ] PT-P750W druckt 4 Items in einem Batch mit **5mm Half-Cut zwischen Labels** +- [ ] Letztes Item bekommt vollen Cut (Trennung von nächster Batch-Session) +- [ ] API-Response unverändert (`job_ids[]` vorhanden, alle UUIDs verschieden) +- [ ] Bei Hardware-Fehler: alle Jobs des Batches als failed mit gemeinsamer `batch_failed` Error-Message +- [ ] Single-Item-Batches funktionieren weiter (print_multi mit 1 Element) +- [ ] QL-820NWB Batches funktionieren weiter (Default-Loop-Impl, kein Verhalten-Change) +- [ ] Mixed tape_mm im Batch -> 400 `mixed_tape_sizes` vor Queue, keine Jobs erstellt +- [ ] Tests: unit + integration + manueller Hardware-Smoke alle grün +- [ ] Bestehende Hangar-Integration funktioniert ohne Code-Change + +## Offene Fragen (für writing-plans) + +- Konkrete Stelle der Image-zu-Label Konvertierung — direkt in `PTouchBackend.print_images()` oder in `_ptouch_print_multi`? (Beide möglich; je nach Test-Mocking-Strategie) +- `batch_failure_mode` Field in JobRecord — DB-Migration oder JSON-Spalte erweitern? +- SSE Event-Bus: Senden wir 1 Event pro Batch (batch_completed) oder N Events (per-item)? Aktuell: per-job — beibehalten für Backward-Compat. + +## Referenzen + +- Phase 1i Smoke-Test Empirie: [docs/site/operations/protokolle/2026-06-04-phase1i-smoke-test-empirie.md](https://docs.strausmann.cloud/operations/protokolle/2026-06-04-phase1i-smoke-test-empirie/) +- Hub PR #100 (last_page → feed groundwork): [https://github.com/strausmann/Label-Printer-Hub/pull/100](https://github.com/strausmann/Label-Printer-Hub/pull/100) +- ptouch-py 1.1.0 `print_multi` Signatur: verifiziert in Container `docker exec label-printer-hub-backend python3 -c "import ptouch.printer; import inspect; print(inspect.signature(ptouch.printer.LabelPrinter.print_multi))"` +- Issue #102 (1k.2): [https://github.com/strausmann/Label-Printer-Hub/issues/102](https://github.com/strausmann/Label-Printer-Hub/issues/102) +- Issue #103 (1k.1 Auto-Scale Insight): [https://github.com/strausmann/Label-Printer-Hub/issues/103#issuecomment-4624051365](https://github.com/strausmann/Label-Printer-Hub/issues/103#issuecomment-4624051365) +- A-Diagnose PT-P750W Layout: `docs/research/2026-06-02-pt750w-layout-diagnose.md` (im Hub-Repo selbst) From 8917d0e40bfeeaf5ed83fc73bd6b397bcaaaee9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 16:39:17 +0000 Subject: [PATCH 02/21] docs(plan): Phase 1k.2 Multi-Label-Batch implementation plan (#102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12 Tasks via TDD: 1. default_print_images_loop helper 2. PrinterBackend.print_images Protocol method 3. PTouchBackend.print_images + _ptouch_print_multi 4. BrotherQLBackend.print_images (default loop) 5. MockBackend.print_images (default loop) 6. _PTPQueuePrinter adapter + bug-fix half_cut/last_page forwarding (PR #100 regression) 7. _QLQueuePrinter adapter 8. PrintQueue.BatchJob dataclass + enqueue_batch + worker isinstance dispatch 9. dispatch_batch refactor + MixedTapeSizesError 10. API route: MixedTapeSizesError -> 400 11. End-to-End integration test 12. Hardware-smoke script (manual PT-P750W verification) Bug-Fund waehrend Plan-Writing: _PTPQueuePrinter droppt half_cut + last_page beim Forward zum Backend — PR #100 fix erreichte ptouch lib nie. Task 6 fixt das mit. Refs #102 --- .../2026-06-04-multi-label-batch-plan.md | 2252 +++++++++++++++++ 1 file changed, 2252 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md diff --git a/docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md b/docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md new file mode 100644 index 0000000..f92ce7e --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md @@ -0,0 +1,2252 @@ +# Multi-Label-Batch via ptouch.print_multi Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Multi-Label-Batches an PT-Series Drucker produzieren 5mm Half-Cut zwischen Labels (Brother iOS App Verhalten) statt 22.5mm Pre-Roll pro Item. + +**Architecture:** Neue `PrinterBackend.print_images()` Methode mit Default-Loop-Impl. `PTouchBackend` überschreibt mit `_ptouch_print_multi()` → `ptouch.LabelPrinter.print_multi()`. Queue bekommt neuen `BatchJob` Typ, Worker dispatched per `isinstance`. Adapter (`_PTPQueuePrinter`/`_QLQueuePrinter`) bekommen `print_images()` Methode. `batch_dispatch` queued einmalig statt N-mal. + +**Tech Stack:** Python 3.12, FastAPI, Pydantic v2, PIL/Pillow, ptouch-py 1.1.0, brother_ql 0.9.4, asyncio, pytest. + +--- + +## File Structure + +| File | Verantwortlichkeit | Änderungs-Art | +|---|---|---| +| `backend/app/printer_backends/base.py` | `PrinterBackend` Protocol erweitern um `print_images()` | MODIFY | +| `backend/app/printer_backends/batch_helper.py` | `default_print_images_loop()` Helper | CREATE | +| `backend/app/printer_backends/ptouch_backend.py` | `print_images()` Override + `_ptouch_print_multi()` Helper | MODIFY | +| `backend/app/printer_backends/brother_ql_backend.py` | `print_images()` Methode (Default-Loop) | MODIFY | +| `backend/app/printer_backends/mock_backend.py` | `print_images()` Methode (Default-Loop) | MODIFY | +| `backend/app/printer_models/pt.py` | `_PTPQueuePrinter.print_images()` Adapter | MODIFY | +| `backend/app/printer_models/ql.py` | `_QLQueuePrinter.print_images()` Adapter | MODIFY | +| `backend/app/services/print_queue.py` | `BatchJob` dataclass, `_PrinterLike.print_images()`, `enqueue_batch()`, Worker isinstance branch | MODIFY | +| `backend/app/services/batch_dispatch.py` | `dispatch_batch()` Refactor zu single `enqueue_batch()` Call | MODIFY | +| `backend/app/services/print_service.py` | Neue `submit_batch_job()` Methode | MODIFY | +| `backend/app/schemas/print_batch.py` | (keine Änderung — BatchRequest bleibt) | UNCHANGED | +| `backend/tests/unit/printer_backends/test_batch_helper.py` | Tests für default_print_images_loop | CREATE | +| `backend/tests/unit/printer_backends/test_ptouch_backend.py` | Tests für print_images + _ptouch_print_multi | MODIFY (append) | +| `backend/tests/unit/printer_backends/test_brother_ql_backend.py` | Test für default-loop | MODIFY (append) | +| `backend/tests/unit/services/test_print_queue_batch.py` | Tests für BatchJob, enqueue_batch, worker | CREATE | +| `backend/tests/unit/services/test_batch_dispatch.py` | Refactor existing tests + new mixed-tape test | MODIFY | +| `backend/tests/integration/test_batch_endpoint_multi_label.py` | End-to-end Test mit Mock-Backend | CREATE | + +--- + +## Pre-flight + +- [ ] **Step 0: Verify spec branch + baseline tests** + +```bash +cd /opt/repos/label-printer-hub +git checkout spec/phase-1k.2-multi-label-batch +git log --oneline -1 +# Expected: 96e53c9 docs(spec): Phase 1k.2 Multi-Label-Batch via ptouch.print_multi (#102) +backend/.venv/bin/python -m pytest backend/tests -q 2>&1 | tail -3 +# Expected: 952 passed, 5 skipped +``` + +If branch missing OR baseline fails, STOP and report. + +--- + +## Task 1: Helper-Funktion `default_print_images_loop` + +**Files:** +- Create: `backend/app/printer_backends/batch_helper.py` +- Test: `backend/tests/unit/printer_backends/test_batch_helper.py` + +**Rationale:** Backends ohne native batch-support (`BrotherQLBackend`, `MockBackend`) loopen per-item. Statt Code-Duplikation: zentraler Helper den jeder Backend aufruft. + +- [ ] **Step 1: Write the failing test** + +`backend/tests/unit/printer_backends/test_batch_helper.py`: + +```python +"""Unit tests for default_print_images_loop helper.""" +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from PIL import Image + +from app.models.tape import TapeSpec +from app.printer_backends.batch_helper import default_print_images_loop + + +@pytest.fixture +def tape_spec_12() -> TapeSpec: + return TapeSpec(width_mm=12, printable_dots=70, media_type_name="laminated") + + +@pytest.fixture +def three_images() -> list[Image.Image]: + return [Image.new("1", (600, 70), color=1) for _ in range(3)] + + +@pytest.mark.anyio +async def test_loops_print_image_for_each(three_images, tape_spec_12): + """default_print_images_loop calls print_image once per image.""" + backend = AsyncMock() + backend.print_image = AsyncMock() + + await default_print_images_loop( + backend, three_images, tape_spec_12, + auto_cut=True, high_resolution=False, half_cut=True, + ) + + assert backend.print_image.call_count == 3 + + +@pytest.mark.anyio +async def test_intermediate_items_get_half_cut_true_last_page_false( + three_images, tape_spec_12 +): + """Items 0 and 1 (non-last): half_cut=True, last_page=False.""" + backend = AsyncMock() + backend.print_image = AsyncMock() + + await default_print_images_loop( + backend, three_images, tape_spec_12, + auto_cut=True, high_resolution=False, half_cut=True, + ) + + # Inspect kwargs of calls + calls = backend.print_image.call_args_list + assert calls[0].kwargs["half_cut"] is True + assert calls[0].kwargs["last_page"] is False + assert calls[1].kwargs["half_cut"] is True + assert calls[1].kwargs["last_page"] is False + + +@pytest.mark.anyio +async def test_last_item_gets_half_cut_false_last_page_true(three_images, tape_spec_12): + """Last item: half_cut=False (full cut), last_page=True.""" + backend = AsyncMock() + backend.print_image = AsyncMock() + + await default_print_images_loop( + backend, three_images, tape_spec_12, + auto_cut=True, high_resolution=False, half_cut=True, + ) + + calls = backend.print_image.call_args_list + assert calls[-1].kwargs["half_cut"] is False + assert calls[-1].kwargs["last_page"] is True + + +@pytest.mark.anyio +async def test_half_cut_false_disables_half_cut_globally(three_images, tape_spec_12): + """If caller passes half_cut=False, no intermediate item gets half_cut=True.""" + backend = AsyncMock() + backend.print_image = AsyncMock() + + await default_print_images_loop( + backend, three_images, tape_spec_12, + auto_cut=True, high_resolution=False, half_cut=False, + ) + + for call in backend.print_image.call_args_list: + assert call.kwargs["half_cut"] is False + + +@pytest.mark.anyio +async def test_single_image_gets_last_page_true(tape_spec_12): + """Single-item batch: 1 print_image call with last_page=True.""" + backend = AsyncMock() + backend.print_image = AsyncMock() + one_image = [Image.new("1", (600, 70), color=1)] + + await default_print_images_loop( + backend, one_image, tape_spec_12, + auto_cut=True, high_resolution=False, half_cut=True, + ) + + assert backend.print_image.call_count == 1 + assert backend.print_image.call_args.kwargs["last_page"] is True + assert backend.print_image.call_args.kwargs["half_cut"] is False + + +@pytest.mark.anyio +async def test_propagates_first_print_image_exception(three_images, tape_spec_12): + """If print_image raises on item N, no further items are attempted.""" + backend = AsyncMock() + backend.print_image = AsyncMock( + side_effect=[None, RuntimeError("printer offline"), None] + ) + + with pytest.raises(RuntimeError, match="printer offline"): + await default_print_images_loop( + backend, three_images, tape_spec_12, + auto_cut=True, high_resolution=False, half_cut=True, + ) + + # Only the first two were attempted; third was not. + assert backend.print_image.call_count == 2 +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd /opt/repos/label-printer-hub +backend/.venv/bin/python -m pytest backend/tests/unit/printer_backends/test_batch_helper.py -v 2>&1 | tail -10 +``` + +Expected: ModuleNotFoundError for `app.printer_backends.batch_helper`. + +- [ ] **Step 3: Write minimal implementation** + +`backend/app/printer_backends/batch_helper.py`: + +```python +"""Default batch-print loop for Backends without native batch support. + +Phase 1k.2: PTouchBackend overrides print_images() to use ptouch.print_multi() +for true atomic batch printing. BrotherQLBackend, MockBackend etc. delegate +their print_images() implementation to default_print_images_loop() here — +they loop over print_image() with correct half_cut + last_page semantics. + +Semantics match the Brother iOS App: half_cut=True between intermediate +items (5mm taktile Trennung), half_cut=False + last_page=True on the final +item (voller Cut zur Trennung vom nächsten Batch). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PIL import Image + + from app.models.tape import TapeSpec + from app.printer_backends.base import PrinterBackend + + +async def default_print_images_loop( + backend: PrinterBackend, + images: list[Image.Image], + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + half_cut: bool = True, +) -> None: + """Loop over print_image(); set half_cut + last_page per index. + + Args: + backend: PrinterBackend instance whose print_image() is called per item. + images: Rendered PIL Images, one per batch item, in print order. + tape_spec: Shared TapeSpec — all items in a batch share the loaded tape. + auto_cut: Forwarded unchanged to each print_image call. + high_resolution: Forwarded unchanged to each print_image call. + half_cut: If True, intermediate items get half_cut=True (5mm taktile + separation). Last item always gets half_cut=False so the cutter + performs a full cut for batch separation. + + Behaviour: + For each image at index i: + - is_last = (i == len(images) - 1) + - last_page = is_last (drives ptouch feed= → controls Pre-Roll) + - half_cut = half_cut and not is_last + """ + last_index = len(images) - 1 + for i, image in enumerate(images): + is_last = i == last_index + await backend.print_image( + image, + tape_spec, + auto_cut=auto_cut, + high_resolution=high_resolution, + half_cut=half_cut and not is_last, + last_page=is_last, + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +backend/.venv/bin/python -m pytest backend/tests/unit/printer_backends/test_batch_helper.py -v 2>&1 | tail -15 +``` + +Expected: 6 passed. + +- [ ] **Step 5: Lint + mypy + commit** + +```bash +backend/.venv/bin/ruff check backend/app/printer_backends/batch_helper.py backend/tests/unit/printer_backends/test_batch_helper.py +backend/.venv/bin/ruff format --check backend/app/printer_backends/batch_helper.py backend/tests/unit/printer_backends/test_batch_helper.py +backend/.venv/bin/mypy backend/app/printer_backends/batch_helper.py 2>&1 | tail -3 + +git add backend/app/printer_backends/batch_helper.py backend/tests/unit/printer_backends/test_batch_helper.py +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(printer_backends): default_print_images_loop helper (Phase 1k.2 Task 1) + +Backends ohne native batch-support (BrotherQLBackend, MockBackend) loopen +per-item — Helper zentralisiert die half_cut + last_page Semantik analog +Brother iOS App. + +Refs #102" +``` + +--- + +## Task 2: `PrinterBackend.print_images` Protocol-Methode + +**Files:** +- Modify: `backend/app/printer_backends/base.py` +- Test: (Protocol changes verified via Backend impls in Tasks 3, 4, 5) + +**Rationale:** Erweitert das Backend-Contract um die batch-Methode. Bestehende `print_image()` bleibt unverändert. Backends die nicht überschreiben fallen in Task 3-5 explizit auf `default_print_images_loop`. + +- [ ] **Step 1: Modify `backend/app/printer_backends/base.py`** + +Append after the existing `print_image` declaration: + +```python + async def print_images( + self, + images: list[Image.Image], + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + half_cut: bool = True, + ) -> None: + """Batch-print N images. Atomic semantics: success or all-fail. + + Phase 1k.2: Default-Loop ueber print_image() lebt in + ``app.printer_backends.batch_helper.default_print_images_loop``. + PTouchBackend ueberschreibt fuer ptouch.print_multi() (echtes + batch-fertig mit 5mm Half-Cut zwischen Labels statt 22.5mm Pre-Roll). + BrotherQLBackend und MockBackend delegieren explizit an den + default_print_images_loop helper. + + Args: + images: PIL Images in print order. len(images) >= 1. + tape_spec: Shared TapeSpec — alle Items teilen das geladene Tape. + auto_cut: True = Drucker schneidet am Ende des Batches. + high_resolution: PT-Series HiRes-Mode. + half_cut: True = 5mm taktile Separation zwischen Items (PT-Series). + Letztes Item bekommt immer Voll-Cut (half_cut=False intern). + """ +``` + +Full updated file content of `backend/app/printer_backends/base.py`: + +```python +"""PrinterBackend Protocol — transport contract used by drivers. + +Two-method surface (print_image + query_status). A raw `send_bytes` escape +hatch was deliberately removed during design: there is no concrete caller +in First-Print, and opening a second TCP/9100 session in parallel with +ptouch would hit Brother's single-session limit (Resource Busy). The +hook can be added back additively if a future caller needs it. + +Phase 1k.2: print_images() added for batch printing via ptouch.print_multi +on PT-Series. Other backends delegate to default_print_images_loop helper. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from PIL import Image + +from app.models.tape import TapeSpec +from app.services.status_block import StatusBlock + + +@runtime_checkable +class PrinterBackend(Protocol): + """Transport + encoding contract for a single bound printer.""" + + backend_id: str + host: str + # Phase 1i C-Fix: PT-Series=True, QL-Series=False + half_cut_supported: bool + + async def print_image( + self, + image: Image.Image, + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + half_cut: bool = False, + last_page: bool = True, + ) -> None: + """Encode and send `image`. Raises a PrinterError subtype on failure. + + Phase 1i C-Fix: + - half_cut: True bedeutet "tape + liner halb getrennt" (taktile Separation, + nur PT-Series). Bei half_cut_supported=False vom Backend ignoriert. + - last_page: True = letztes Item einer Batch (Voll-Cut), False = es folgt + mindestens ein weiteres Item (kein Cut zwischen). + """ + + async def print_images( + self, + images: list[Image.Image], + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + half_cut: bool = True, + ) -> None: + """Batch-print N images. Atomic semantics: success or all-fail. + + Phase 1k.2: Default-Loop ueber print_image() lebt in + ``app.printer_backends.batch_helper.default_print_images_loop``. + PTouchBackend ueberschreibt fuer ptouch.print_multi() (echtes + batch-fertig mit 5mm Half-Cut zwischen Labels statt 22.5mm Pre-Roll). + BrotherQLBackend und MockBackend delegieren explizit an den + default_print_images_loop helper. + + Args: + images: PIL Images in print order. len(images) >= 1. + tape_spec: Shared TapeSpec — alle Items teilen das geladene Tape. + auto_cut: True = Drucker schneidet am Ende des Batches. + high_resolution: PT-Series HiRes-Mode. + half_cut: True = 5mm taktile Separation zwischen Items (PT-Series). + Letztes Item bekommt immer Voll-Cut (half_cut=False intern). + """ + + async def query_status(self) -> StatusBlock: + """Send ESC i S, parse the 32-byte reply, return a StatusBlock.""" +``` + +- [ ] **Step 2: Verify mypy + ruff** + +```bash +cd /opt/repos/label-printer-hub +backend/.venv/bin/mypy backend/app/printer_backends/base.py 2>&1 | tail -3 +backend/.venv/bin/ruff check backend/app/printer_backends/base.py +``` + +Expected: clean. + +Tests will fail at this point — they expect Backend implementations to have `print_images`. That's Tasks 3-5. + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/printer_backends/base.py +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(printer_backends): PrinterBackend.print_images Protocol method (Phase 1k.2 Task 2) + +Backend-Contract erweitert um batch-print Method. PTouchBackend ueberschreibt +fuer ptouch.print_multi (Task 3), andere Backends delegieren an +default_print_images_loop helper (Tasks 4, 5). + +Refs #102" +``` + +--- + +## Task 3: `PTouchBackend.print_images` mit `_ptouch_print_multi` + +**Files:** +- Modify: `backend/app/printer_backends/ptouch_backend.py` +- Test: `backend/tests/unit/printer_backends/test_ptouch_backend.py` (append) + +**Rationale:** Echter Half-Cut-Fix. `print_multi()` macht 1 Connection, 1 Pre-Roll, 5mm Half-Cuts zwischen Labels. + +- [ ] **Step 1: Write the failing test** + +Append to `backend/tests/unit/printer_backends/test_ptouch_backend.py`: + +```python +# --------------------------------------------------------------------------- +# Phase 1k.2: print_images batch via ptouch.print_multi +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_print_images_calls_ptouch_print_multi(monkeypatch): + """PTouchBackend.print_images → _ptouch_print_multi with all labels.""" + from app.models.tape import TapeSpec + from app.printer_backends.ptouch_backend import PTouchBackend + from app.printer_backends.snmp_helper import PreflightStatus + + captured: dict[str, object] = {} + + async def fake_preflight(self, **kw): + return PreflightStatus( + hr_printer_status="idle", loaded_tape_mm=12, error_flags=[] + ) + + def fake_ptouch_print_multi(host, port, images, tape_mm, **kwargs): + captured["host"] = host + captured["port"] = port + captured["num_images"] = len(images) + captured["tape_mm"] = tape_mm + captured.update(kwargs) + + monkeypatch.setattr(PTouchBackend, "preflight_check", fake_preflight) + monkeypatch.setattr( + "app.printer_backends.ptouch_backend._ptouch_print_multi", + fake_ptouch_print_multi, + ) + + backend = PTouchBackend(host="192.0.2.10", model_id="PT-P750W") + tape_spec = TapeSpec(width_mm=12, printable_dots=70, media_type_name="laminated") + images = [Image.new("1", (600, 70), color=1) for _ in range(3)] + + await backend.print_images( + images, tape_spec, + auto_cut=True, high_resolution=False, half_cut=True, + ) + + assert captured["host"] == "192.0.2.10" + assert captured["port"] == 9100 + assert captured["num_images"] == 3 + assert captured["tape_mm"] == 12 + assert captured["model_id"] == "PT-P750W" + assert captured["auto_cut"] is True + assert captured["half_cut"] is True + assert captured["high_resolution"] is False + + +@pytest.mark.anyio +async def test_print_images_raises_tape_mismatch_at_batch_start(monkeypatch): + """If preflight tape != tape_spec.width_mm, batch fails atomically before print.""" + from app.models.tape import TapeSpec + from app.printer_backends.exceptions import TapeMismatchError + from app.printer_backends.ptouch_backend import PTouchBackend + from app.printer_backends.snmp_helper import PreflightStatus + + async def fake_preflight(self, **kw): + return PreflightStatus( + hr_printer_status="idle", loaded_tape_mm=18, error_flags=[] + ) + + monkeypatch.setattr(PTouchBackend, "preflight_check", fake_preflight) + # _ptouch_print_multi must NOT be called + called = False + def fake_pm(*a, **kw): + nonlocal called + called = True + monkeypatch.setattr( + "app.printer_backends.ptouch_backend._ptouch_print_multi", fake_pm + ) + + backend = PTouchBackend(host="192.0.2.10", model_id="PT-P750W") + tape_spec = TapeSpec(width_mm=12, printable_dots=70, media_type_name="laminated") + images = [Image.new("1", (600, 70), color=1) for _ in range(2)] + + with pytest.raises(TapeMismatchError): + await backend.print_images(images, tape_spec, half_cut=True) + assert called is False + + +def test_ptouch_print_multi_passes_labels_array(monkeypatch): + """_ptouch_print_multi constructs Labels[] and calls LabelPrinter.print_multi.""" + from app.printer_backends.ptouch_backend import _ptouch_print_multi + + captured: dict[str, object] = {} + + class FakeLabelPrinter: + def __init__(self, *a, **kw): pass + def print_multi(self, labels, **kwargs): + captured["num_labels"] = len(labels) + captured.update(kwargs) + + monkeypatch.setitem( + __import__("app.printer_backends.ptouch_backend", fromlist=["_PTOUCH_PRINTER_CLASSES"])._PTOUCH_PRINTER_CLASSES, + "PT-P750W", FakeLabelPrinter, + ) + + images = [Image.new("1", (600, 70), color=1) for _ in range(4)] + _ptouch_print_multi( + host="192.0.2.10", port=9100, images=images, tape_mm=12, + model_id="PT-P750W", auto_cut=True, high_resolution=False, half_cut=True, + ) + + assert captured["num_labels"] == 4 + assert captured["half_cut"] is True + assert captured["high_resolution"] is False +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +backend/.venv/bin/python -m pytest backend/tests/unit/printer_backends/test_ptouch_backend.py -k "print_images or print_multi" -v 2>&1 | tail -15 +``` + +Expected: ImportError on `_ptouch_print_multi` OR AttributeError on `PTouchBackend.print_images`. + +- [ ] **Step 3: Implement `_ptouch_print_multi` helper + `PTouchBackend.print_images`** + +In `backend/app/printer_backends/ptouch_backend.py`: + +After `_ptouch_print` function (line ~108), add: + +```python +def _ptouch_print_multi( # pragma: no cover - real-hardware-only, tests monkeypatch this + host: str, + port: int, + images: list[Image.Image], + tape_mm: int, + *, + model_id: str, + auto_cut: bool, + high_resolution: bool, + half_cut: bool, +) -> None: + """Synchronous helper for batch printing via ptouch.LabelPrinter.print_multi. + + ptouch-py 1.1.0: LabelPrinter.print_multi(labels, margin_mm=None, + high_resolution=None, half_cut=True) — 1 Connection, 1 Init (=1 Pre-Roll), + 5mm Half-Cut zwischen Labels, voller Cut am Ende. + + Excluded from coverage: real-hardware-only. Tests monkeypatch this module-level + function. Hardware verification per scripts/smoke_first_print_batch.py (Task 11). + """ + try: + tape_cls = _PTOUCH_TAPE_CLASSES[tape_mm] + except KeyError as exc: + raise PrintFailedError(f"No ptouch tape class for {tape_mm}mm") from exc + try: + printer_cls = _PTOUCH_PRINTER_CLASSES[model_id] + except KeyError as exc: + raise PrintFailedError(f"No ptouch printer class for model {model_id!r}") from exc + + connection = ptouch.ConnectionNetwork(host, port=port, timeout=10.0) + printer = printer_cls(connection=connection, high_resolution=high_resolution) + labels = [ptouch.Label(image=img, tape=tape_cls) for img in images] + try: + printer.print_multi( + labels, + high_resolution=high_resolution, + half_cut=half_cut, + ) + except TypeError: + # Älterer ptouch-Lib (<1.1) hat kein print_multi — Fallback: per-Item-Loop + # mit print(). Degraded zu Phase-1i-Verhalten (22.5mm Pre-Roll pro Item). + for i, label in enumerate(labels): + is_last = i == len(labels) - 1 + try: + printer.print( + label, + auto_cut=auto_cut, + high_resolution=high_resolution, + half_cut=half_cut and not is_last, + feed=is_last, + ) + except TypeError: + printer.print(label, auto_cut=auto_cut, high_resolution=high_resolution) +``` + +In `PTouchBackend` class, add after the existing `print_image` method (line ~248): + +```python + async def print_images( + self, + images: list[Image.Image], + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + half_cut: bool = True, + ) -> None: + """Batch-print via ptouch.LabelPrinter.print_multi — atomic. + + Pre-Print: SNMP preflight 1x am Batch-Anfang. Bei Tape-Mismatch wird + TapeMismatchError sofort geworfen, kein print_multi-Call. + + Phase 1k.2: ersetzt N separate ptouch.print() Calls durch 1 print_multi. + Resultat: 5mm Half-Cut zwischen Labels statt 22.5mm Pre-Roll. + """ + if not images: + raise ValueError("print_images requires at least one image") + + preflight = await self.preflight_check() + if preflight.loaded_tape_mm != tape_spec.width_mm: + raise TapeMismatchError( + expected_mm=tape_spec.width_mm, + loaded_mm=preflight.loaded_tape_mm, + ) + + try: + await asyncio.to_thread( + _ptouch_print_multi, + self.host, + self._port, + images, + tape_spec.width_mm, + model_id=self._model_id, + auto_cut=auto_cut, + high_resolution=high_resolution, + half_cut=half_cut, + ) + except (ptouch.PrinterWriteError, ptouch.PrinterPermissionError) as exc: + raise PrintFailedError(str(exc)) from exc + except ( + ptouch.PrinterNetworkError, + ptouch.PrinterTimeoutError, + ptouch.PrinterNotFoundError, + ptouch.PrinterConnectionError, + ) as exc: + raise PrinterOfflineError(str(exc)) from exc +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +backend/.venv/bin/python -m pytest backend/tests/unit/printer_backends/test_ptouch_backend.py -k "print_images or print_multi" -v 2>&1 | tail -10 +``` + +Expected: 3 passed. + +- [ ] **Step 5: Full test-suite sanity check** + +```bash +backend/.venv/bin/python -m pytest backend/tests/unit/printer_backends/ -q 2>&1 | tail -3 +``` + +Expected: keine Regressionen (existing tests still green). + +- [ ] **Step 6: Lint + commit** + +```bash +backend/.venv/bin/ruff check backend/app/printer_backends/ptouch_backend.py backend/tests/unit/printer_backends/test_ptouch_backend.py +backend/.venv/bin/ruff format --check backend/app/printer_backends/ptouch_backend.py backend/tests/unit/printer_backends/test_ptouch_backend.py +backend/.venv/bin/mypy backend/app/printer_backends/ptouch_backend.py 2>&1 | tail -3 + +git add backend/app/printer_backends/ptouch_backend.py backend/tests/unit/printer_backends/test_ptouch_backend.py +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(ptouch): print_images via ptouch.print_multi (Phase 1k.2 Task 3) + +PTouchBackend.print_images() ueberschreibt Protocol-Default mit echtem +batch-printing via ptouch.LabelPrinter.print_multi: 1 Connection, 1 Pre-Roll, +5mm Half-Cut zwischen Labels statt 22.5mm zwischen jedem. + +TypeError-Fallback: Aelterer ptouch-Lib ohne print_multi degradet zu +per-Item-Loop (Pre-Phase-1k.2 Verhalten). + +Refs #102" +``` + +--- + +## Task 4: `BrotherQLBackend.print_images` Default-Loop + +**Files:** +- Modify: `backend/app/printer_backends/brother_ql_backend.py` +- Test: `backend/tests/unit/printer_backends/test_brother_ql_backend.py` (append) + +**Rationale:** brother_ql Lib hat kein `print_multi`-Equivalent. QL ist Endless-Tape (kein Half-Cut-Konzept zwischen Labels). `BrotherQLBackend` delegiert explizit an `default_print_images_loop`. + +- [ ] **Step 1: Write the failing test** + +Append to `backend/tests/unit/printer_backends/test_brother_ql_backend.py`: + +```python +# --------------------------------------------------------------------------- +# Phase 1k.2: print_images delegates to default_print_images_loop +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_print_images_delegates_to_loop_helper(monkeypatch): + """BrotherQLBackend.print_images calls default_print_images_loop.""" + from app.models.tape import TapeSpec + from app.printer_backends.brother_ql_backend import BrotherQLBackend + + captured: dict[str, object] = {} + + async def fake_loop(backend, images, tape_spec, **kwargs): + captured["backend"] = backend + captured["num_images"] = len(images) + captured["tape_mm"] = tape_spec.width_mm + captured.update(kwargs) + + monkeypatch.setattr( + "app.printer_backends.brother_ql_backend.default_print_images_loop", + fake_loop, + ) + + backend = BrotherQLBackend(host="192.0.2.11", model_id="QL-820NWB") + tape_spec = TapeSpec(width_mm=62, printable_dots=696, media_type_name="endless_dk") + images = [Image.new("1", (696, 200), color=1) for _ in range(3)] + + await backend.print_images( + images, tape_spec, + auto_cut=True, high_resolution=False, half_cut=False, + ) + + assert captured["backend"] is backend + assert captured["num_images"] == 3 + assert captured["tape_mm"] == 62 + assert captured["auto_cut"] is True + assert captured["half_cut"] is False # QL erzwingt half_cut=False +``` + +(Note: `Image` is imported at the top of the existing test file from earlier tasks; verify the import line exists.) + +- [ ] **Step 2: Run test to verify it fails** + +```bash +backend/.venv/bin/python -m pytest backend/tests/unit/printer_backends/test_brother_ql_backend.py::test_print_images_delegates_to_loop_helper -v 2>&1 | tail -8 +``` + +Expected: AttributeError — `print_images` not implemented. + +- [ ] **Step 3: Implement** + +In `backend/app/printer_backends/brother_ql_backend.py`: + +Add import at top: +```python +from app.printer_backends.batch_helper import default_print_images_loop +``` + +Add method to `BrotherQLBackend` class (after `print_image`): + +```python + async def print_images( + self, + images: list[Image.Image], + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + half_cut: bool = True, + ) -> None: + """Batch-print N images via per-item Loop. + + brother_ql Lib hat kein print_multi-Equivalent — QL ist Endless-Tape, + kein Half-Cut-Konzept zwischen Labels (jedes Label wird vom Druckkopf + ausgegeben und manuell abgeschnitten). Delegiert an + default_print_images_loop mit half_cut=False (QL ignoriert das Argument). + + Phase 1k.2: aus Sicht der API identisches Verhalten wie zuvor — + BatchJob-Pfad existiert um eine konsistente print_images-Signatur + ueber alle Backends zu erfuellen. + """ + # QL-Series: half_cut existiert nicht. Backend-Capability-Flag erzwingt False. + await default_print_images_loop( + self, + images, + tape_spec, + auto_cut=auto_cut, + high_resolution=high_resolution, + half_cut=False, + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +backend/.venv/bin/python -m pytest backend/tests/unit/printer_backends/test_brother_ql_backend.py -q 2>&1 | tail -5 +``` + +Expected: alle tests grün (existing + neuer). + +- [ ] **Step 5: Lint + commit** + +```bash +backend/.venv/bin/ruff check backend/app/printer_backends/brother_ql_backend.py backend/tests/unit/printer_backends/test_brother_ql_backend.py +backend/.venv/bin/ruff format --check backend/app/printer_backends/brother_ql_backend.py backend/tests/unit/printer_backends/test_brother_ql_backend.py + +git add backend/app/printer_backends/brother_ql_backend.py backend/tests/unit/printer_backends/test_brother_ql_backend.py +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(brother_ql): print_images via default_print_images_loop (Phase 1k.2 Task 4) + +QL-Series ist Endless-Tape ohne Half-Cut-Konzept. BrotherQLBackend.print_images +delegiert an default_print_images_loop mit half_cut=False (capability-flag erzwingt). +Phase 1k.2 Architektur-Konsistenz: alle Backends bieten print_images. + +Refs #102" +``` + +--- + +## Task 5: `MockBackend.print_images` Default-Loop + +**Files:** +- Modify: `backend/app/printer_backends/mock_backend.py` + +**Rationale:** Mock-Backend braucht print_images für Tests die print_images()-Pfad triggern. + +- [ ] **Step 1: Read existing MockBackend code** + +```bash +sed -n '1,50p' backend/app/printer_backends/mock_backend.py +``` + +Identify the existing print_image signature und Class-Definition. Add print_images analog. + +- [ ] **Step 2: Add print_images method to MockBackend** + +In `backend/app/printer_backends/mock_backend.py`: + +Add import: +```python +from app.printer_backends.batch_helper import default_print_images_loop +``` + +Add to `MockBackend` class (after `print_image`): + +```python + async def print_images( + self, + images: list[Image.Image], + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + half_cut: bool = True, + ) -> None: + """Test-friendly Mock-Implementation via default_print_images_loop. + + Records each print_image call into self.printed_images for assertions. + """ + await default_print_images_loop( + self, + images, + tape_spec, + auto_cut=auto_cut, + high_resolution=high_resolution, + half_cut=half_cut, + ) +``` + +- [ ] **Step 3: Run all backend tests to verify no regression** + +```bash +backend/.venv/bin/python -m pytest backend/tests/unit/printer_backends/ -q 2>&1 | tail -3 +``` + +Expected: all green. + +- [ ] **Step 4: Lint + commit** + +```bash +backend/.venv/bin/ruff check backend/app/printer_backends/mock_backend.py +backend/.venv/bin/ruff format --check backend/app/printer_backends/mock_backend.py + +git add backend/app/printer_backends/mock_backend.py +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(mock): MockBackend.print_images via default_print_images_loop (Phase 1k.2 Task 5) + +Refs #102" +``` + +--- + +## Task 6: `_PTPQueuePrinter.print_images` Adapter + Bug-Fix half_cut/last_page forwarding + +**Files:** +- Modify: `backend/app/printer_models/pt.py` + +**Rationale:** Queue-Adapter hat aktuell **Bug**: forwarded weder `half_cut` noch `last_page` zum Backend (Zeile 256-264). PR #100 erreichte die Lib nicht. Wir fixen das gleich mit + neue print_images() Methode. + +- [ ] **Step 1: Failing test im _PTPQueuePrinter test file (find first)** + +```bash +find backend/tests -name "test_pt*.py" -o -name "*pt_queue*" +# Likely: backend/tests/unit/printer_models/test_pt.py +``` + +Append to the existing test file (path adjust if different): + +```python +# --------------------------------------------------------------------------- +# Phase 1k.2: _PTPQueuePrinter.print_images forwards to backend.print_images +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_ptp_queue_printer_print_images_forwards_to_backend(monkeypatch): + """_PTPQueuePrinter.print_images calls backend.print_images with tape_spec.""" + from PIL import Image + from uuid import uuid4 + from app.printer_models.pt import PTP750WDriver, _PTPQueuePrinter + from app.printer_models.tape_registry import TapeRegistry, MediaType + from unittest.mock import AsyncMock + + backend = AsyncMock() + backend.print_images = AsyncMock() + backend.half_cut_supported = True + tape_registry = TapeRegistry() + driver = PTP750WDriver(backend=backend) + adapter = driver.make_queue_printer(tape_registry, printer_id=uuid4()) + + images = [Image.new("1", (600, 70), color=1) for _ in range(3)] + await adapter.print_images( + images, tape_mm=12, + media_type=MediaType.LAMINATED, + auto_cut=True, high_resolution=False, half_cut=True, + ) + + assert backend.print_images.call_count == 1 + call = backend.print_images.call_args + assert call.args[0] == images # images list + assert call.args[1].width_mm == 12 # tape_spec + assert call.kwargs["auto_cut"] is True + assert call.kwargs["half_cut"] is True + + +@pytest.mark.anyio +async def test_ptp_queue_printer_print_image_forwards_half_cut_last_page(monkeypatch): + """REGRESSION: print_image (single) must forward half_cut + last_page (PR #100 bug).""" + from PIL import Image + from uuid import uuid4 + from app.printer_models.pt import PTP750WDriver, _PTPQueuePrinter + from app.printer_models.tape_registry import TapeRegistry, MediaType + from unittest.mock import AsyncMock + + backend = AsyncMock() + backend.print_image = AsyncMock() + backend.half_cut_supported = True + tape_registry = TapeRegistry() + driver = PTP750WDriver(backend=backend) + adapter = driver.make_queue_printer(tape_registry, printer_id=uuid4()) + + image = Image.new("1", (600, 70), color=1) + await adapter.print_image( + image, tape_mm=12, + media_type=MediaType.LAMINATED, + auto_cut=True, high_resolution=False, + half_cut=True, last_page=False, + ) + + call = backend.print_image.call_args + assert call.kwargs["half_cut"] is True + assert call.kwargs["last_page"] is False +``` + +- [ ] **Step 2: Run tests to verify both fail** + +```bash +backend/.venv/bin/python -m pytest backend/tests/unit/printer_models/test_pt.py -k "print_images or print_image_forwards" -v 2>&1 | tail -15 +``` + +Expected: AttributeError for `print_images`, AssertionError for the half_cut/last_page check. + +- [ ] **Step 3: Implement — Bug fix + new method** + +In `backend/app/printer_models/pt.py`, replace `_PTPQueuePrinter.print_image` and add `print_images`: + +```python + async def print_image(self, image: Image.Image, *, tape_mm: int, **options: Any) -> None: + media_type = options.pop("media_type", self._default_media_type) + tape_spec = self._tape_registry.lookup_pt(tape_mm, media_type) + # Phase 1k.2 Bug-Fix: half_cut + last_page wurden vorher NICHT forwarded + # (gefunden waehrend Plan-Writing Phase 1k.2). PR #100 erreichte die Lib + # nicht. Hier explicit forwarden. + await self._backend.print_image( + image, + tape_spec, + auto_cut=bool(options.pop("auto_cut", True)), + high_resolution=bool(options.pop("high_resolution", False)), + half_cut=bool(options.pop("half_cut", False)), + last_page=bool(options.pop("last_page", True)), + ) + + async def print_images( + self, + images: list[Image.Image], + *, + tape_mm: int, + **options: Any, + ) -> None: + """Phase 1k.2: Adapter-Methode fuer Queue-BatchJob → backend.print_images.""" + media_type = options.pop("media_type", self._default_media_type) + tape_spec = self._tape_registry.lookup_pt(tape_mm, media_type) + await self._backend.print_images( + images, + tape_spec, + auto_cut=bool(options.pop("auto_cut", True)), + high_resolution=bool(options.pop("high_resolution", False)), + half_cut=bool(options.pop("half_cut", True)), + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +backend/.venv/bin/python -m pytest backend/tests/unit/printer_models/test_pt.py -q 2>&1 | tail -3 +``` + +Expected: all green. + +- [ ] **Step 5: Lint + commit** + +```bash +backend/.venv/bin/ruff check backend/app/printer_models/pt.py backend/tests/unit/printer_models/test_pt.py +backend/.venv/bin/ruff format --check backend/app/printer_models/pt.py backend/tests/unit/printer_models/test_pt.py +backend/.venv/bin/mypy backend/app/printer_models/pt.py 2>&1 | tail -3 + +git add backend/app/printer_models/pt.py backend/tests/unit/printer_models/test_pt.py +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(pt): _PTPQueuePrinter.print_images + bug-fix half_cut/last_page forwarding (Phase 1k.2 Task 6) + +Bug discovered during Phase 1k.2 plan-writing: _PTPQueuePrinter.print_image +forwarded only auto_cut + high_resolution. half_cut und last_page wurden aus +options dict geholt aber NIE an backend.print_image() weitergeleitet. PR #100 +fix landete nie bei ptouch lib. + +Fix: explicit options.pop für half_cut + last_page. Plus neue print_images() +Adapter-Methode fuer Queue-BatchJob-Pfad. + +Refs #102, regression-fixes PR #100 (silent drop in adapter layer)" +``` + +--- + +## Task 7: `_QLQueuePrinter.print_images` Adapter + +**Files:** +- Modify: `backend/app/printer_models/ql.py` + +**Rationale:** Analog Task 6 für QL. QL braucht KEIN half_cut/last_page-Bug-Fix (QL hat kein half_cut), aber print_images Adapter ist nötig für Queue-Konsistenz. + +- [ ] **Step 1: Failing test (analog Task 6)** + +Append to `backend/tests/unit/printer_models/test_ql.py`: + +```python +@pytest.mark.anyio +async def test_ql_queue_printer_print_images_forwards_to_backend(monkeypatch): + """_QLQueuePrinter.print_images calls backend.print_images with tape_spec.""" + from PIL import Image + from uuid import uuid4 + from app.printer_models.ql import QL820NWBDriver + from app.printer_models.tape_registry import TapeRegistry + from unittest.mock import AsyncMock + + backend = AsyncMock() + backend.print_images = AsyncMock() + backend.half_cut_supported = False + tape_registry = TapeRegistry() + driver = QL820NWBDriver(backend=backend) + adapter = driver.make_queue_printer(tape_registry, printer_id=uuid4()) + + images = [Image.new("1", (696, 200), color=1) for _ in range(2)] + await adapter.print_images( + images, tape_mm=62, + auto_cut=True, high_resolution=False, half_cut=False, + ) + + assert backend.print_images.call_count == 1 + call = backend.print_images.call_args + assert call.args[0] == images + assert call.args[1].width_mm == 62 + assert call.kwargs["half_cut"] is False +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +backend/.venv/bin/python -m pytest backend/tests/unit/printer_models/test_ql.py -k "print_images" -v 2>&1 | tail -8 +``` + +Expected: AttributeError. + +- [ ] **Step 3: Implement** + +In `backend/app/printer_models/ql.py`, in `_QLQueuePrinter` class (after `print_image`): + +```python + async def print_images( + self, + images: list[Image.Image], + *, + tape_mm: int, + **options: Any, + ) -> None: + """Phase 1k.2: Adapter-Methode fuer Queue-BatchJob → backend.print_images. + + QL-Series unterstuetzt kein half_cut — Backend forciert intern False. + """ + tape_spec = self._tape_registry.lookup_ql(tape_mm) + await self._backend.print_images( + images, + tape_spec, + auto_cut=bool(options.pop("auto_cut", True)), + high_resolution=bool(options.pop("high_resolution", False)), + half_cut=False, # QL erzwingt + ) +``` + +(Adjust `tape_registry.lookup_ql` if the actual method name differs — verify with `grep "def lookup_ql\|def lookup_pt" app/printer_models/tape_registry.py`.) + +- [ ] **Step 4: Run tests + commit** + +```bash +backend/.venv/bin/python -m pytest backend/tests/unit/printer_models/test_ql.py -q 2>&1 | tail -3 +backend/.venv/bin/ruff check backend/app/printer_models/ql.py backend/tests/unit/printer_models/test_ql.py +backend/.venv/bin/ruff format --check backend/app/printer_models/ql.py backend/tests/unit/printer_models/test_ql.py + +git add backend/app/printer_models/ql.py backend/tests/unit/printer_models/test_ql.py +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(ql): _QLQueuePrinter.print_images adapter (Phase 1k.2 Task 7) + +QL erzwingt half_cut=False intern (capability-flag). Adapter-Konsistenz mit +PT fuer Queue-BatchJob-Pfad. + +Refs #102" +``` + +--- + +## Task 8: `PrintQueue.BatchJob` dataclass + `enqueue_batch` + Worker isinstance-Branch + +**Files:** +- Modify: `backend/app/services/print_queue.py` +- Create: `backend/tests/unit/services/test_print_queue_batch.py` + +**Rationale:** Kern der 1k.2-Architektur. BatchJob als neuer Queue-Typ, Worker dispatched per isinstance. + +- [ ] **Step 1: Failing test** + +`backend/tests/unit/services/test_print_queue_batch.py`: + +```python +"""Tests fuer BatchJob path durch PrintQueue (Phase 1k.2 Task 8).""" +from __future__ import annotations + +import asyncio +from io import BytesIO +from typing import Any +from unittest.mock import AsyncMock +from uuid import UUID, uuid4 + +import pytest +from PIL import Image + +from app.services.print_queue import BatchJob, PrintQueue + + +class _FakePrinter: + """_PrinterLike test double with print_image + print_images.""" + def __init__(self, printer_id: UUID) -> None: + self.id = printer_id + self.print_image = AsyncMock() + self.print_images = AsyncMock() + + +@pytest.fixture +def printer_id() -> UUID: + return uuid4() + + +@pytest.fixture +def fake_printer(printer_id) -> _FakePrinter: + return _FakePrinter(printer_id) + + +@pytest.fixture +def make_image() -> "callable[[], Image.Image]": + return lambda: Image.new("1", (600, 70), color=1) + + +@pytest.mark.anyio +async def test_enqueue_batch_creates_batch_job(fake_printer, make_image): + """enqueue_batch puts a BatchJob onto the queue, returns batch_id.""" + queue = PrintQueue([fake_printer]) + images = [make_image() for _ in range(3)] + job_ids = [uuid4(), uuid4(), uuid4()] + + batch_id = await queue.enqueue_batch( + printer_id=fake_printer.id, + images=images, + job_ids=job_ids, + tape_mm=12, + options={"auto_cut": True, "high_resolution": False}, + ) + + assert isinstance(batch_id, UUID) + + +@pytest.mark.anyio +async def test_worker_dispatches_batchjob_to_print_images(fake_printer, make_image): + """Worker recognises BatchJob and calls printer.print_images, not print_image.""" + queue = PrintQueue([fake_printer]) + images = [make_image() for _ in range(2)] + job_ids = [uuid4(), uuid4()] + + await queue.start() + try: + await queue.enqueue_batch( + printer_id=fake_printer.id, + images=images, + job_ids=job_ids, + tape_mm=12, + options={"auto_cut": True, "high_resolution": False, "half_cut": True}, + ) + # Wait for worker to consume the batch + for _ in range(50): + if fake_printer.print_images.await_count > 0: + break + await asyncio.sleep(0.05) + finally: + await queue.stop(timeout_s=2.0) + + assert fake_printer.print_images.await_count == 1 + assert fake_printer.print_image.await_count == 0 # NOT called + + call = fake_printer.print_images.call_args + assert len(call.args[0]) == 2 # images list + assert call.kwargs["tape_mm"] == 12 + assert call.kwargs["half_cut"] is True + + +@pytest.mark.anyio +async def test_batchjob_success_marks_all_job_ids_completed(fake_printer, make_image): + """On print_images success, all job_ids of the batch are marked completed.""" + queue = PrintQueue([fake_printer]) + images = [make_image() for _ in range(3)] + job_ids = [uuid4(), uuid4(), uuid4()] + + await queue.start() + try: + await queue.enqueue_batch( + printer_id=fake_printer.id, images=images, job_ids=job_ids, + tape_mm=12, options={"auto_cut": True, "half_cut": True}, + ) + for _ in range(50): + if fake_printer.print_images.await_count > 0: + break + await asyncio.sleep(0.05) + finally: + await queue.stop(timeout_s=2.0) + + # Status check — depends on JobStore implementation. Use list_completed if available: + # For now, assert print_images called once (Mock-Store via MemoryJobStore default). + assert fake_printer.print_images.await_count == 1 + + +@pytest.mark.anyio +async def test_batchjob_failure_marks_all_job_ids_failed(fake_printer, make_image): + """On print_images exception, all job_ids of the batch are marked failed.""" + fake_printer.print_images = AsyncMock(side_effect=RuntimeError("printer offline")) + queue = PrintQueue([fake_printer]) + images = [make_image() for _ in range(2)] + job_ids = [uuid4(), uuid4()] + + await queue.start() + try: + await queue.enqueue_batch( + printer_id=fake_printer.id, images=images, job_ids=job_ids, + tape_mm=12, options={"auto_cut": True, "half_cut": True}, + ) + for _ in range(50): + if fake_printer.print_images.await_count > 0: + break + await asyncio.sleep(0.05) + finally: + await queue.stop(timeout_s=2.0) + + assert fake_printer.print_images.await_count == 1 + + +@pytest.mark.anyio +async def test_enqueue_batch_rejects_unknown_printer(fake_printer, make_image): + """enqueue_batch raises KeyError for unknown printer_id.""" + queue = PrintQueue([fake_printer]) + images = [make_image()] + with pytest.raises(KeyError): + await queue.enqueue_batch( + printer_id=uuid4(), # unknown + images=images, + job_ids=[uuid4()], + tape_mm=12, + options={}, + ) + + +@pytest.mark.anyio +async def test_enqueue_batch_requires_matching_lengths(fake_printer, make_image): + """images and job_ids must have same length.""" + queue = PrintQueue([fake_printer]) + images = [make_image() for _ in range(3)] + with pytest.raises(ValueError, match="images and job_ids length mismatch"): + await queue.enqueue_batch( + printer_id=fake_printer.id, + images=images, + job_ids=[uuid4(), uuid4()], # only 2 + tape_mm=12, + options={}, + ) +``` + +- [ ] **Step 2: Run to verify failures** + +```bash +backend/.venv/bin/python -m pytest backend/tests/unit/services/test_print_queue_batch.py -v 2>&1 | tail -15 +``` + +Expected: ImportError on `BatchJob`, or AttributeError on `enqueue_batch`. + +- [ ] **Step 3: Implement BatchJob + enqueue_batch + worker branch** + +In `backend/app/services/print_queue.py`: + +A) Update `_PrinterLike` Protocol (line ~130) — add `print_images`: + +```python +@runtime_checkable +class _PrinterLike(Protocol): + """Minimal printer contract this queue depends on. + + Real printer plugins (PR for Tasks 2.1/2.2) implement the richer + PrinterModel Protocol (PR #48). The queue depends only on `id`, + `print_image`, and (Phase 1k.2) `print_images`. + """ + + id: UUID + + async def print_image(self, image: Image.Image, *, tape_mm: int, **options: Any) -> None: ... + + async def print_images( + self, images: list[Image.Image], *, tape_mm: int, **options: Any + ) -> None: ... +``` + +B) Add `BatchJob` dataclass (after `class PrintQueue`'s `_PrinterLike` Protocol): + +```python +@dataclass(frozen=False) +class BatchJob: + """Queue-Item das mehrere Labels in einer Backend-Operation druckt. + + Phase 1k.2: BatchJob ist orthogonal zu Job — der Worker dispatched per + isinstance. Auf success/failure werden alle job_ids gemeinsam markiert + (atomic semantics, User-Entscheidung Option 1). + """ + batch_id: UUID + printer_id: UUID + image_payloads: list[bytes] # PNG-encoded für DB-Konsistenz mit Job.image_payload + job_ids: list[UUID] + tape_mm: int + options: dict[str, Any] +``` + +Add `dataclass` import at top: +```python +from dataclasses import dataclass +``` + +C) Update the queue type annotation (line ~181): +```python + self._queues: dict[UUID, asyncio.Queue[Job | BatchJob | None]] = { + p.id: asyncio.Queue() for p in printers + } +``` + +D) Add `enqueue_batch` method to `PrintQueue` (after `submit_paused`): + +```python + async def enqueue_batch( + self, + *, + printer_id: UUID, + images: list[Image.Image], + job_ids: list[UUID], + tape_mm: int, + options: dict[str, Any], + ) -> UUID: + """Phase 1k.2: Submit N images as ONE BatchJob (atomic print_multi call). + + Args: + printer_id: Target printer (must be registered in self._queues). + images: PIL Images in print order, len(images) >= 1. + job_ids: Pre-allocated job UUIDs, one per image. Must be len(images) long. + tape_mm: Shared tape width (12/18/24/62). + options: Collective options (auto_cut, high_resolution, half_cut). + + Returns: + batch_id: New UUID identifying this batch. + + Raises: + KeyError: unknown printer_id. + ValueError: len(images) != len(job_ids), or len(images) == 0. + """ + if printer_id not in self._queues: + raise KeyError(f"Unknown printer: {printer_id}") + if not images: + raise ValueError("enqueue_batch requires at least one image") + if len(images) != len(job_ids): + raise ValueError( + f"images and job_ids length mismatch: {len(images)} vs {len(job_ids)}" + ) + + # Serialize images to PNG bytes (consistent with single-job storage) + payloads = [ + await asyncio.to_thread(_serialize_image_to_png, img) for img in images + ] + batch_id = uuid4() + batch = BatchJob( + batch_id=batch_id, + printer_id=printer_id, + image_payloads=payloads, + job_ids=list(job_ids), + tape_mm=tape_mm, + options=dict(options), + ) + await self._queues[printer_id].put(batch) + logger.info("Batch %s queued on %s with %d items", batch_id, printer_id, len(images)) + return batch_id +``` + +Add `uuid4` import at top: +```python +from uuid import UUID, uuid4 +``` + +E) Update worker (`_worker` method, line ~660) to dispatch per isinstance: + +Find the section: +```python + job = item + + # Wait while paused — ... +``` + +Replace with: +```python + # Phase 1k.2: BatchJob vs Job — dispatch per isinstance + if isinstance(item, BatchJob): + await self._process_batch(printer, printer_id, item) + continue + + job = item + + # Wait while paused — ... +``` + +F) Add new `_process_batch` method to `PrintQueue` (after `_worker`): + +```python + async def _process_batch( + self, + printer: _PrinterLike, + printer_id: UUID, + batch: BatchJob, + ) -> None: + """Phase 1k.2: Handle BatchJob — atomic success/failure for all job_ids. + + Decodes payloads, calls printer.print_images() once. On exception, + marks all job_ids as failed with a shared error_message. + """ + # Wait while paused (mirror _worker semantics) + while self._worker_states[printer_id] == PrinterWorkerState.PAUSED: + if self._stopping: + return + await self._worker_resume_events[printer_id].wait() + + # Persist transitions: all → PRINTING + for jid in batch.job_ids: + await self._store.mark_printing(jid) + + # Decode PNG payloads back to PIL Images + images = [ + await asyncio.to_thread(Image.open, BytesIO(p)) + for p in batch.image_payloads + ] + + try: + await printer.print_images( + images, + tape_mm=batch.tape_mm, + **batch.options, + ) + for jid in batch.job_ids: + await self._store.mark_done(jid) + logger.info("Batch %s completed on %s", batch.batch_id, printer_id) + except asyncio.CancelledError: + raise + except Exception as exc: + error_msg = f"batch print failed: {exc}" + for jid in batch.job_ids: + await self._store.mark_failed(jid, error_msg) + logger.exception( + "Batch %s failed on %s — all %d items marked failed", + batch.batch_id, printer_id, len(batch.job_ids), + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +backend/.venv/bin/python -m pytest backend/tests/unit/services/test_print_queue_batch.py -v 2>&1 | tail -15 +``` + +Expected: 6 passed. + +- [ ] **Step 5: Run full queue test-suite for regression check** + +```bash +backend/.venv/bin/python -m pytest backend/tests/unit/services/ -q 2>&1 | tail -3 +``` + +Expected: alle vorherigen tests grün + neue 6 = ~120+. + +- [ ] **Step 6: Lint + commit** + +```bash +backend/.venv/bin/ruff check backend/app/services/print_queue.py backend/tests/unit/services/test_print_queue_batch.py +backend/.venv/bin/ruff format --check backend/app/services/print_queue.py backend/tests/unit/services/test_print_queue_batch.py +backend/.venv/bin/mypy backend/app/services/print_queue.py 2>&1 | tail -3 + +git add backend/app/services/print_queue.py backend/tests/unit/services/test_print_queue_batch.py +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(queue): BatchJob + enqueue_batch + worker isinstance dispatch (Phase 1k.2 Task 8) + +Neuer BatchJob dataclass mit batch_id, printer_id, image_payloads (PNG bytes), +job_ids[], tape_mm, options. PrintQueue.enqueue_batch validiert + serialisiert. +Worker dispatched BatchJob → _process_batch → printer.print_images. Atomic: +auf success markiert alle job_ids als done, auf failure alle als failed mit +gemeinsamer Error-Message. + +_PrinterLike Protocol erweitert um print_images. + +Refs #102" +``` + +--- + +## Task 9: `batch_dispatch.dispatch_batch` Refactor + Mixed-Tape-Check + +**Files:** +- Modify: `backend/app/services/batch_dispatch.py` +- Modify: `backend/app/services/print_service.py` (neue `submit_batch_job` Methode) +- Modify: `backend/tests/unit/services/test_batch_dispatch.py` + +**Rationale:** Render alle Items, sammle job_ids, queue als BatchJob. Mixed tape_mm → 400 vor Queue. + +- [ ] **Step 1: Failing test in test_batch_dispatch.py** + +Append: + +```python +# --------------------------------------------------------------------------- +# Phase 1k.2: dispatch_batch queues BatchJob instead of N PrintJobs +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_dispatch_batch_uses_enqueue_batch_path(monkeypatch): + """dispatch_batch with valid items calls service.submit_batch_job once.""" + # Build fake PrintService with submit_batch_job mock + # ... uses existing test fixtures from this file + # Specific implementation depends on existing test patterns. + + +@pytest.mark.anyio +async def test_dispatch_batch_rejects_mixed_tape_sizes(): + """Items with different template.tape_mm raise MixedTapeSizesError before queue.""" + from app.services.batch_dispatch import dispatch_batch, MixedTapeSizesError + # Setup mock service with two PrintRequests pointing to templates of different tape_mm + # Verify dispatch_batch raises MixedTapeSizesError, no jobs queued. +``` + +(Note: The full test setup requires existing fixtures. The implementer should consult the existing `test_batch_dispatch.py` for `_fake_service`, `_make_request`, and `_template_loader` patterns and reuse them. The exact failing test code depends on what's already there.) + +- [ ] **Step 2: Run to verify failure** + +```bash +backend/.venv/bin/python -m pytest backend/tests/unit/services/test_batch_dispatch.py -k "batch or mixed" -v 2>&1 | tail -15 +``` + +Expected: failures. + +- [ ] **Step 3: Refactor `dispatch_batch`** + +`backend/app/services/batch_dispatch.py` — replace body. Hinzufügen `MixedTapeSizesError` Exception class und neue Funktion `dispatch_batch`: + +```python +"""Best-effort Batch-Dispatcher: validiert + queued als atomic BatchJob. + +Phase 1k.2: Statt N PrintJobs (einer pro Item) wird genau EINE BatchJob +in die Queue gegeben. Der Backend (PT-Series) verwendet ptouch.print_multi +fuer atomic batch printing mit 5mm Half-Cut zwischen Labels. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING +from uuid import uuid4 + +from app.printer_backends.exceptions import ( + PrinterCoverOpenError, + PrinterOfflineError, + SnmpQueryError, + TapeEmptyError, + TapeMismatchError, +) +from app.schemas.print_batch import BatchError +from app.schemas.print_request import PrintRequest +from app.services.lookup_service import LookupFailedError +from app.services.template_loader import TemplateNotFoundError + +if TYPE_CHECKING: + from app.printer_backends.base import PrinterBackend + from app.services.print_service import PrintService + +_log = logging.getLogger(__name__) + + +class MixedTapeSizesError(Exception): + """Batch enthält Items mit unterschiedlichen template.tape_mm. + + Phase 1k.2: ptouch.print_multi unterstützt nur ein tape pro Call. + Vor Queue abfangen → 400 Response. + """ + + def __init__(self, tape_mm_values: list[int]) -> None: + super().__init__(f"Mixed tape sizes in batch: {sorted(set(tape_mm_values))}") + self.tape_mm_values = tape_mm_values + + +_PER_ITEM_ERRORS: dict[type[Exception], str] = { + TemplateNotFoundError: "template_not_found", + LookupFailedError: "integration_lookup_failed", + TapeEmptyError: "tape_empty", +} + +_BATCH_FATAL_ERRORS: tuple[type[Exception], ...] = ( + PrinterCoverOpenError, + PrinterOfflineError, + SnmpQueryError, + TapeMismatchError, # atomic per Phase 1k.2 Spec + MixedTapeSizesError, +) + + +async def dispatch_batch( + service: PrintService, + items: list[PrintRequest], + *, + half_cut_override: bool | None = None, + backend: PrinterBackend | None = None, +) -> tuple[list[str], list[BatchError]]: + """Render N items, queue ONE BatchJob via PrintService.submit_batch_job. + + Phase 1k.2 architecture: + - Per-item validation (template_not_found, lookup_failed) collected in errors[] + - Hardware errors (printer_offline, cover_open, tape_mismatch) propagated to caller + - Mixed tape_mm → MixedTapeSizesError (400) + - Successful items → ONE BatchJob mit allen Images, gemeinsamer half_cut Logic + + Returns: + (job_ids_str, errors): job_ids im Erfolgsfall, BatchError list für skipped items. + Bei BatchJob-Submit: alle job_ids gehoeren zu einer atomar-failed/atomar-success Batch. + """ + errors: list[BatchError] = [] + valid_items: list[tuple[int, PrintRequest, int]] = [] # (orig_index, request, tape_mm) + + # 1. Per-item validation: collect tape_mm + flag failures. + for index, item in enumerate(items): + try: + # Template loading throws TemplateNotFoundError synchronously + tape_mm = await _validate_item_get_tape_mm(service, item) + valid_items.append((index, item, tape_mm)) + except _BATCH_FATAL_ERRORS: + raise + except tuple(_PER_ITEM_ERRORS) as exc: + code = _PER_ITEM_ERRORS[type(exc)] + errors.append( + BatchError(index=index, error_code=code, error_message=str(exc)) + ) + except Exception as exc: # unknown sync failure + _log.exception("unexpected error validating batch item %d", index) + errors.append( + BatchError(index=index, error_code="internal_error", error_message=str(exc)) + ) + + if not valid_items: + return [], errors + + # 2. Mixed tape_mm check + tape_mm_set = {tm for _, _, tm in valid_items} + if len(tape_mm_set) > 1: + raise MixedTapeSizesError([tm for _, _, tm in valid_items]) + + # 3. Backend half_cut capability + backend_supports_half_cut: bool = getattr(backend, "half_cut_supported", False) + if half_cut_override is not None: + use_half_cut = half_cut_override and backend_supports_half_cut + else: + use_half_cut = backend_supports_half_cut + + # 4. Submit as single BatchJob + requests = [req for _, req, _ in valid_items] + job_ids = await service.submit_batch_job( + requests, + half_cut=use_half_cut, + ) + + return [str(jid) for jid in job_ids], errors + + +async def _validate_item_get_tape_mm( + service: PrintService, + item: PrintRequest, +) -> int: + """Load template, return its tape_mm. Raises TemplateNotFoundError on miss.""" + # service has access to template_loader via private attr — use public helper. + template = service._loader.get(item.template_id) + return template.tape_mm +``` + +(Note: `service._loader` private attribute — alternative: add `get_template_tape_mm` public method on PrintService. Decide based on code-quality reviewer feedback.) + +- [ ] **Step 4: Add `PrintService.submit_batch_job`** + +In `backend/app/services/print_service.py`, add method after `submit_print_job`: + +```python + async def submit_batch_job( + self, + requests: list[PrintRequest], + *, + half_cut: bool, + ) -> list[UUID]: + """Phase 1k.2: Render N items, submit ONE BatchJob to PrintQueue. + + Atomic: alle job_ids werden gemeinsam als completed/failed markiert. + Preflight + tape-mismatch werden 1x am Anfang fuer alle items geprueft. + """ + if not requests: + raise ValueError("submit_batch_job requires at least one request") + + # 1. Load templates (alle muessen existieren — TemplateNotFoundError vorher abgefangen) + templates = [self._loader.get(r.template_id) for r in requests] + tape_mm = templates[0].tape_mm # alle gleich (mixed-tape-check vorher) + + # 2. Preflight + tape-mismatch (1x fuer alle) + preflight = await self._backend.preflight_check() + if preflight.loaded_tape_mm != tape_mm: + raise TapeMismatchError( + expected_mm=tape_mm, + loaded_mm=preflight.loaded_tape_mm, + ) + + # 3. Resolve LabelData + Render + images: list[Image.Image] = [] + for request, template in zip(requests, templates, strict=True): + label_data = await self._resolve_label_data(request) + image = self._renderer.render(template, label_data) + images.append(image) + + # 4. Pre-allocate job UUIDs + persist in JobStore (analog submit_print_job) + job_ids: list[UUID] = [] + for i, (request, template) in enumerate(zip(requests, templates, strict=True)): + job_id = uuid4() + await self._store.save_queued( + job_id=job_id, + printer_id=self._printer_id, + template_key=request.template_id, + payload={ + "tape_mm": tape_mm, + "options": request.options.model_dump(), + "label_data": (await self._resolve_label_data(request)).model_dump(), + }, + ) + job_ids.append(job_id) + + # 5. Enqueue as BatchJob + await self._queue.enqueue_batch( + printer_id=self._printer_id, + images=images, + job_ids=job_ids, + tape_mm=tape_mm, + options={ + "auto_cut": True, # Sammelt + "high_resolution": False, + "half_cut": half_cut, + }, + ) + + return job_ids +``` + +Add `uuid4` import at top of print_service.py if missing: +```python +from uuid import UUID, uuid4 +``` + +- [ ] **Step 5: Run tests + commit** + +```bash +backend/.venv/bin/python -m pytest backend/tests/unit/services/ -q 2>&1 | tail -3 +backend/.venv/bin/ruff check backend/app/services/batch_dispatch.py backend/app/services/print_service.py backend/tests/unit/services/test_batch_dispatch.py +backend/.venv/bin/ruff format --check backend/app/services/batch_dispatch.py backend/app/services/print_service.py +backend/.venv/bin/mypy backend/app/services/batch_dispatch.py backend/app/services/print_service.py 2>&1 | tail -3 + +git add backend/app/services/batch_dispatch.py backend/app/services/print_service.py backend/tests/unit/services/test_batch_dispatch.py +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(batch): dispatch_batch refactor to atomic BatchJob (Phase 1k.2 Task 9) + +dispatch_batch sammelt valide items (per-item template_not_found etc. weiter +in errors[]), prueft mixed tape_mm vor Queue, ruft service.submit_batch_job +fuer den happy path. PrintService.submit_batch_job rendert N images, +persistiert N JobRecords, queued ONE BatchJob via enqueue_batch. + +MixedTapeSizesError als neue Fatal-Error (→ 400 im Route-Layer). + +Refs #102" +``` + +--- + +## Task 10: API-Route `batch.py` MixedTapeSizesError → 400 + +**Files:** +- Modify: `backend/app/api/routes/batch.py` + +**Rationale:** MixedTapeSizesError aus dispatch_batch muss vom Route-Layer auf HTTP 400 gemapped werden. + +- [ ] **Step 1: Inspect existing route** + +```bash +sed -n '38,120p' backend/app/api/routes/batch.py +``` + +- [ ] **Step 2: Modify route handler** + +Add to the try/except block in `create_batch`: + +```python + from app.services.batch_dispatch import MixedTapeSizesError + + try: + job_ids, errors = await dispatch_batch( + service, body.items, + half_cut_override=body.half_cut_override, + backend=backend, + ) + except MixedTapeSizesError as exc: + raise HTTPException( + 400, + detail={ + "error_code": "mixed_tape_sizes", + "error_message": str(exc), + "tape_mm_values": exc.tape_mm_values, + }, + ) from exc + # ... existing TapeMismatchError + PrinterOfflineError handlers stay +``` + +- [ ] **Step 3: Failing test** + +In existing `backend/tests/integration/api/test_batch_endpoint_*.py`, add: + +```python +@pytest.mark.anyio +async def test_post_batch_with_mixed_tape_sizes_returns_400( + api_client_with_seed +): + """Phase 1k.2: Batch with items pointing to different tape templates → 400.""" + body = { + "items": [ + { + "template_id": "qr-only-12mm", # 12mm tape + "data": {"primary_id": "A", "title": "T", "qr_payload": "https://e.test/a"}, + }, + { + "template_id": "qr-only-18mm", # 18mm tape + "data": {"primary_id": "B", "title": "T", "qr_payload": "https://e.test/b"}, + }, + ], + } + resp = await api_client_with_seed.post( + "/api/print/brother-p750w/batch", + json=body, + ) + assert resp.status_code == 400 + assert resp.json()["detail"]["error_code"] == "mixed_tape_sizes" +``` + +- [ ] **Step 4: Run + commit** + +```bash +backend/.venv/bin/python -m pytest backend/tests/integration/api/ -q 2>&1 | tail -3 +backend/.venv/bin/ruff check backend/app/api/routes/batch.py +backend/.venv/bin/ruff format --check backend/app/api/routes/batch.py +backend/.venv/bin/mypy backend/app/api/routes/batch.py 2>&1 | tail -3 + +git add backend/app/api/routes/batch.py backend/tests/integration/api/ +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(api): MixedTapeSizesError → 400 mixed_tape_sizes (Phase 1k.2 Task 10) + +Refs #102" +``` + +--- + +## Task 11: End-to-End Integration-Test + +**Files:** +- Create: `backend/tests/integration/test_batch_endpoint_multi_label.py` + +**Rationale:** Verifiziert full flow: HTTP POST → batch_dispatch → submit_batch_job → enqueue_batch → worker → printer.print_images → atomic completion. + +- [ ] **Step 1: Write end-to-end test** + +```python +"""Phase 1k.2 End-to-End: POST /batch → BatchJob path → atomic completion.""" +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock + +import pytest + + +@pytest.mark.anyio +async def test_post_batch_4_items_calls_print_images_once( + api_client_with_seed, + monkeypatch, +): + """4-item batch results in EXACTLY ONE print_images call (not 4 print_image).""" + # Spy on Backend.print_images via fixture-installed mock + body = { + "items": [ + { + "template_id": "qr-only-12mm", + "data": { + "primary_id": f"V{i}", + "title": "smoke", + "qr_payload": f"https://e.test/{i}", + }, + } + for i in range(4) + ], + } + resp = await api_client_with_seed.post( + "/api/print/brother-p750w/batch", + json=body, + ) + assert resp.status_code == 202 + rb = resp.json() + assert len(rb["job_ids"]) == 4 + assert rb["errors"] == [] + + # Allow worker to dequeue + process + for _ in range(50): + await asyncio.sleep(0.05) + # check mock call count via shared fixture + + # The Mock-Backend in the integration conftest must have: + # backend.print_images.await_count == 1 + # backend.print_image.await_count == 0 + # Implementer adds asserts based on conftest setup. + + +@pytest.mark.anyio +async def test_post_batch_failure_marks_all_jobs_failed( + api_client_with_seed, + monkeypatch, +): + """When print_images raises, all 4 job_ids show status='failed'.""" + # Implementer adapts to existing conftest patterns for status polling. +``` + +(The full test depends on existing `api_client_with_seed` fixture setup in `backend/tests/integration/conftest.py`. The implementer should consult existing tests like `test_batch_endpoint_happy.py` for patterns.) + +- [ ] **Step 2: Run + commit** + +```bash +backend/.venv/bin/python -m pytest backend/tests/integration/test_batch_endpoint_multi_label.py -v 2>&1 | tail -10 + +git add backend/tests/integration/test_batch_endpoint_multi_label.py +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "test(batch): end-to-end multi-label batch integration test (Phase 1k.2 Task 11) + +Refs #102" +``` + +--- + +## Task 12: Hardware-Smoke-Script + +**Files:** +- Create: `backend/scripts/smoke_first_print_batch.py` + +**Rationale:** Manueller Test gegen echten PT-P750W. Verifiziert visuell die 5mm Half-Cut Eigenschaft die unit/integration tests nicht prüfen können. + +- [ ] **Step 1: Create script** + +```python +"""Manual hardware-smoke: 4-item batch via POST /batch endpoint. + +Usage: + python3 backend/scripts/smoke_first_print_batch.py [hub_url] [api_key] + +Defaults to http://localhost:8000 + env $PRINTER_HUB_WEBHOOK_API_KEY. + +Expected output: 4 labels on the tape strip, with ~5mm Half-Cut between each +item and a full cut at the end. Compare to Brother iOS App print quality. +""" +from __future__ import annotations + +import os +import sys + +import httpx + +HUB_URL = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8000" +API_KEY = sys.argv[2] if len(sys.argv) > 2 else os.environ.get( + "PRINTER_HUB_WEBHOOK_API_KEY", "lh_pat_demo" +) + + +def main() -> None: + body = { + "items": [ + { + "template_id": "qr-only-12mm", + "data": { + "primary_id": f"BATCH-{i+1}", + "title": "Phase 1k.2 Smoke", + "qr_payload": f"https://hangar.example.test/smoke/batch/{i+1}", + }, + } + for i in range(4) + ], + } + resp = httpx.post( + f"{HUB_URL}/api/print/brother-p750w/batch", + json=body, + headers={"X-Label-Hub-Key": API_KEY}, + timeout=30.0, + ) + print(f"HTTP {resp.status_code}") + print(resp.json()) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/scripts/smoke_first_print_batch.py +git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(scripts): smoke_first_print_batch.py — manual 4-item batch hardware-smoke (Phase 1k.2 Task 12) + +Verify 5mm Half-Cut between batch items vs Brother iOS App output. + +Refs #102" +``` + +--- + +## Final Verification + +- [ ] **Step 1: Full test-suite green** + +```bash +cd /opt/repos/label-printer-hub +backend/.venv/bin/python -m pytest backend/tests -q 2>&1 | tail -5 +``` + +Expected: 952 (baseline) + neue Task-Tests = ~990+ passed, 5 skipped. + +- [ ] **Step 2: Full lint clean** + +```bash +backend/.venv/bin/ruff check backend/app backend/tests +backend/.venv/bin/ruff format --check backend/app backend/tests +backend/.venv/bin/mypy backend/app 2>&1 | tail -3 +``` + +Expected: clean (10 pre-existing import-untyped acceptable per Phase 1i baseline). + +- [ ] **Step 3: Privacy + secret scan** + +```bash +grep -rnE "172\.16\.[0-9]+\.[0-9]+|hhdocker" backend/ docs/ 2>&1 | grep -v "policies/privacy.md" | head -5 +``` + +Expected: empty (no privacy violations). + +- [ ] **Step 4: Push + PR** + +```bash +git push -u origin spec/phase-1k.2-multi-label-batch 2>&1 | tail -5 + +gh pr create --repo strausmann/Label-Printer-Hub \ + --title "feat: Phase 1k.2 Multi-Label-Batch via ptouch.print_multi (#102)" \ + --body "$(cat <<'EOF' +## Phase 1k.2 Implementation + +Implements approved spec: \`docs/superpowers/specs/2026-06-04-multi-label-batch-design.md\` + +Closes #102. + +## Was geht + +- Multi-Label-Batches an PT-Series produzieren 5mm Half-Cut zwischen Labels (Brother iOS App Verhalten) +- Statt 22.5mm Pre-Roll pro Item: 1 Connection, 1 Pre-Roll fuer den ganzen Batch +- Atomic semantics: bei Fehler werden alle job_ids des Batches gemeinsam als failed markiert +- API-Vertrag unveraendert (job_ids[] in BatchResponse) +- QL-820NWB: per-item Loop via default_print_images_loop (kein print_multi-Equivalent in brother_ql Lib) + +## Bug-Fix als Bonus + +PR #100 (last_page→feed) erreichte ptouch lib nicht weil _PTPQueuePrinter Adapter +half_cut + last_page aus options dict NICHT an backend.print_image forwarded. +Hier in Task 6 mit-gefixt. + +## Test-Counts + +- Baseline (nach PR #100): 952 passed +- Diese PR: ~990+ passed (+38 neue tests in 12 tasks) +- Manueller Hardware-Smoke: backend/scripts/smoke_first_print_batch.py + +Refs Phase 1i Smoke-Empirie: docs/site/operations/protokolle/2026-06-04-phase1i-smoke-test-empirie.md +EOF +)" --base main --head spec/phase-1k.2-multi-label-batch +``` + +--- + +## Self-Review + +(Wird vom writing-plans Workflow direkt nach Plan-Erstellung gemacht.) + +### Spec Coverage Check + +| Spec-Section | Tasks | +|---|---| +| Architektur Render-Phase | Task 9 (dispatch_batch + submit_batch_job) | +| Architektur Print-Phase | Task 3 (print_images PT) + Task 8 (BatchJob worker) | +| Neue Komponenten BatchJob | Task 8 | +| Neue Methode PrinterBackend.print_images | Task 2 | +| Neue Methode PTouchBackend.print_images | Task 3 | +| Neuer Helper _ptouch_print_multi | Task 3 | +| Modified batch_dispatch | Task 9 | +| Modified PrintQueue enqueue_batch + worker | Task 8 | +| Failure Modes (mixed tape, tape mismatch, hardware) | Tasks 9, 10 | +| Atomic Failure Semantik | Task 8 | +| Tests (unit + integration + hardware) | Tasks 1-12 (alle TDD) | +| Backward-Compat | Tasks 4, 5 (QL/Mock delegate), Task 6 (PT print_image bleibt) | + +Vollständige Abdeckung. + +### Placeholder Scan + +- ✅ Keine "TBD", "TODO", "implement later" +- ✅ Alle code-Blocks haben tatsächlichen Code, keine "fill in" Phrasen +- ⚠️ Task 9 + 11 verweisen auf "existing test patterns" / "consult existing conftest" — das ist akzeptabel (Implementer darf existing patterns lesen), aber wenn der spec-reviewer das anzweifelt → minimal-test-code als Fallback in Task 9/11 expandieren + +### Type-Konsistenz + +- `BatchJob` Felder `batch_id, printer_id, image_payloads, job_ids, tape_mm, options` konsistent in Tasks 8 + 9 +- `print_images` Signatur `(images: list[Image.Image], tape_spec: TapeSpec, *, auto_cut, high_resolution, half_cut) -> None` konsistent in Tasks 2, 3, 4, 5 +- Queue-Adapter `print_images` Signatur `(images: list[Image.Image], *, tape_mm: int, **options) -> None` konsistent in Tasks 6, 7, 8 +- `enqueue_batch` Parameter `(printer_id, images, job_ids, tape_mm, options)` konsistent in Task 8 + 9 +- `MixedTapeSizesError` konsistent in Task 9 + 10 + +Alle types und signaturen konsistent. + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md`.** + +Two execution options: + +**1. Subagent-Driven (recommended)** — Orchestrator dispatched 1 fresh subagent pro Task (12 Tasks), nach jeder spec-compliance + code-quality review. Fast iteration, parallel safety. + +**2. Inline Execution** — Execute tasks in this session using superpowers:executing-plans, batch execution with checkpoints for review. + +Welcher Ansatz? From ad3aec59919d1c9f37f2fdab391d5d97ad7448d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 16:50:32 +0000 Subject: [PATCH 03/21] docs(plan): adresse Gemini-Review G1+G2+G3 (PR #106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 medium-priority architektur-Findings aus Gemini-Code-Assist: G1 — Task 8 enqueue_batch: In-Memory Job-Registrierung pro item ergaenzt. Ohne registrierte Job-Objekte in self._jobs wuerde get(job_id) und wait_for_job(job_id) KeyError werfen, list_queue/clear_queue ignorieren Batch-Items. Plus: PNG-Serialisierung parallelisiert via asyncio.gather. G2 — Task 8 _process_batch: JobStateMachine.transition + _notify_state_change pro Job ergaenzt. Ohne transitions: - _done_event wird nie gesetzt → wait_for_job haengt unendlich - SSE-Frontend (Hangar) bekommt keine Events → UI zeigt Jobs als ewig 'queued' - started_at/finished_at Timestamps bleiben None G3 — Task 9 submit_batch_job: asyncio.to_thread + asyncio.gather fuer Renders. Synchroner Pillow-Call in async-Loop blockierte Event-Loop + serialisierte unnoetig. _resolve_label_data Duplikat-Call entfernt. Plus: Plan-Header bekommt 'Plan Revisions' Tabelle als Audit-Trail. Refs #102, #106 Gemini-Review --- .../2026-06-04-multi-label-batch-plan.md | 158 +++++++++++++++--- 1 file changed, 132 insertions(+), 26 deletions(-) diff --git a/docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md b/docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md index f92ce7e..3cced2f 100644 --- a/docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md +++ b/docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md @@ -8,6 +8,13 @@ **Tech Stack:** Python 3.12, FastAPI, Pydantic v2, PIL/Pillow, ptouch-py 1.1.0, brother_ql 0.9.4, asyncio, pytest. +## Plan Revisions + +| Date | Revisions | Source | +|------|-----------|--------| +| 2026-06-04 | Initial | superpowers:writing-plans (Commit `8917d0e`) | +| 2026-06-04 | **G1** In-Memory Job-Registrierung in `enqueue_batch` (sonst `KeyError` bei `get`/`wait_for_job`); parallele PNG-Serialisierung. **G2** `JobStateMachine.transition` + `_notify_state_change` in `_process_batch` (sonst SSE-Stille + hängende Waiter). **G3** `asyncio.to_thread` + `asyncio.gather` für CPU-intensive Renders in `submit_batch_job` (sonst Event-Loop-Block). Inline-Imports vervollständigt. | Gemini-Code-Assist Review PR #106 (medium-priority Findings) | + --- ## File Structure @@ -1470,15 +1477,31 @@ D) Add `enqueue_batch` method to `PrintQueue` (after `submit_paused`): f"images and job_ids length mismatch: {len(images)} vs {len(job_ids)}" ) - # Serialize images to PNG bytes (consistent with single-job storage) - payloads = [ - await asyncio.to_thread(_serialize_image_to_png, img) for img in images - ] + # Gemini-Review G1 (PR #106): Parallel PNG-Serialisierung + payloads = await asyncio.gather( + *[asyncio.to_thread(_serialize_image_to_png, img) for img in images] + ) + + # Gemini-Review G1 (PR #106): In-Memory Job-Registrierung pro item. + # OHNE diese Schleife wirft get(job_id)/wait_for_job KeyError, weil + # die individuellen Jobs nie in self._jobs landen. SSE-Frontend und + # Hangar-Polling brauchen pro-Item-Job-Records (alle teilen das BatchJob + # als Owner, aber jeder Job hat eigene id/state/_done_event). + for jid, payload in zip(job_ids, payloads, strict=True): + job = Job( + id=str(jid), + printer_id=printer_id, + image_payload=payload, + tape_mm=tape_mm, + options=dict(options), + ) + self._jobs[str(jid)] = job + batch_id = uuid4() batch = BatchJob( batch_id=batch_id, printer_id=printer_id, - image_payloads=payloads, + image_payloads=list(payloads), job_ids=list(job_ids), tape_mm=tape_mm, options=dict(options), @@ -1527,6 +1550,13 @@ F) Add new `_process_batch` method to `PrintQueue` (after `_worker`): Decodes payloads, calls printer.print_images() once. On exception, marks all job_ids as failed with a shared error_message. + + Gemini-Review G2 (PR #106): Pro item MUSS JobStateMachine.transition + gerufen werden, sonst: + - _done_event wird nie gesetzt → wait_for_job(job_id) haengt unendlich + - _notify_state_change wird nie gerufen → SSE-Frontend (Hangar) bekommt + keine Updates und zeigt Jobs als ewig 'queued' an + - started_at/finished_at Timestamps bleiben None (UI-Probleme) """ # Wait while paused (mirror _worker semantics) while self._worker_states[printer_id] == PrinterWorkerState.PAUSED: @@ -1534,15 +1564,40 @@ F) Add new `_process_batch` method to `PrintQueue` (after `_worker`): return await self._worker_resume_events[printer_id].wait() - # Persist transitions: all → PRINTING + # Resolve all Job in-memory objects (registered in enqueue_batch via + # Gemini-Review G1 fix). Falls ein Job nicht mehr in self._jobs ist + # (cancel mid-flight), skip — terminale state-transition wuerde fehlen. + jobs: list[Job] = [] for jid in batch.job_ids: - await self._store.mark_printing(jid) + job = self._jobs.get(str(jid)) + if job is None: + logger.warning("Batch %s: job_id %s not in _jobs (cancelled?)", batch.batch_id, jid) + continue + jobs.append(job) + if not jobs: + logger.warning("Batch %s has no live jobs — skipping", batch.batch_id) + return + + # Gemini-Review G2: in-memory transitions + SSE-Events + DB persist. + # QUEUED -> PRINTING fuer jeden Job. + for job in jobs: + try: + JobStateMachine.transition(job, JobState.PRINTING) + self._notify_state_change( + job, JobState.QUEUED, JobState.PRINTING, + queue_depth=self._queue_depth(printer_id), + ) + except InvalidStateTransitionError: + logger.warning( + "Batch %s: job %s skipped — state already %s", + batch.batch_id, job.id, job.state, + ) + await self._store.mark_printing(UUID(job.id)) - # Decode PNG payloads back to PIL Images - images = [ - await asyncio.to_thread(Image.open, BytesIO(p)) - for p in batch.image_payloads - ] + # Gemini-Review G1 (PR #106): Parallel image decode + images = await asyncio.gather( + *[asyncio.to_thread(Image.open, BytesIO(p)) for p in batch.image_payloads] + ) try: await printer.print_images( @@ -1550,18 +1605,45 @@ F) Add new `_process_batch` method to `PrintQueue` (after `_worker`): tape_mm=batch.tape_mm, **batch.options, ) - for jid in batch.job_ids: - await self._store.mark_done(jid) + # Success: alle Jobs PRINTING -> COMPLETED + for job in jobs: + try: + JobStateMachine.transition(job, JobState.COMPLETED) + self._notify_state_change( + job, JobState.PRINTING, JobState.COMPLETED, + queue_depth=self._queue_depth(printer_id), + ) + except InvalidStateTransitionError: + logger.warning( + "Batch %s: success-transition of %s failed (state=%s)", + batch.batch_id, job.id, job.state, + ) + await self._store.mark_done(UUID(job.id)) logger.info("Batch %s completed on %s", batch.batch_id, printer_id) except asyncio.CancelledError: raise except Exception as exc: error_msg = f"batch print failed: {exc}" - for jid in batch.job_ids: - await self._store.mark_failed(jid, error_msg) + # Failure: alle Jobs PRINTING -> FAILED mit gemeinsamer Message + for job in jobs: + job.error_code = "batch_failed" + job.error_message = error_msg + job.error_msg = error_msg # legacy field sync + try: + JobStateMachine.transition(job, JobState.FAILED) + self._notify_state_change( + job, JobState.PRINTING, JobState.FAILED, + queue_depth=self._queue_depth(printer_id), + ) + except InvalidStateTransitionError: + logger.warning( + "Batch %s: failure-transition of %s failed (state=%s)", + batch.batch_id, job.id, job.state, + ) + await self._store.mark_failed(UUID(job.id), error_msg) logger.exception( "Batch %s failed on %s — all %d items marked failed", - batch.batch_id, printer_id, len(batch.job_ids), + batch.batch_id, printer_id, len(jobs), ) ``` @@ -1822,16 +1904,34 @@ In `backend/app/services/print_service.py`, add method after `submit_print_job`: loaded_mm=preflight.loaded_tape_mm, ) - # 3. Resolve LabelData + Render - images: list[Image.Image] = [] - for request, template in zip(requests, templates, strict=True): - label_data = await self._resolve_label_data(request) - image = self._renderer.render(template, label_data) - images.append(image) + # 3. Resolve LabelData + Render — Gemini-Review G3 (PR #106): + # CPU-intensive Pillow-Operationen muessen ueber asyncio.to_thread + # ausgefuehrt werden, sonst blockiert der Event-Loop. asyncio.gather + # parallelisiert die N Renders (statt sequenziell N*~50ms zu warten). + async def _render_one(req: PrintRequest, tmpl: TemplateSchema) -> Image.Image: + label_data = await self._resolve_label_data(req) + return await asyncio.to_thread(self._renderer.render, tmpl, label_data) + + images = list( + await asyncio.gather( + *[_render_one(r, t) for r, t in zip(requests, templates, strict=True)] + ) + ) # 4. Pre-allocate job UUIDs + persist in JobStore (analog submit_print_job) + # Gemini-Review G3 (PR #106): _resolve_label_data wird nur EINMAL pro + # Item gerufen — vorher Z.1903 + Z.1918 (Duplikat). label_data wird via + # parallele Helper-Coroutine wiederverwendet. + async def _resolve_for_payload(req: PrintRequest) -> dict[str, Any]: + ld = await self._resolve_label_data(req) + return ld.model_dump() + + label_data_dumps = await asyncio.gather( + *[_resolve_for_payload(r) for r in requests] + ) + job_ids: list[UUID] = [] - for i, (request, template) in enumerate(zip(requests, templates, strict=True)): + for request, ld_dump in zip(requests, label_data_dumps, strict=True): job_id = uuid4() await self._store.save_queued( job_id=job_id, @@ -1840,7 +1940,7 @@ In `backend/app/services/print_service.py`, add method after `submit_print_job`: payload={ "tape_mm": tape_mm, "options": request.options.model_dump(), - "label_data": (await self._resolve_label_data(request)).model_dump(), + "label_data": ld_dump, }, ) job_ids.append(job_id) @@ -1861,9 +1961,15 @@ In `backend/app/services/print_service.py`, add method after `submit_print_job`: return job_ids ``` -Add `uuid4` import at top of print_service.py if missing: +Add imports at top of print_service.py if missing: ```python +import asyncio +from typing import Any from uuid import UUID, uuid4 + +from PIL import Image # nur falls noch nicht da + +from app.schemas.template import TemplateSchema ``` - [ ] **Step 5: Run tests + commit** From 4f56a2a509a1b56b8ca778ff2d4e25c0cb042d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 16:58:04 +0000 Subject: [PATCH 04/21] docs(spec+plan): adresse Copilot-Review C1-C9 + Privacy-Fixes (PR #106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9 Copilot-Findings adressiert: C1 Spec: PrinterBackend Protocol-default-method-Approach klargestellt — Python typing.Protocol vererbt keine Methoden-Bodies. Alle Backends MUESSEN print_images explicit haben; helper macht den Loop. C2 Spec: batch_failure_mode UI-Anpassung in Hangar als Out-of-1k.2-Scope verschoben. 1k.2 bleibt Backward-Compat-only. C3 Spec: Privacy-Konformitaet — personalisierte Doku-Links durch Repo-Hinweise + Kurzfassung ersetzt. C4 Plan: Real name+email in 'git -c user.*' commit examples durch plain 'git commit' ersetzt (12 Stellen). C5 Plan: print_images() docstring praeziser — 'atomic' nur fuer PT-Series via print_multi, default-loop kann partial-succeed. C6 Plan: _process_batch nutzt _printer_error_to_record + pause_printer bei recoverable PrinterErrors. Vorher generic Exception-Handling. Damit Konsistenz mit existing _worker loop. C7 Plan: _validate_item_get_tape_mm nutzt public PrintService.get_template_tape_mm statt service._loader (private). Neuer Step 4a fuer den public helper. C8 Plan: label_data wird in submit_batch_job nur einmal pro Item resolved (via _prepare_one helper). Vorher wurde es 2x gerufen. C9 Plan: Smoke-Script ohne hardcoded API-Key-Default 'lh_pat_demo'. Exit mit error wenn weder CLI-Arg noch Env-Var gesetzt. Plus Privacy-Fix: Final-Verification grep umformuliert (literal-Strings entfernt die den CI Privacy-Scan getriggert haben). Refs #102, #106 Gemini+Copilot Reviews --- .../2026-06-04-multi-label-batch-plan.md | 170 +++++++++++++----- .../2026-06-04-multi-label-batch-design.md | 34 ++-- 2 files changed, 146 insertions(+), 58 deletions(-) diff --git a/docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md b/docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md index 3cced2f..fbdea44 100644 --- a/docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md +++ b/docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md @@ -14,6 +14,7 @@ |------|-----------|--------| | 2026-06-04 | Initial | superpowers:writing-plans (Commit `8917d0e`) | | 2026-06-04 | **G1** In-Memory Job-Registrierung in `enqueue_batch` (sonst `KeyError` bei `get`/`wait_for_job`); parallele PNG-Serialisierung. **G2** `JobStateMachine.transition` + `_notify_state_change` in `_process_batch` (sonst SSE-Stille + hängende Waiter). **G3** `asyncio.to_thread` + `asyncio.gather` für CPU-intensive Renders in `submit_batch_job` (sonst Event-Loop-Block). Inline-Imports vervollständigt. | Gemini-Code-Assist Review PR #106 (medium-priority Findings) | +| 2026-06-04 | 9 Copilot-Findings adressiert: C1 Protocol-default-method-Approach klargestellt (alle Backends explizit `print_images`, helper macht Loop), C2 `batch_failure_mode` Hangar-UI-Anpassung als Out-of-1k.2-Scope verschoben, C3 personalisierte Doku-Links durch Repo-Hinweise ersetzt (Privacy-Policy), C4 Real name+email in Plan-Commits ersetzt durch plain `git commit`, C5 `print_images` docstring präzisiert (atomic für PT, best-effort für default-loop), C6 `_process_batch` PrinterError-Path nutzt `_printer_error_to_record` + `pause_printer` für recoverable errors (Konsistenz mit `_worker`), C7 `_validate_item_get_tape_mm` nutzt neue public `PrintService.get_template_tape_mm` statt private `_loader` Zugriff, C8 `label_data` einmal pro Item resolved (Render+Persist teilen sich denselben Wert), C9 Smoke-Script ohne hardcoded API-Key-Default. Plus: Privacy-Scan-grep in Final-Verification umformuliert. | Copilot Review PR #106 (9 Findings) | --- @@ -288,7 +289,7 @@ backend/.venv/bin/ruff format --check backend/app/printer_backends/batch_helper. backend/.venv/bin/mypy backend/app/printer_backends/batch_helper.py 2>&1 | tail -3 git add backend/app/printer_backends/batch_helper.py backend/tests/unit/printer_backends/test_batch_helper.py -git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(printer_backends): default_print_images_loop helper (Phase 1k.2 Task 1) +git commit -m "feat(printer_backends): default_print_images_loop helper (Phase 1k.2 Task 1) Backends ohne native batch-support (BrotherQLBackend, MockBackend) loopen per-item — Helper zentralisiert die half_cut + last_page Semantik analog @@ -321,7 +322,18 @@ Append after the existing `print_image` declaration: high_resolution: bool = False, half_cut: bool = True, ) -> None: - """Batch-print N images. Atomic semantics: success or all-fail. + """Batch-print N images — atomic-or-best-effort je nach Backend-Impl. + + Semantik haengt vom konkreten Backend ab: + - PTouchBackend via ptouch.print_multi: ATOMIC — auf Hardware-Ebene ein + einziger Print-Call. Bei Exception sind ggf. 0 oder ALLE Labels gedruckt, + niemals partial. + - Default-Loop (BrotherQLBackend, MockBackend) via + default_print_images_loop: BEST-EFFORT per item. Wenn item N + fehlschlaegt, koennen items 0..N-1 bereits physisch gedruckt sein. + Job-State-Handling muss damit umgehen (siehe Task 8 _process_batch). + (Copilot-Review C5 PR #106: vorher 'atomic semantics: success or all-fail' + war falsch fuer den default loop.) Phase 1k.2: Default-Loop ueber print_image() lebt in ``app.printer_backends.batch_helper.default_print_images_loop``. @@ -402,7 +414,18 @@ class PrinterBackend(Protocol): high_resolution: bool = False, half_cut: bool = True, ) -> None: - """Batch-print N images. Atomic semantics: success or all-fail. + """Batch-print N images — atomic-or-best-effort je nach Backend-Impl. + + Semantik haengt vom konkreten Backend ab: + - PTouchBackend via ptouch.print_multi: ATOMIC — auf Hardware-Ebene ein + einziger Print-Call. Bei Exception sind ggf. 0 oder ALLE Labels gedruckt, + niemals partial. + - Default-Loop (BrotherQLBackend, MockBackend) via + default_print_images_loop: BEST-EFFORT per item. Wenn item N + fehlschlaegt, koennen items 0..N-1 bereits physisch gedruckt sein. + Job-State-Handling muss damit umgehen (siehe Task 8 _process_batch). + (Copilot-Review C5 PR #106: vorher 'atomic semantics: success or all-fail' + war falsch fuer den default loop.) Phase 1k.2: Default-Loop ueber print_image() lebt in ``app.printer_backends.batch_helper.default_print_images_loop``. @@ -440,7 +463,7 @@ Tests will fail at this point — they expect Backend implementations to have `p ```bash git add backend/app/printer_backends/base.py -git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(printer_backends): PrinterBackend.print_images Protocol method (Phase 1k.2 Task 2) +git commit -m "feat(printer_backends): PrinterBackend.print_images Protocol method (Phase 1k.2 Task 2) Backend-Contract erweitert um batch-print Method. PTouchBackend ueberschreibt fuer ptouch.print_multi (Task 3), andere Backends delegieren an @@ -722,7 +745,7 @@ backend/.venv/bin/ruff format --check backend/app/printer_backends/ptouch_backen backend/.venv/bin/mypy backend/app/printer_backends/ptouch_backend.py 2>&1 | tail -3 git add backend/app/printer_backends/ptouch_backend.py backend/tests/unit/printer_backends/test_ptouch_backend.py -git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(ptouch): print_images via ptouch.print_multi (Phase 1k.2 Task 3) +git commit -m "feat(ptouch): print_images via ptouch.print_multi (Phase 1k.2 Task 3) PTouchBackend.print_images() ueberschreibt Protocol-Default mit echtem batch-printing via ptouch.LabelPrinter.print_multi: 1 Connection, 1 Pre-Roll, @@ -857,7 +880,7 @@ backend/.venv/bin/ruff check backend/app/printer_backends/brother_ql_backend.py backend/.venv/bin/ruff format --check backend/app/printer_backends/brother_ql_backend.py backend/tests/unit/printer_backends/test_brother_ql_backend.py git add backend/app/printer_backends/brother_ql_backend.py backend/tests/unit/printer_backends/test_brother_ql_backend.py -git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(brother_ql): print_images via default_print_images_loop (Phase 1k.2 Task 4) +git commit -m "feat(brother_ql): print_images via default_print_images_loop (Phase 1k.2 Task 4) QL-Series ist Endless-Tape ohne Half-Cut-Konzept. BrotherQLBackend.print_images delegiert an default_print_images_loop mit half_cut=False (capability-flag erzwingt). @@ -933,7 +956,7 @@ backend/.venv/bin/ruff check backend/app/printer_backends/mock_backend.py backend/.venv/bin/ruff format --check backend/app/printer_backends/mock_backend.py git add backend/app/printer_backends/mock_backend.py -git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(mock): MockBackend.print_images via default_print_images_loop (Phase 1k.2 Task 5) +git commit -m "feat(mock): MockBackend.print_images via default_print_images_loop (Phase 1k.2 Task 5) Refs #102" ``` @@ -1085,7 +1108,7 @@ backend/.venv/bin/ruff format --check backend/app/printer_models/pt.py backend/t backend/.venv/bin/mypy backend/app/printer_models/pt.py 2>&1 | tail -3 git add backend/app/printer_models/pt.py backend/tests/unit/printer_models/test_pt.py -git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(pt): _PTPQueuePrinter.print_images + bug-fix half_cut/last_page forwarding (Phase 1k.2 Task 6) +git commit -m "feat(pt): _PTPQueuePrinter.print_images + bug-fix half_cut/last_page forwarding (Phase 1k.2 Task 6) Bug discovered during Phase 1k.2 plan-writing: _PTPQueuePrinter.print_image forwarded only auto_cut + high_resolution. half_cut und last_page wurden aus @@ -1185,7 +1208,7 @@ backend/.venv/bin/ruff check backend/app/printer_models/ql.py backend/tests/unit backend/.venv/bin/ruff format --check backend/app/printer_models/ql.py backend/tests/unit/printer_models/test_ql.py git add backend/app/printer_models/ql.py backend/tests/unit/printer_models/test_ql.py -git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(ql): _QLQueuePrinter.print_images adapter (Phase 1k.2 Task 7) +git commit -m "feat(ql): _QLQueuePrinter.print_images adapter (Phase 1k.2 Task 7) QL erzwingt half_cut=False intern (capability-flag). Adapter-Konsistenz mit PT fuer Queue-BatchJob-Pfad. @@ -1622,9 +1645,41 @@ F) Add new `_process_batch` method to `PrintQueue` (after `_worker`): logger.info("Batch %s completed on %s", batch.batch_id, printer_id) except asyncio.CancelledError: raise + except PrinterError as exc: + # Copilot-Review C6 (PR #106): Konsistenz mit existing _worker — + # PrinterError-Subtypes muessen via _printer_error_to_record auf + # strukturierte error_code/error_detail gemapped werden. Plus: + # recoverable hardware errors (tape_mismatch, cover_open, offline) + # MUESSEN den Printer pausieren, sonst laufen Folge-Jobs ins gleiche + # Problem. + code, msg, detail = _printer_error_to_record(exc) + for job in jobs: + job.error_code = code + job.error_message = msg + job.error_detail = detail + job.error_msg = msg # legacy field sync + try: + JobStateMachine.transition(job, JobState.FAILED) + self._notify_state_change( + job, JobState.PRINTING, JobState.FAILED, + queue_depth=self._queue_depth(printer_id), + ) + except InvalidStateTransitionError: + logger.warning( + "Batch %s: failure-transition of %s failed (state=%s)", + batch.batch_id, job.id, job.state, + ) + await self._store.mark_failed(UUID(job.id), f"{code}: {msg}") + logger.exception( + "Batch %s: PrinterError on %s — all %d items marked failed (%s)", + batch.batch_id, printer_id, len(jobs), code, + ) + # Recoverable hardware error -> Printer pausieren (User-Interaktion noetig) + if isinstance(exc, _RECOVERABLE_PRINTER_ERRORS): + await self.pause_printer(printer_id, reason=code) except Exception as exc: + # Fallback fuer non-PrinterError exceptions error_msg = f"batch print failed: {exc}" - # Failure: alle Jobs PRINTING -> FAILED mit gemeinsamer Message for job in jobs: job.error_code = "batch_failed" job.error_message = error_msg @@ -1671,7 +1726,7 @@ backend/.venv/bin/ruff format --check backend/app/services/print_queue.py backen backend/.venv/bin/mypy backend/app/services/print_queue.py 2>&1 | tail -3 git add backend/app/services/print_queue.py backend/tests/unit/services/test_print_queue_batch.py -git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(queue): BatchJob + enqueue_batch + worker isinstance dispatch (Phase 1k.2 Task 8) +git commit -m "feat(queue): BatchJob + enqueue_batch + worker isinstance dispatch (Phase 1k.2 Task 8) Neuer BatchJob dataclass mit batch_id, printer_id, image_payloads (PNG bytes), job_ids[], tape_mm, options. PrintQueue.enqueue_batch validiert + serialisiert. @@ -1865,13 +1920,36 @@ async def _validate_item_get_tape_mm( service: PrintService, item: PrintRequest, ) -> int: - """Load template, return its tape_mm. Raises TemplateNotFoundError on miss.""" - # service has access to template_loader via private attr — use public helper. - template = service._loader.get(item.template_id) - return template.tape_mm + """Load template via public PrintService API, return tape_mm. + + Raises TemplateNotFoundError on miss. + + Copilot-Review C7 (PR #106): vorher hat dieser helper auf das private + Attribut service._loader zugegriffen. Plaene auf Internals brechen bei + Refactors. Stattdessen wird ein public Helper get_template_tape_mm auf + PrintService aufgerufen (Task 9 Step 4a ergaenzt diese Methode). + """ + return await service.get_template_tape_mm(item.template_id) ``` -(Note: `service._loader` private attribute — alternative: add `get_template_tape_mm` public method on PrintService. Decide based on code-quality reviewer feedback.) +**Zusaetzlicher Step 4a vor Step 4 (`submit_batch_job`): public Helper auf `PrintService`** + +In `backend/app/services/print_service.py` vor `submit_batch_job` adden: + +```python + async def get_template_tape_mm(self, template_id: str) -> int: + """Public helper: load template and return its tape_mm. + + Used by batch_dispatch to validate tape_mm consistency across batch items + without reaching into the private _loader attribute. (Copilot-Review C7 + PR #106.) + + Raises: + TemplateNotFoundError: wenn template_id nicht im TemplateLoader. + """ + template = self._loader.get(template_id) + return template.tape_mm +``` - [ ] **Step 4: Add `PrintService.submit_batch_job`** @@ -1904,32 +1982,26 @@ In `backend/app/services/print_service.py`, add method after `submit_print_job`: loaded_mm=preflight.loaded_tape_mm, ) - # 3. Resolve LabelData + Render — Gemini-Review G3 (PR #106): - # CPU-intensive Pillow-Operationen muessen ueber asyncio.to_thread - # ausgefuehrt werden, sonst blockiert der Event-Loop. asyncio.gather - # parallelisiert die N Renders (statt sequenziell N*~50ms zu warten). - async def _render_one(req: PrintRequest, tmpl: TemplateSchema) -> Image.Image: + # 3. Resolve LabelData ONCE per item, then render — Copilot-Review C8 + + # Gemini-Review G3 (PR #106): + # - label_data wird einmal pro Item resolved, fuer Render UND Persist + # wiederverwendet (vorher 2x: einmal fuer renderer, einmal fuer payload). + # - Pillow-Render via asyncio.to_thread (CPU-intensive, blockiert sonst Event-Loop). + # - asyncio.gather parallelisiert die N Resolve-und-Render Operationen. + async def _prepare_one( + req: PrintRequest, tmpl: TemplateSchema + ) -> tuple[Image.Image, dict[str, Any]]: label_data = await self._resolve_label_data(req) - return await asyncio.to_thread(self._renderer.render, tmpl, label_data) + image = await asyncio.to_thread(self._renderer.render, tmpl, label_data) + return image, label_data.model_dump() - images = list( - await asyncio.gather( - *[_render_one(r, t) for r, t in zip(requests, templates, strict=True)] - ) + prepared = await asyncio.gather( + *[_prepare_one(r, t) for r, t in zip(requests, templates, strict=True)] ) + images = [img for img, _ in prepared] + label_data_dumps = [dump for _, dump in prepared] # 4. Pre-allocate job UUIDs + persist in JobStore (analog submit_print_job) - # Gemini-Review G3 (PR #106): _resolve_label_data wird nur EINMAL pro - # Item gerufen — vorher Z.1903 + Z.1918 (Duplikat). label_data wird via - # parallele Helper-Coroutine wiederverwendet. - async def _resolve_for_payload(req: PrintRequest) -> dict[str, Any]: - ld = await self._resolve_label_data(req) - return ld.model_dump() - - label_data_dumps = await asyncio.gather( - *[_resolve_for_payload(r) for r in requests] - ) - job_ids: list[UUID] = [] for request, ld_dump in zip(requests, label_data_dumps, strict=True): job_id = uuid4() @@ -1981,7 +2053,7 @@ backend/.venv/bin/ruff format --check backend/app/services/batch_dispatch.py bac backend/.venv/bin/mypy backend/app/services/batch_dispatch.py backend/app/services/print_service.py 2>&1 | tail -3 git add backend/app/services/batch_dispatch.py backend/app/services/print_service.py backend/tests/unit/services/test_batch_dispatch.py -git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(batch): dispatch_batch refactor to atomic BatchJob (Phase 1k.2 Task 9) +git commit -m "feat(batch): dispatch_batch refactor to atomic BatchJob (Phase 1k.2 Task 9) dispatch_batch sammelt valide items (per-item template_not_found etc. weiter in errors[]), prueft mixed tape_mm vor Queue, ruft service.submit_batch_job @@ -2072,7 +2144,7 @@ backend/.venv/bin/ruff format --check backend/app/api/routes/batch.py backend/.venv/bin/mypy backend/app/api/routes/batch.py 2>&1 | tail -3 git add backend/app/api/routes/batch.py backend/tests/integration/api/ -git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(api): MixedTapeSizesError → 400 mixed_tape_sizes (Phase 1k.2 Task 10) +git commit -m "feat(api): MixedTapeSizesError → 400 mixed_tape_sizes (Phase 1k.2 Task 10) Refs #102" ``` @@ -2155,7 +2227,7 @@ async def test_post_batch_failure_marks_all_jobs_failed( backend/.venv/bin/python -m pytest backend/tests/integration/test_batch_endpoint_multi_label.py -v 2>&1 | tail -10 git add backend/tests/integration/test_batch_endpoint_multi_label.py -git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "test(batch): end-to-end multi-label batch integration test (Phase 1k.2 Task 11) +git commit -m "test(batch): end-to-end multi-label batch integration test (Phase 1k.2 Task 11) Refs #102" ``` @@ -2190,9 +2262,18 @@ import sys import httpx HUB_URL = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8000" -API_KEY = sys.argv[2] if len(sys.argv) > 2 else os.environ.get( - "PRINTER_HUB_WEBHOOK_API_KEY", "lh_pat_demo" +# Copilot-Review C9 (PR #106): kein hardcoded API-Key Default. Wenn weder +# CLI-Arg noch Env-Var gesetzt -> sofortiger Fehler mit klarer Meldung. +API_KEY = ( + sys.argv[2] if len(sys.argv) > 2 + else os.environ.get("PRINTER_HUB_WEBHOOK_API_KEY") ) +if not API_KEY: + print( + "ERROR: API key required. Set $PRINTER_HUB_WEBHOOK_API_KEY or pass as 2nd CLI arg.", + file=sys.stderr, + ) + sys.exit(2) def main() -> None: @@ -2227,7 +2308,7 @@ if __name__ == "__main__": ```bash git add backend/scripts/smoke_first_print_batch.py -git -c user.name="Björn Strausmann" -c user.email="strausmannservices@googlemail.com" commit -m "feat(scripts): smoke_first_print_batch.py — manual 4-item batch hardware-smoke (Phase 1k.2 Task 12) +git commit -m "feat(scripts): smoke_first_print_batch.py — manual 4-item batch hardware-smoke (Phase 1k.2 Task 12) Verify 5mm Half-Cut between batch items vs Brother iOS App output. @@ -2260,7 +2341,8 @@ Expected: clean (10 pre-existing import-untyped acceptable per Phase 1i baseline - [ ] **Step 3: Privacy + secret scan** ```bash -grep -rnE "172\.16\.[0-9]+\.[0-9]+|hhdocker" backend/ docs/ 2>&1 | grep -v "policies/privacy.md" | head -5 +# (Privacy-scan: identisch zum CI-Job — siehe .github/workflows/ci.yml Step "Privacy / secret scan". +# Implementer kann den CI-Step lokal nachbauen falls noetig.) ``` Expected: empty (no privacy violations). diff --git a/docs/superpowers/specs/2026-06-04-multi-label-batch-design.md b/docs/superpowers/specs/2026-06-04-multi-label-batch-design.md index 713987f..b9d0f17 100644 --- a/docs/superpowers/specs/2026-06-04-multi-label-batch-design.md +++ b/docs/superpowers/specs/2026-06-04-multi-label-batch-design.md @@ -185,21 +185,23 @@ class PrinterBackend(Protocol): high_resolution: bool = False, half_cut: bool = True, # Standard: Half-Cut zwischen Batch-Items ) -> None: - """Print N images as a batch. Default loops over print_image.""" - for i, img in enumerate(images): - is_last = i == len(images) - 1 - await self.print_image( - img, tape_spec, - auto_cut=auto_cut, - high_resolution=high_resolution, - half_cut=half_cut and not is_last, - last_page=is_last, - ) + """Print N images as a batch — semantics depend on backend impl. + + PT-Series via ptouch.print_multi: ATOMIC (success or all-fail at hardware level). + Default per-item loop (QL/Mock): best-effort per item — items 0..N-1 may already + be printed when item N fails. Job-state handling MUST treat partial-success + explicitly (see plan Task 8 _process_batch error handler). + """ + # (Copilot-Review C1 PR #106): Default-Impl als FREIE Funktion in + # batch_helper.py, NICHT als Protocol-default — Python typing.Protocol + # method bodies werden nicht von conforming classes geerbt. Backends + # die nicht ueberschreiben rufen explicit default_print_images_loop auf. + ... # siehe app.printer_backends.batch_helper.default_print_images_loop ``` -`PTouchBackend.print_images()` überschreibt diese Default-Impl und ruft direkt `_ptouch_print_multi`. +`PTouchBackend.print_images()` überschreibt mit echtem `ptouch.print_multi` (atomic). -`BrotherQLBackend` erbt Default und macht Status-quo-Verhalten (jedes Label einzeln). Damit ist Phase 1k.2 transparent für QL. +`BrotherQLBackend` und `MockBackend` haben eigene `print_images` Methoden die explicit `default_print_images_loop(self, ...)` aus `app.printer_backends.batch_helper` aufrufen — Python Protocols haben keine vererbten Default-Methoden, daher MUSS jeder Backend die Methode haben. ## Failure Modes @@ -231,7 +233,9 @@ except PtouchError as exc: ) ``` -**Hangar-Sicht:** Wenn ein Item-Job-Status `failed` zeigt, weiss Hangar nicht ob NUR dieses Item gescheitert ist oder der ganze Batch. Lösung: Job-Record bekommt zusätzliches Feld `batch_failure_mode: atomic | individual` damit Hangar die UI entsprechend gestalten kann ("Batch failed — 4 items affected"). +**Hangar-Sicht (1k.2-Scope):** Wenn ein Item-Job-Status `failed` zeigt, weiß Hangar zunächst nicht ob NUR dieses Item gescheitert ist oder der ganze Batch — sie sehen alle vier als failed. Das ist **akzeptabel für 1k.2** weil Hangar-Code unverändert bleibt und die UI bereits "alle 4 sind rot" anzeigt. + +**Optionale Erweiterung (Phase 1k.2-Followup oder Phase 1l, OUT OF 1k.2 SCOPE):** Job-Record könnte ein zusätzliches Feld `batch_failure_mode: atomic | individual` bekommen, plus Hangar-UI-Anpassung "Batch failed — 4 items affected". Das ist **kein 1k.2-Scope** (würde Hangar-Änderung erfordern, was 1k.2 explizit ausschließt — siehe "Backward-Compatibility"). (Copilot-Review C2 PR #106 — Scope-Konflikt aufgelöst durch Verschiebung in Followup.) ## Test-Strategie @@ -274,7 +278,9 @@ except PtouchError as exc: ## Referenzen -- Phase 1i Smoke-Test Empirie: [docs/site/operations/protokolle/2026-06-04-phase1i-smoke-test-empirie.md](https://docs.strausmann.cloud/operations/protokolle/2026-06-04-phase1i-smoke-test-empirie/) +(Copilot-Review C3 PR #106: Privacy-Konformität — keine `*.strausmann.*` Links. Phase 1i Smoke-Empirie ist im privaten Repo `homelab-management`, nicht in diesem OS-Repo gemirrort.) + +- Phase 1i Smoke-Test Empirie: internes Repo `homelab-management`, Pfad `docs/site/operations/protokolle/2026-06-04-phase1i-smoke-test-empirie.md` — Kurzfassung: PT-P750W druckt 22.5mm leeres Tape zwischen Multi-Label-Batches statt 5mm Half-Cut (Brother iOS App Verhalten). Empirisch verifiziert über 2 Smoke-Test-Iterationen (V1-V4 4-Varianten-Test) am 2026-06-04. - Hub PR #100 (last_page → feed groundwork): [https://github.com/strausmann/Label-Printer-Hub/pull/100](https://github.com/strausmann/Label-Printer-Hub/pull/100) - ptouch-py 1.1.0 `print_multi` Signatur: verifiziert in Container `docker exec label-printer-hub-backend python3 -c "import ptouch.printer; import inspect; print(inspect.signature(ptouch.printer.LabelPrinter.print_multi))"` - Issue #102 (1k.2): [https://github.com/strausmann/Label-Printer-Hub/issues/102](https://github.com/strausmann/Label-Printer-Hub/issues/102) From 968b6935de0064ff98e2f857495b444c416e1ab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 17:09:19 +0000 Subject: [PATCH 05/21] docs(plan): adresse Gemini Round-2 Review G-R2-1/2/3 (PR #106) 3 Runtime-Crashes im Plan-Code gefixt: G-R2-1 (_QLQueuePrinter.print_images): lookup_ql benoetigt tape_mm UND media_type. Mein Plan rief lookup_ql(tape_mm) ohne media_type -> TypeError. Fix: pop media_type aus options + pass through (analog _PTPQueuePrinter). G-R2-2 (_process_batch state inconsistency): Wenn job mid-flight cancelled ist, wirft JobStateMachine.transition InvalidStateTransitionError. Mein Plan loggte Warning aber rief trotzdem _store.mark_printing + mark_done -> DB sagt COMPLETED, in-memory CANCELLED. Fix: active_jobs[] sammelt nur erfolgreich transitioned jobs. Alle folgenden DB-Calls + post-print transitions nutzen active_jobs[]. G-R2-3 (submit_batch_job save_queued signature): Mein Plan rief save_queued mit kwargs (job_id=..., printer_id=..., template_key=..., payload=...) aber JobStore.save_queued erwartet eine DbJob-Instanz. Fix: DbJob model instanziieren + an save_queued uebergeben (konsistent mit submit_print_job). Import 'from app.models.job import Job as DbJob' ergaenzt. Refs #102, #106 Gemini R2 Review --- .../2026-06-04-multi-label-batch-plan.md | 63 ++++++++++++++----- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md b/docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md index fbdea44..d5a562f 100644 --- a/docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md +++ b/docs/superpowers/plans/2026-06-04-multi-label-batch-plan.md @@ -15,6 +15,7 @@ | 2026-06-04 | Initial | superpowers:writing-plans (Commit `8917d0e`) | | 2026-06-04 | **G1** In-Memory Job-Registrierung in `enqueue_batch` (sonst `KeyError` bei `get`/`wait_for_job`); parallele PNG-Serialisierung. **G2** `JobStateMachine.transition` + `_notify_state_change` in `_process_batch` (sonst SSE-Stille + hängende Waiter). **G3** `asyncio.to_thread` + `asyncio.gather` für CPU-intensive Renders in `submit_batch_job` (sonst Event-Loop-Block). Inline-Imports vervollständigt. | Gemini-Code-Assist Review PR #106 (medium-priority Findings) | | 2026-06-04 | 9 Copilot-Findings adressiert: C1 Protocol-default-method-Approach klargestellt (alle Backends explizit `print_images`, helper macht Loop), C2 `batch_failure_mode` Hangar-UI-Anpassung als Out-of-1k.2-Scope verschoben, C3 personalisierte Doku-Links durch Repo-Hinweise ersetzt (Privacy-Policy), C4 Real name+email in Plan-Commits ersetzt durch plain `git commit`, C5 `print_images` docstring präzisiert (atomic für PT, best-effort für default-loop), C6 `_process_batch` PrinterError-Path nutzt `_printer_error_to_record` + `pause_printer` für recoverable errors (Konsistenz mit `_worker`), C7 `_validate_item_get_tape_mm` nutzt neue public `PrintService.get_template_tape_mm` statt private `_loader` Zugriff, C8 `label_data` einmal pro Item resolved (Render+Persist teilen sich denselben Wert), C9 Smoke-Script ohne hardcoded API-Key-Default. Plus: Privacy-Scan-grep in Final-Verification umformuliert. | Copilot Review PR #106 (9 Findings) | +| 2026-06-04 | 3 Runtime-Bugs (R2) gefixt: **G-R2-1** `_QLQueuePrinter.print_images` ruft `lookup_ql(tape_mm)` ohne `media_type` → TypeError. Fix: pop media_type aus options + pass through. **G-R2-2** State-Inkonsistenz: `_process_batch` ruft `_store.mark_*` auch wenn `JobStateMachine.transition` failed (cancelled job mid-flight) → DB sagt COMPLETED, in-memory CANCELLED. Fix: `active_jobs[]` Liste nur mit erfolgreich transitioned jobs, alle folgenden DB-Calls + post-print transitions nutzen `active_jobs`, nicht `jobs`. **G-R2-3** `submit_batch_job` ruft `JobStore.save_queued(job_id=..., printer_id=..., ...)` mit kwargs → TypeError, erwartet `DbJob`-Instanz. Fix: `DbJob` model instanziieren + an save_queued übergeben (konsistent mit `submit_print_job`). Import `from app.models.job import Job as DbJob` ergänzt. | Gemini-Code-Assist Round-2 Review PR #106 (3 medium-priority Runtime-Crashes) | --- @@ -1188,7 +1189,11 @@ In `backend/app/printer_models/ql.py`, in `_QLQueuePrinter` class (after `print_ QL-Series unterstuetzt kein half_cut — Backend forciert intern False. """ - tape_spec = self._tape_registry.lookup_ql(tape_mm) + # Gemini-Review G-R2-1 (PR #106): lookup_ql benoetigt tape_mm UND + # media_type — analog zu _PTPQueuePrinter.print_image. Nur mit tape_mm + # waerf TypeError. + media_type = options.pop("media_type", self._default_media_type) + tape_spec = self._tape_registry.lookup_ql(tape_mm, media_type) await self._backend.print_images( images, tape_spec, @@ -1603,6 +1608,13 @@ F) Add new `_process_batch` method to `PrintQueue` (after `_worker`): # Gemini-Review G2: in-memory transitions + SSE-Events + DB persist. # QUEUED -> PRINTING fuer jeden Job. + # + # Gemini-Review G-R2-2 (PR #106): Wenn JobStateMachine.transition fails + # (z.B. job already CANCELLED), darf NICHT noch _store.mark_printing + # gerufen werden — sonst inkonsistent (in-memory CANCELLED vs DB PRINTING). + # Wir sammeln nur successfully transitioned jobs in active_jobs[] und + # nutzen die fuer alle folgenden DB-Calls + post-print transitions. + active_jobs: list[Job] = [] for job in jobs: try: JobStateMachine.transition(job, JobState.PRINTING) @@ -1610,12 +1622,17 @@ F) Add new `_process_batch` method to `PrintQueue` (after `_worker`): job, JobState.QUEUED, JobState.PRINTING, queue_depth=self._queue_depth(printer_id), ) + await self._store.mark_printing(UUID(job.id)) + active_jobs.append(job) except InvalidStateTransitionError: logger.warning( - "Batch %s: job %s skipped — state already %s", + "Batch %s: job %s skipped — state already %s (cancelled?)", batch.batch_id, job.id, job.state, ) - await self._store.mark_printing(UUID(job.id)) + + if not active_jobs: + logger.warning("Batch %s: 0 active jobs after transitions — skipping print", batch.batch_id) + return # Gemini-Review G1 (PR #106): Parallel image decode images = await asyncio.gather( @@ -1628,20 +1645,22 @@ F) Add new `_process_batch` method to `PrintQueue` (after `_worker`): tape_mm=batch.tape_mm, **batch.options, ) - # Success: alle Jobs PRINTING -> COMPLETED - for job in jobs: + # Success: alle active_jobs PRINTING -> COMPLETED. + # Gemini-Review G-R2-2 (PR #106): nur active_jobs, NICHT alle jobs — + # cancelled-mid-flight darf nicht ueberschrieben werden. + for job in active_jobs: try: JobStateMachine.transition(job, JobState.COMPLETED) self._notify_state_change( job, JobState.PRINTING, JobState.COMPLETED, queue_depth=self._queue_depth(printer_id), ) + await self._store.mark_done(UUID(job.id)) except InvalidStateTransitionError: logger.warning( "Batch %s: success-transition of %s failed (state=%s)", batch.batch_id, job.id, job.state, ) - await self._store.mark_done(UUID(job.id)) logger.info("Batch %s completed on %s", batch.batch_id, printer_id) except asyncio.CancelledError: raise @@ -1653,7 +1672,8 @@ F) Add new `_process_batch` method to `PrintQueue` (after `_worker`): # MUESSEN den Printer pausieren, sonst laufen Folge-Jobs ins gleiche # Problem. code, msg, detail = _printer_error_to_record(exc) - for job in jobs: + # Gemini-Review G-R2-2 (PR #106): nur active_jobs, NICHT alle jobs. + for job in active_jobs: job.error_code = code job.error_message = msg job.error_detail = detail @@ -1664,15 +1684,15 @@ F) Add new `_process_batch` method to `PrintQueue` (after `_worker`): job, JobState.PRINTING, JobState.FAILED, queue_depth=self._queue_depth(printer_id), ) + await self._store.mark_failed(UUID(job.id), f"{code}: {msg}") except InvalidStateTransitionError: logger.warning( "Batch %s: failure-transition of %s failed (state=%s)", batch.batch_id, job.id, job.state, ) - await self._store.mark_failed(UUID(job.id), f"{code}: {msg}") logger.exception( - "Batch %s: PrinterError on %s — all %d items marked failed (%s)", - batch.batch_id, printer_id, len(jobs), code, + "Batch %s: PrinterError on %s — %d items marked failed (%s)", + batch.batch_id, printer_id, len(active_jobs), code, ) # Recoverable hardware error -> Printer pausieren (User-Interaktion noetig) if isinstance(exc, _RECOVERABLE_PRINTER_ERRORS): @@ -1680,7 +1700,8 @@ F) Add new `_process_batch` method to `PrintQueue` (after `_worker`): except Exception as exc: # Fallback fuer non-PrinterError exceptions error_msg = f"batch print failed: {exc}" - for job in jobs: + # Gemini-Review G-R2-2 (PR #106): nur active_jobs. + for job in active_jobs: job.error_code = "batch_failed" job.error_message = error_msg job.error_msg = error_msg # legacy field sync @@ -1690,15 +1711,15 @@ F) Add new `_process_batch` method to `PrintQueue` (after `_worker`): job, JobState.PRINTING, JobState.FAILED, queue_depth=self._queue_depth(printer_id), ) + await self._store.mark_failed(UUID(job.id), error_msg) except InvalidStateTransitionError: logger.warning( "Batch %s: failure-transition of %s failed (state=%s)", batch.batch_id, job.id, job.state, ) - await self._store.mark_failed(UUID(job.id), error_msg) logger.exception( - "Batch %s failed on %s — all %d items marked failed", - batch.batch_id, printer_id, len(jobs), + "Batch %s failed on %s — %d items marked failed", + batch.batch_id, printer_id, len(active_jobs), ) ``` @@ -2001,12 +2022,16 @@ In `backend/app/services/print_service.py`, add method after `submit_print_job`: images = [img for img, _ in prepared] label_data_dumps = [dump for _, dump in prepared] - # 4. Pre-allocate job UUIDs + persist in JobStore (analog submit_print_job) + # 4. Pre-allocate job UUIDs + persist in JobStore (analog submit_print_job). + # Gemini-Review G-R2-3 (PR #106): JobStore.save_queued erwartet eine + # DbJob model instance, NICHT kwargs. Konsistent mit submit_print_job. + # Implementer: ggf. import-Pfad an existing submit_print_job orientieren + # (from app.models.job import Job as DbJob). job_ids: list[UUID] = [] for request, ld_dump in zip(requests, label_data_dumps, strict=True): job_id = uuid4() - await self._store.save_queued( - job_id=job_id, + db_job = DbJob( + id=job_id, printer_id=self._printer_id, template_key=request.template_id, payload={ @@ -2014,7 +2039,10 @@ In `backend/app/services/print_service.py`, add method after `submit_print_job`: "options": request.options.model_dump(), "label_data": ld_dump, }, + api_key_id=None, + source_ip=None, ) + await self._store.save_queued(db_job) job_ids.append(job_id) # 5. Enqueue as BatchJob @@ -2041,6 +2069,7 @@ from uuid import UUID, uuid4 from PIL import Image # nur falls noch nicht da +from app.models.job import Job as DbJob # Gemini-Review G-R2-3 PR #106 from app.schemas.template import TemplateSchema ``` From 54e1123ab1b6f2c69114a981092d44c5cfe7c521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 17:22:59 +0000 Subject: [PATCH 06/21] feat(printer_backends): default_print_images_loop helper (Phase 1k.2 Task 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backends ohne native batch-support (BrotherQLBackend, MockBackend) loopen per-item — Helper zentralisiert die half_cut + last_page Semantik analog Brother iOS App. Refs #102 --- backend/app/printer_backends/batch_helper.py | 61 +++++++ .../printer_backends/test_batch_helper.py | 151 ++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 backend/app/printer_backends/batch_helper.py create mode 100644 backend/tests/unit/printer_backends/test_batch_helper.py diff --git a/backend/app/printer_backends/batch_helper.py b/backend/app/printer_backends/batch_helper.py new file mode 100644 index 0000000..aea7b7b --- /dev/null +++ b/backend/app/printer_backends/batch_helper.py @@ -0,0 +1,61 @@ +"""Default batch-print loop for Backends without native batch support. + +Phase 1k.2: PTouchBackend overrides print_images() to use ptouch.print_multi() +for true atomic batch printing. BrotherQLBackend, MockBackend etc. delegate +their print_images() implementation to default_print_images_loop() here — +they loop over print_image() with correct half_cut + last_page semantics. + +Semantics match the Brother iOS App: half_cut=True between intermediate +items (5mm taktile Trennung), half_cut=False + last_page=True on the final +item (voller Cut zur Trennung vom nächsten Batch). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PIL import Image + + from app.models.tape import TapeSpec + from app.printer_backends.base import PrinterBackend + + +async def default_print_images_loop( + backend: PrinterBackend, + images: list[Image.Image], + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + half_cut: bool = True, +) -> None: + """Loop over print_image(); set half_cut + last_page per index. + + Args: + backend: PrinterBackend instance whose print_image() is called per item. + images: Rendered PIL Images, one per batch item, in print order. + tape_spec: Shared TapeSpec — all items in a batch share the loaded tape. + auto_cut: Forwarded unchanged to each print_image call. + high_resolution: Forwarded unchanged to each print_image call. + half_cut: If True, intermediate items get half_cut=True (5mm taktile + separation). Last item always gets half_cut=False so the cutter + performs a full cut for batch separation. + + Behaviour: + For each image at index i: + - is_last = (i == len(images) - 1) + - last_page = is_last (drives ptouch feed= → controls Pre-Roll) + - half_cut = half_cut and not is_last + """ + last_index = len(images) - 1 + for i, image in enumerate(images): + is_last = i == last_index + await backend.print_image( + image, + tape_spec, + auto_cut=auto_cut, + high_resolution=high_resolution, + half_cut=half_cut and not is_last, + last_page=is_last, + ) diff --git a/backend/tests/unit/printer_backends/test_batch_helper.py b/backend/tests/unit/printer_backends/test_batch_helper.py new file mode 100644 index 0000000..07bf795 --- /dev/null +++ b/backend/tests/unit/printer_backends/test_batch_helper.py @@ -0,0 +1,151 @@ +"""Unit tests for default_print_images_loop helper.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from app.models.tape import TapeSpec +from app.printer_backends.batch_helper import default_print_images_loop +from app.services.status_block import MediaType +from PIL import Image + + +@pytest.fixture +def tape_spec_12() -> TapeSpec: + return TapeSpec( + width_mm=12, + media_type=MediaType.LAMINATED, + print_area_pins=70, + print_area_dots=70, + bytes_per_raster=9, + min_length_mm=4.4, + max_length_mm=1000, + cutter_min_length_mm=24.5, + ) + + +@pytest.fixture +def three_images() -> list[Image.Image]: + return [Image.new("1", (600, 70), color=1) for _ in range(3)] + + +@pytest.mark.anyio +async def test_loops_print_image_for_each(three_images, tape_spec_12): + """default_print_images_loop calls print_image once per image.""" + backend = AsyncMock() + backend.print_image = AsyncMock() + + await default_print_images_loop( + backend, + three_images, + tape_spec_12, + auto_cut=True, + high_resolution=False, + half_cut=True, + ) + + assert backend.print_image.call_count == 3 + + +@pytest.mark.anyio +async def test_intermediate_items_get_half_cut_true_last_page_false(three_images, tape_spec_12): + """Items 0 and 1 (non-last): half_cut=True, last_page=False.""" + backend = AsyncMock() + backend.print_image = AsyncMock() + + await default_print_images_loop( + backend, + three_images, + tape_spec_12, + auto_cut=True, + high_resolution=False, + half_cut=True, + ) + + # Inspect kwargs of calls + calls = backend.print_image.call_args_list + assert calls[0].kwargs["half_cut"] is True + assert calls[0].kwargs["last_page"] is False + assert calls[1].kwargs["half_cut"] is True + assert calls[1].kwargs["last_page"] is False + + +@pytest.mark.anyio +async def test_last_item_gets_half_cut_false_last_page_true(three_images, tape_spec_12): + """Last item: half_cut=False (full cut), last_page=True.""" + backend = AsyncMock() + backend.print_image = AsyncMock() + + await default_print_images_loop( + backend, + three_images, + tape_spec_12, + auto_cut=True, + high_resolution=False, + half_cut=True, + ) + + calls = backend.print_image.call_args_list + assert calls[-1].kwargs["half_cut"] is False + assert calls[-1].kwargs["last_page"] is True + + +@pytest.mark.anyio +async def test_half_cut_false_disables_half_cut_globally(three_images, tape_spec_12): + """If caller passes half_cut=False, no intermediate item gets half_cut=True.""" + backend = AsyncMock() + backend.print_image = AsyncMock() + + await default_print_images_loop( + backend, + three_images, + tape_spec_12, + auto_cut=True, + high_resolution=False, + half_cut=False, + ) + + for call in backend.print_image.call_args_list: + assert call.kwargs["half_cut"] is False + + +@pytest.mark.anyio +async def test_single_image_gets_last_page_true(tape_spec_12): + """Single-item batch: 1 print_image call with last_page=True.""" + backend = AsyncMock() + backend.print_image = AsyncMock() + one_image = [Image.new("1", (600, 70), color=1)] + + await default_print_images_loop( + backend, + one_image, + tape_spec_12, + auto_cut=True, + high_resolution=False, + half_cut=True, + ) + + assert backend.print_image.call_count == 1 + assert backend.print_image.call_args.kwargs["last_page"] is True + assert backend.print_image.call_args.kwargs["half_cut"] is False + + +@pytest.mark.anyio +async def test_propagates_first_print_image_exception(three_images, tape_spec_12): + """If print_image raises on item N, no further items are attempted.""" + backend = AsyncMock() + backend.print_image = AsyncMock(side_effect=[None, RuntimeError("printer offline"), None]) + + with pytest.raises(RuntimeError, match="printer offline"): + await default_print_images_loop( + backend, + three_images, + tape_spec_12, + auto_cut=True, + high_resolution=False, + half_cut=True, + ) + + # Only the first two were attempted; third was not. + assert backend.print_image.call_count == 2 From e93f94515e497f51a7b73f8be98c55dac9a0121d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 17:26:45 +0000 Subject: [PATCH 07/21] feat(printer_backends): PrinterBackend.print_images Protocol method (Phase 1k.2 Task 2) Backend-Contract erweitert um batch-print Method. PTouchBackend ueberschreibt fuer ptouch.print_multi (Task 3), andere Backends delegieren an default_print_images_loop helper (Tasks 4, 5). Refs #102 --- backend/app/printer_backends/base.py | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/backend/app/printer_backends/base.py b/backend/app/printer_backends/base.py index 5002a47..7f17696 100644 --- a/backend/app/printer_backends/base.py +++ b/backend/app/printer_backends/base.py @@ -5,6 +5,9 @@ in First-Print, and opening a second TCP/9100 session in parallel with ptouch would hit Brother's single-session limit (Resource Busy). The hook can be added back additively if a future caller needs it. + +Phase 1k.2: print_images() added for batch printing via ptouch.print_multi +on PT-Series. Other backends delegate to default_print_images_loop helper. """ from __future__ import annotations @@ -45,5 +48,43 @@ async def print_image( mindestens ein weiteres Item (kein Cut zwischen). """ + async def print_images( + self, + images: list[Image.Image], + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + half_cut: bool = True, + ) -> None: + """Batch-print N images — atomic-or-best-effort je nach Backend-Impl. + + Semantik haengt vom konkreten Backend ab: + - PTouchBackend via ptouch.print_multi: ATOMIC — auf Hardware-Ebene ein + einziger Print-Call. Bei Exception sind ggf. 0 oder ALLE Labels gedruckt, + niemals partial. + - Default-Loop (BrotherQLBackend, MockBackend) via + default_print_images_loop: BEST-EFFORT per item. Wenn item N + fehlschlaegt, koennen items 0..N-1 bereits physisch gedruckt sein. + Job-State-Handling muss damit umgehen (siehe Task 8 _process_batch). + (Copilot-Review C5 PR #106: vorher 'atomic semantics: success or all-fail' + war falsch fuer den default loop.) + + Phase 1k.2: Default-Loop ueber print_image() lebt in + ``app.printer_backends.batch_helper.default_print_images_loop``. + PTouchBackend ueberschreibt fuer ptouch.print_multi() (echtes + batch-fertig mit 5mm Half-Cut zwischen Labels statt 22.5mm Pre-Roll). + BrotherQLBackend und MockBackend delegieren explizit an den + default_print_images_loop helper. + + Args: + images: PIL Images in print order. len(images) >= 1. + tape_spec: Shared TapeSpec — alle Items teilen das geladene Tape. + auto_cut: True = Drucker schneidet am Ende des Batches. + high_resolution: PT-Series HiRes-Mode. + half_cut: True = 5mm taktile Separation zwischen Items (PT-Series). + Letztes Item bekommt immer Voll-Cut (half_cut=False intern). + """ + async def query_status(self) -> StatusBlock: """Send ESC i S, parse the 32-byte reply, return a StatusBlock.""" From 5cbf9555e6ad92f688fac2592b182aa2785c53b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 17:38:56 +0000 Subject: [PATCH 08/21] fix(printer_backends): print_images stubs to fix Protocol isinstance regressions (Phase 1k.2 Task 2 fixup) Code-quality review on commit e93f945 found 3 test failures: adding print_images to the Protocol broke isinstance(obj, PrinterBackend) for backends that did not yet implement it. Fixes: - _Compliant test fixture (test_base.py): minimal pass stub - MockBackend + PTouchBackend: print_images that raises NotImplementedError (real impl follows in Task 5 and Task 3 respectively) - base.py: docstring notes half_cut=True default vs print_image's False - base.py: module docstring 'two-method' -> 'three-method' surface Refs #102 --- backend/app/printer_backends/base.py | 12 +++++++----- backend/app/printer_backends/mock_backend.py | 11 +++++++++++ backend/app/printer_backends/ptouch_backend.py | 11 +++++++++++ backend/tests/unit/printer_backends/test_base.py | 11 +++++++++++ 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/backend/app/printer_backends/base.py b/backend/app/printer_backends/base.py index 7f17696..cce9a42 100644 --- a/backend/app/printer_backends/base.py +++ b/backend/app/printer_backends/base.py @@ -1,10 +1,10 @@ """PrinterBackend Protocol — transport contract used by drivers. -Two-method surface (print_image + query_status). A raw `send_bytes` escape -hatch was deliberately removed during design: there is no concrete caller -in First-Print, and opening a second TCP/9100 session in parallel with -ptouch would hit Brother's single-session limit (Resource Busy). The -hook can be added back additively if a future caller needs it. +Three-method surface (print_image, print_images, query_status). A raw +`send_bytes` escape hatch was deliberately removed during design: there is no +concrete caller in First-Print, and opening a second TCP/9100 session in +parallel with ptouch would hit Brother's single-session limit (Resource Busy). +The hook can be added back additively if a future caller needs it. Phase 1k.2: print_images() added for batch printing via ptouch.print_multi on PT-Series. Other backends delegate to default_print_images_loop helper. @@ -84,6 +84,8 @@ async def print_images( high_resolution: PT-Series HiRes-Mode. half_cut: True = 5mm taktile Separation zwischen Items (PT-Series). Letztes Item bekommt immer Voll-Cut (half_cut=False intern). + Default True differs from print_image (default False): in batch + context, inter-label half-cut separation is the expected mode. """ async def query_status(self) -> StatusBlock: diff --git a/backend/app/printer_backends/mock_backend.py b/backend/app/printer_backends/mock_backend.py index f7dbb10..bb6d0d7 100644 --- a/backend/app/printer_backends/mock_backend.py +++ b/backend/app/printer_backends/mock_backend.py @@ -150,3 +150,14 @@ async def print_image( loaded_mm=status.loaded_tape_mm, ) self.printed_images.append(image.copy()) + + async def print_images( + self, + images: list[Image.Image], + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + half_cut: bool = True, + ) -> None: + raise NotImplementedError("implemented in Task 5") diff --git a/backend/app/printer_backends/ptouch_backend.py b/backend/app/printer_backends/ptouch_backend.py index 8b4add9..ede6c99 100644 --- a/backend/app/printer_backends/ptouch_backend.py +++ b/backend/app/printer_backends/ptouch_backend.py @@ -246,3 +246,14 @@ async def print_image( ptouch.PrinterConnectionError, ) as exc: raise PrinterOfflineError(str(exc)) from exc + + async def print_images( + self, + images: list[Image.Image], + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + half_cut: bool = True, + ) -> None: + raise NotImplementedError("implemented in Task 3") diff --git a/backend/tests/unit/printer_backends/test_base.py b/backend/tests/unit/printer_backends/test_base.py index fa5234d..9c4f985 100644 --- a/backend/tests/unit/printer_backends/test_base.py +++ b/backend/tests/unit/printer_backends/test_base.py @@ -23,6 +23,17 @@ async def print_image( ) -> None: return None + async def print_images( + self, + images: list[Image.Image], + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + half_cut: bool = True, + ) -> None: + pass # structural compliance only — behaviour tested via real backends + async def query_status(self) -> StatusBlock: # pragma: no cover - shape only # Use the test helper to construct a valid StatusBlock with all 18 fields from tests._helpers.status import make_status_block From c0d15f05fb291c701b15a86339357f51efca7be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 17:47:04 +0000 Subject: [PATCH 09/21] feat(ptouch): print_images via ptouch.print_multi (Phase 1k.2 Task 3) PTouchBackend.print_images() ueberschreibt Protocol-Default mit echtem batch-printing via ptouch.LabelPrinter.print_multi: 1 Connection, 1 Pre-Roll, 5mm Half-Cut zwischen Labels statt 22.5mm zwischen jedem. Replaces Task 2 stub (NotImplementedError) with real implementation. TypeError-Fallback: Aelterer ptouch-Lib ohne print_multi degradet zu per-Item-Loop (Pre-Phase-1k.2 Verhalten). Refs #102 --- .../app/printer_backends/ptouch_backend.py | 95 +++++++++++- .../printer_backends/test_ptouch_backend.py | 139 ++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) diff --git a/backend/app/printer_backends/ptouch_backend.py b/backend/app/printer_backends/ptouch_backend.py index ede6c99..b5394b4 100644 --- a/backend/app/printer_backends/ptouch_backend.py +++ b/backend/app/printer_backends/ptouch_backend.py @@ -108,6 +108,61 @@ def _ptouch_print( # pragma: no cover - real-hardware-only, tests monkeypatch t printer.print(label, auto_cut=auto_cut, high_resolution=high_resolution) +def _ptouch_print_multi( # pragma: no cover - real-hardware-only, tests monkeypatch this + host: str, + port: int, + images: list[Image.Image], + tape_mm: int, + *, + model_id: str, + auto_cut: bool, + high_resolution: bool, + half_cut: bool, +) -> None: + """Synchronous helper for batch printing via ptouch.LabelPrinter.print_multi. + + ptouch-py 1.1.0: LabelPrinter.print_multi(labels, margin_mm=None, + high_resolution=None, half_cut=True) — 1 Connection, 1 Init (=1 Pre-Roll), + 5mm Half-Cut zwischen Labels, voller Cut am Ende. + + Excluded from coverage: real-hardware-only. Tests monkeypatch this module-level + function. Hardware verification per scripts/smoke_first_print_batch.py (Task 11). + """ + try: + tape_cls = _PTOUCH_TAPE_CLASSES[tape_mm] + except KeyError as exc: + raise PrintFailedError(f"No ptouch tape class for {tape_mm}mm") from exc + try: + printer_cls = _PTOUCH_PRINTER_CLASSES[model_id] + except KeyError as exc: + raise PrintFailedError(f"No ptouch printer class for model {model_id!r}") from exc + + connection = ptouch.ConnectionNetwork(host, port=port, timeout=10.0) + printer = printer_cls(connection=connection, high_resolution=high_resolution) + labels = [ptouch.Label(image=img, tape=tape_cls) for img in images] + try: + printer.print_multi( + labels, + high_resolution=high_resolution, + half_cut=half_cut, + ) + except TypeError: + # Älterer ptouch-Lib (<1.1) hat kein print_multi — Fallback: per-Item-Loop + # mit print(). Degraded zu Phase-1i-Verhalten (22.5mm Pre-Roll pro Item). + for i, label in enumerate(labels): + is_last = i == len(labels) - 1 + try: + printer.print( + label, + auto_cut=auto_cut, + high_resolution=high_resolution, + half_cut=half_cut and not is_last, + feed=is_last, + ) + except TypeError: + printer.print(label, auto_cut=auto_cut, high_resolution=high_resolution) + + class PTouchBackend: """PrinterBackend backed by the ptouch library.""" @@ -256,4 +311,42 @@ async def print_images( high_resolution: bool = False, half_cut: bool = True, ) -> None: - raise NotImplementedError("implemented in Task 3") + """Batch-print via ptouch.LabelPrinter.print_multi — atomic. + + Pre-Print: SNMP preflight 1x am Batch-Anfang. Bei Tape-Mismatch wird + TapeMismatchError sofort geworfen, kein print_multi-Call. + + Phase 1k.2: ersetzt N separate ptouch.print() Calls durch 1 print_multi. + Resultat: 5mm Half-Cut zwischen Labels statt 22.5mm Pre-Roll. + """ + if not images: + raise ValueError("print_images requires at least one image") + + preflight = await self.preflight_check() + if preflight.loaded_tape_mm != tape_spec.width_mm: + raise TapeMismatchError( + expected_mm=tape_spec.width_mm, + loaded_mm=preflight.loaded_tape_mm, + ) + + try: + await asyncio.to_thread( + _ptouch_print_multi, + self.host, + self._port, + images, + tape_spec.width_mm, + model_id=self._model_id, + auto_cut=auto_cut, + high_resolution=high_resolution, + half_cut=half_cut, + ) + except (ptouch.PrinterWriteError, ptouch.PrinterPermissionError) as exc: + raise PrintFailedError(str(exc)) from exc + except ( + ptouch.PrinterNetworkError, + ptouch.PrinterTimeoutError, + ptouch.PrinterNotFoundError, + ptouch.PrinterConnectionError, + ) as exc: + raise PrinterOfflineError(str(exc)) from exc diff --git a/backend/tests/unit/printer_backends/test_ptouch_backend.py b/backend/tests/unit/printer_backends/test_ptouch_backend.py index 15f8217..9d3be43 100644 --- a/backend/tests/unit/printer_backends/test_ptouch_backend.py +++ b/backend/tests/unit/printer_backends/test_ptouch_backend.py @@ -656,3 +656,142 @@ def print(self, label: Any, **kwargs: Any) -> None: assert captured.get("feed") is True, ( f"_ptouch_print() ohne last_page muss feed=True (Default) senden, got: {captured}" ) + + +# --------------------------------------------------------------------------- +# Phase 1k.2: print_images batch via ptouch.print_multi +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_print_images_calls_ptouch_print_multi(monkeypatch): + """PTouchBackend.print_images → _ptouch_print_multi with all labels.""" + from app.printer_backends.ptouch_backend import PTouchBackend + from app.printer_backends.snmp_helper import PreflightStatus + from app.services.status_block import MediaType + + captured: dict[str, object] = {} + + async def fake_preflight(self, **kw): + return PreflightStatus(hr_printer_status="idle", loaded_tape_mm=12, error_flags=[]) + + def fake_ptouch_print_multi(host, port, images, tape_mm, **kwargs): + captured["host"] = host + captured["port"] = port + captured["num_images"] = len(images) + captured["tape_mm"] = tape_mm + captured.update(kwargs) + + monkeypatch.setattr(PTouchBackend, "preflight_check", fake_preflight) + monkeypatch.setattr( + "app.printer_backends.ptouch_backend._ptouch_print_multi", + fake_ptouch_print_multi, + ) + + backend = PTouchBackend(host="192.0.2.10", model_id="PT-P750W") + tape_spec = TapeSpec( + width_mm=12, + media_type=MediaType.LAMINATED, + print_area_pins=70, + print_area_dots=70, + bytes_per_raster=9, + min_length_mm=4.4, + max_length_mm=1000, + cutter_min_length_mm=12.0, + ) + images = [Image.new("1", (600, 70), color=1) for _ in range(3)] + + await backend.print_images( + images, + tape_spec, + auto_cut=True, + high_resolution=False, + half_cut=True, + ) + + assert captured["host"] == "192.0.2.10" + assert captured["port"] == 9100 + assert captured["num_images"] == 3 + assert captured["tape_mm"] == 12 + assert captured["model_id"] == "PT-P750W" + assert captured["auto_cut"] is True + assert captured["half_cut"] is True + assert captured["high_resolution"] is False + + +@pytest.mark.anyio +async def test_print_images_raises_tape_mismatch_at_batch_start(monkeypatch): + """If preflight tape != tape_spec.width_mm, batch fails atomically before print.""" + from app.printer_backends.exceptions import TapeMismatchError + from app.printer_backends.ptouch_backend import PTouchBackend + from app.printer_backends.snmp_helper import PreflightStatus + from app.services.status_block import MediaType + + async def fake_preflight(self, **kw): + return PreflightStatus(hr_printer_status="idle", loaded_tape_mm=18, error_flags=[]) + + monkeypatch.setattr(PTouchBackend, "preflight_check", fake_preflight) + # _ptouch_print_multi must NOT be called + called = False + + def fake_pm(*a, **kw): + nonlocal called + called = True + + monkeypatch.setattr("app.printer_backends.ptouch_backend._ptouch_print_multi", fake_pm) + + backend = PTouchBackend(host="192.0.2.10", model_id="PT-P750W") + tape_spec = TapeSpec( + width_mm=12, + media_type=MediaType.LAMINATED, + print_area_pins=70, + print_area_dots=70, + bytes_per_raster=9, + min_length_mm=4.4, + max_length_mm=1000, + cutter_min_length_mm=12.0, + ) + images = [Image.new("1", (600, 70), color=1) for _ in range(2)] + + with pytest.raises(TapeMismatchError): + await backend.print_images(images, tape_spec, half_cut=True) + assert called is False + + +def test_ptouch_print_multi_passes_labels_array(monkeypatch): + """_ptouch_print_multi constructs Labels[] and calls LabelPrinter.print_multi.""" + from app.printer_backends.ptouch_backend import _ptouch_print_multi + + captured: dict[str, object] = {} + + class FakeLabelPrinter: + def __init__(self, *a, **kw): + pass + + def print_multi(self, labels, **kwargs): + captured["num_labels"] = len(labels) + captured.update(kwargs) + + import app.printer_backends.ptouch_backend as _ptouch_mod + + monkeypatch.setitem( + _ptouch_mod._PTOUCH_PRINTER_CLASSES, + "PT-P750W", + FakeLabelPrinter, + ) + + images = [Image.new("1", (600, 70), color=1) for _ in range(4)] + _ptouch_print_multi( + host="192.0.2.10", + port=9100, + images=images, + tape_mm=12, + model_id="PT-P750W", + auto_cut=True, + high_resolution=False, + half_cut=True, + ) + + assert captured["num_labels"] == 4 + assert captured["half_cut"] is True + assert captured["high_resolution"] is False From f56e44c5fc786067277ae9a806a0fef49719e48d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 17:54:07 +0000 Subject: [PATCH 10/21] feat(brother_ql): print_images via default_print_images_loop (Phase 1k.2 Task 4) QL-Series ist Endless-Tape ohne Half-Cut-Konzept. BrotherQLBackend.print_images delegiert an default_print_images_loop mit half_cut=False (capability-flag erzwingt). Phase 1k.2 Architektur-Konsistenz: alle Backends bieten print_images. Refs #102 --- .../printer_backends/brother_ql_backend.py | 27 +++++++++++++ .../test_brother_ql_backend.py | 39 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/backend/app/printer_backends/brother_ql_backend.py b/backend/app/printer_backends/brother_ql_backend.py index 4debb42..7f13232 100644 --- a/backend/app/printer_backends/brother_ql_backend.py +++ b/backend/app/printer_backends/brother_ql_backend.py @@ -19,6 +19,7 @@ from PIL import Image from app.models.tape import TapeSpec +from app.printer_backends.batch_helper import default_print_images_loop from app.printer_backends.exceptions import ( PrinterCoverOpenError, PrinterOfflineError, @@ -121,6 +122,32 @@ async def print_image( blocking=True, ) + async def print_images( + self, + images: list[Image.Image], + tape_spec: TapeSpec, + *, + auto_cut: bool = True, + high_resolution: bool = False, + half_cut: bool = True, # noqa: ARG002 + ) -> None: + """Batch-print N images via per-item Loop. + + brother_ql Lib hat kein print_multi-Equivalent — QL ist Endless-Tape, + kein Half-Cut-Konzept zwischen Labels (jedes Label wird vom Druckkopf + ausgegeben und manuell abgeschnitten). Delegiert an + default_print_images_loop mit half_cut=False (QL ignoriert das Argument). + """ + # QL-Series: half_cut existiert nicht. Backend-Capability-Flag erzwingt False. + await default_print_images_loop( + self, + images, + tape_spec, + auto_cut=auto_cut, + high_resolution=high_resolution, + half_cut=False, + ) + async def preflight_check( self, *, diff --git a/backend/tests/unit/printer_backends/test_brother_ql_backend.py b/backend/tests/unit/printer_backends/test_brother_ql_backend.py index 2964862..f03d5d0 100644 --- a/backend/tests/unit/printer_backends/test_brother_ql_backend.py +++ b/backend/tests/unit/printer_backends/test_brother_ql_backend.py @@ -123,6 +123,45 @@ async def fake_preflight( await backend.preflight_check() +@pytest.mark.anyio +async def test_print_images_delegates_to_loop_helper( + monkeypatch: pytest.MonkeyPatch, + tape_spec_62: TapeSpec, +) -> None: + """BrotherQLBackend.print_images delegates to default_print_images_loop with half_cut=False.""" + captured: dict[str, object] = {} + + async def fake_loop(backend, images, tape_spec, **kwargs): + captured["backend"] = backend + captured["num_images"] = len(images) + captured["tape_mm"] = tape_spec.width_mm + captured.update(kwargs) + + monkeypatch.setattr( + "app.printer_backends.brother_ql_backend.default_print_images_loop", + fake_loop, + ) + + backend = BrotherQLBackend(host="192.0.2.11", model_id="QL-820NWB") + images = [Image.new("1", (696, 200), color=1) for _ in range(3)] + + await backend.print_images( + images, + tape_spec_62, + auto_cut=True, + high_resolution=False, + half_cut=True, # caller passes True; QL erzwingt False + ) + + assert captured["backend"] is backend + assert captured["num_images"] == 3 + assert captured["tape_mm"] == 62 + assert captured["auto_cut"] is True + assert captured["high_resolution"] is False + # QL-Series hat kein Half-Cut — Backend-Capability erzwingt half_cut=False + assert captured["half_cut"] is False + + @pytest.mark.anyio async def test_preflight_check_raises_offline_on_snmp_error( monkeypatch: pytest.MonkeyPatch, From 4ebdbeec33901ac0ef19270c2ea494f8f605b9a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 18:02:58 +0000 Subject: [PATCH 11/21] feat(mock): MockBackend.print_images via default_print_images_loop (Phase 1k.2 Task 5) Replaces Task 2 fix-up stub (NotImplementedError) mit echter Delegation an default_print_images_loop helper. Test-friendly impl: print_image() Recording-Verhalten bleibt erhalten. Refs #102 --- backend/app/printer_backends/mock_backend.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/backend/app/printer_backends/mock_backend.py b/backend/app/printer_backends/mock_backend.py index bb6d0d7..a2a8c16 100644 --- a/backend/app/printer_backends/mock_backend.py +++ b/backend/app/printer_backends/mock_backend.py @@ -12,6 +12,7 @@ from PIL import Image from app.models.tape import TapeSpec +from app.printer_backends.batch_helper import default_print_images_loop from app.printer_backends.exceptions import ( PrinterCoverOpenError, PrinterOfflineError, @@ -160,4 +161,17 @@ async def print_images( high_resolution: bool = False, half_cut: bool = True, ) -> None: - raise NotImplementedError("implemented in Task 5") + """Test-friendly Mock-Implementation via default_print_images_loop. + + Records each print_image call (because self.print_image is the existing + Mock recording method). Phase 1k.2 Task 5: replaces NotImplementedError + stub from Task 2 fix-up commit. + """ + await default_print_images_loop( + self, + images, + tape_spec, + auto_cut=auto_cut, + high_resolution=high_resolution, + half_cut=half_cut, + ) From 15ef3106357181ab1aae38bb444d48d265306c52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 18:14:59 +0000 Subject: [PATCH 12/21] feat(pt): _PTPQueuePrinter.print_images + bug-fix half_cut/last_page forwarding (Phase 1k.2 Task 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug discovered during Phase 1k.2 plan-writing: _PTPQueuePrinter.print_image forwarded only auto_cut + high_resolution. half_cut und last_page wurden aus options dict geholt aber NIE an backend.print_image() weitergeleitet. PR #100 fix landete nie bei ptouch lib. Fix: explicit options.pop für half_cut + last_page. Plus neue print_images() Adapter-Methode für Queue-BatchJob-Pfad. Refs #102, regression-fixes PR #100 (silent drop in adapter layer) --- backend/app/printer_models/pt.py | 23 +++++++ .../unit/printer_models/test_pt_driver.py | 69 +++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/backend/app/printer_models/pt.py b/backend/app/printer_models/pt.py index 06c5a09..9077330 100644 --- a/backend/app/printer_models/pt.py +++ b/backend/app/printer_models/pt.py @@ -256,11 +256,34 @@ def __init__( async def print_image(self, image: Image.Image, *, tape_mm: int, **options: Any) -> None: media_type = options.pop("media_type", self._default_media_type) tape_spec = self._tape_registry.lookup_pt(tape_mm, media_type) + # Phase 1k.2 Bug-Fix: half_cut + last_page wurden vorher NICHT forwarded. + # PR #100 Fix (last_page→feed) landete in PTouchBackend, aber der Adapter + # hat beide Kwargs still aus options.pop() geworfen ohne sie weiterzureichen. await self._backend.print_image( image, tape_spec, auto_cut=bool(options.pop("auto_cut", True)), high_resolution=bool(options.pop("high_resolution", False)), + half_cut=bool(options.pop("half_cut", False)), + last_page=bool(options.pop("last_page", True)), + ) + + async def print_images( + self, + images: list[Image.Image], + *, + tape_mm: int, + **options: Any, + ) -> None: + """Phase 1k.2: Adapter-Methode für Queue-BatchJob → backend.print_images.""" + media_type = options.pop("media_type", self._default_media_type) + tape_spec = self._tape_registry.lookup_pt(tape_mm, media_type) + await self._backend.print_images( + images, + tape_spec, + auto_cut=bool(options.pop("auto_cut", True)), + high_resolution=bool(options.pop("high_resolution", False)), + half_cut=bool(options.pop("half_cut", True)), ) diff --git a/backend/tests/unit/printer_models/test_pt_driver.py b/backend/tests/unit/printer_models/test_pt_driver.py index 251f418..3112de7 100644 --- a/backend/tests/unit/printer_models/test_pt_driver.py +++ b/backend/tests/unit/printer_models/test_pt_driver.py @@ -111,3 +111,72 @@ async def test_queue_printer_default_media_type_override( qp = driver.make_queue_printer(tape_registry, default_media_type=MediaType.LAMINATED) image = Image.new("1", (200, 128)) await qp.print_image(image, tape_mm=24) + + +# --------------------------------------------------------------------------- +# Phase 1k.2 Task 6: print_images adapter + PR #100 bug-fix +# (half_cut / last_page forwarding) +# --------------------------------------------------------------------------- + + +async def test_ptp_queue_printer_print_image_forwards_half_cut_last_page() -> None: + """REGRESSION: print_image (single) must forward half_cut + last_page to backend. + + PR #100 fix for last_page→feed landed in PTouchBackend, but the + _PTPQueuePrinter adapter silently dropped both kwargs. Phase 1i + smoke-test 22.5mm pre-roll bug was caused by this silent drop. + """ + from unittest.mock import AsyncMock, MagicMock + + stub_backend = MagicMock() + stub_backend.print_image = AsyncMock() + stub_backend.half_cut_supported = True + # Make tape-registry lookup succeed for tape_mm=12, MediaType.LAMINATED + stub_tape_registry = TapeRegistry() + + driver = PTP750WDriver(backend=stub_backend) + qp = driver.make_queue_printer(stub_tape_registry, default_media_type=MediaType.LAMINATED) + + image = Image.new("1", (600, 70), color=1) + await qp.print_image( + image, + tape_mm=12, + auto_cut=True, + high_resolution=False, + half_cut=True, + last_page=False, + ) + + stub_backend.print_image.assert_awaited_once() + call = stub_backend.print_image.call_args + assert call.kwargs["half_cut"] is True, "half_cut must be forwarded to backend" + assert call.kwargs["last_page"] is False, "last_page must be forwarded to backend" + + +async def test_ptp_queue_printer_print_images_forwards_to_backend() -> None: + """_PTPQueuePrinter.print_images calls backend.print_images with tape_spec + kwargs.""" + from unittest.mock import AsyncMock, MagicMock + + stub_backend = MagicMock() + stub_backend.print_images = AsyncMock() + stub_backend.half_cut_supported = True + stub_tape_registry = TapeRegistry() + + driver = PTP750WDriver(backend=stub_backend) + qp = driver.make_queue_printer(stub_tape_registry, default_media_type=MediaType.LAMINATED) + + images = [Image.new("1", (600, 70), color=1) for _ in range(3)] + await qp.print_images( + images, + tape_mm=12, + auto_cut=True, + high_resolution=False, + half_cut=True, + ) + + stub_backend.print_images.assert_awaited_once() + call = stub_backend.print_images.call_args + assert call.args[0] is images, "images list must be passed as first positional arg" + assert call.args[1].width_mm == 12, "tape_spec.width_mm must match tape_mm" + assert call.kwargs["auto_cut"] is True + assert call.kwargs["half_cut"] is True From cb1e317aac1117fec5a4b42413f9f5593110171d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 18:28:52 +0000 Subject: [PATCH 13/21] feat(ql): _QLQueuePrinter.print_images adapter (Phase 1k.2 Task 7) QL erzwingt half_cut=False intern (capability-flag). Adapter-Konsistenz mit PT fuer Queue-BatchJob-Pfad. Gemini-Review G-R2-1: lookup_ql benoetigt tape_mm UND media_type (gleiche Konvention wie lookup_pt). Refs #102, #106 --- backend/app/printer_models/ql.py | 26 +++++++ .../unit/printer_models/test_ql_driver.py | 77 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 backend/tests/unit/printer_models/test_ql_driver.py diff --git a/backend/app/printer_models/ql.py b/backend/app/printer_models/ql.py index 6f74204..af4b8ab 100644 --- a/backend/app/printer_models/ql.py +++ b/backend/app/printer_models/ql.py @@ -219,6 +219,32 @@ async def print_image(self, image: Image.Image, *, tape_mm: int, **options: Any) high_resolution=bool(options.pop("high_resolution", False)), ) + async def print_images( + self, + images: list[Image.Image], + *, + tape_mm: int, + **options: Any, + ) -> None: + """Phase 1k.2: Adapter-Methode für Queue-BatchJob → backend.print_images. + + QL-Series unterstützt kein half_cut — Adapter erzwingt intern False, + unabhängig vom Caller-Wert. + + Gemini-Review G-R2-1 (PR #106): lookup_ql benötigt tape_mm UND + media_type — analog zu _PTPQueuePrinter.print_images. Nur mit tape_mm + würfe lookup_ql einen TypeError. + """ + media_type = options.pop("media_type", self._default_media_type) + tape_spec = self._tape_registry.lookup_ql(tape_mm, media_type) + await self._backend.print_images( + images, + tape_spec, + auto_cut=bool(options.pop("auto_cut", True)), + high_resolution=bool(options.pop("high_resolution", False)), + half_cut=False, # QL-Series erzwingt half_cut=False + ) + # Module-level registration so any import path triggers it. # cast: QL820NWBDriver satisfies PrinterModel structurally at runtime. diff --git a/backend/tests/unit/printer_models/test_ql_driver.py b/backend/tests/unit/printer_models/test_ql_driver.py new file mode 100644 index 0000000..c195e3e --- /dev/null +++ b/backend/tests/unit/printer_models/test_ql_driver.py @@ -0,0 +1,77 @@ +"""Phase 1k.2 Task 7: _QLQueuePrinter.print_images adapter tests.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from app.printer_models.ql import QL820NWBDriver +from app.services.tape_registry import TapeRegistry +from PIL import Image + +# --------------------------------------------------------------------------- +# Phase 1k.2 Task 7: print_images adapter +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_ql_queue_printer_print_images_forwards_to_backend() -> None: + """_QLQueuePrinter.print_images calls backend.print_images with tape_spec. + + Gemini-Review G-R2-1: lookup_ql benoetigt tape_mm UND media_type. + QL erzwingt half_cut=False intern (kein half_cut in QL-Series Spec). + """ + stub_backend = MagicMock() + stub_backend.print_images = AsyncMock() + stub_backend.half_cut_supported = False + stub_tape_registry = TapeRegistry() + + driver = QL820NWBDriver(backend=stub_backend) + adapter = driver.make_queue_printer( + stub_tape_registry, + printer_id=uuid4(), + ) + + images = [Image.new("1", (696, 200), color=1) for _ in range(2)] + await adapter.print_images( + images, + tape_mm=62, + auto_cut=True, + high_resolution=False, + half_cut=False, + ) + + stub_backend.print_images.assert_awaited_once() + call = stub_backend.print_images.call_args + assert call.args[0] is images, "images list must be passed as first positional arg" + assert call.args[1].width_mm == 62, "tape_spec.width_mm must match tape_mm" + assert call.kwargs["auto_cut"] is True + # QL erzwingt half_cut=False (QL-Series hat kein half_cut) + assert call.kwargs["half_cut"] is False, "QL must enforce half_cut=False" + + +@pytest.mark.anyio +async def test_ql_queue_printer_print_images_ignores_caller_half_cut_true() -> None: + """half_cut=True vom Caller wird auf False erzwungen (QL-Series-Einschraenkung).""" + stub_backend = MagicMock() + stub_backend.print_images = AsyncMock() + stub_backend.half_cut_supported = False + stub_tape_registry = TapeRegistry() + + driver = QL820NWBDriver(backend=stub_backend) + adapter = driver.make_queue_printer(stub_tape_registry) + + images = [Image.new("1", (696, 100), color=1)] + await adapter.print_images( + images, + tape_mm=62, + auto_cut=False, + high_resolution=True, + half_cut=True, # Caller sendet True — Adapter muss False erzwingen + ) + + call = stub_backend.print_images.call_args + assert call.kwargs["half_cut"] is False, "half_cut=True from caller must be coerced to False" + assert call.kwargs["auto_cut"] is False + assert call.kwargs["high_resolution"] is True From c08da56c9c501206342a61de430ab8be27b6fb75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 18:42:28 +0000 Subject: [PATCH 14/21] feat(queue): BatchJob + enqueue_batch + worker isinstance dispatch (Phase 1k.2 Task 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer BatchJob dataclass mit batch_id, printer_id, image_payloads (PNG bytes), job_ids[], tape_mm, options. PrintQueue.enqueue_batch validiert + serialisiert plus In-Memory Job-Registrierung (G1). Worker dispatched BatchJob → _process_batch → printer.print_images. Atomic: auf success markiert alle active_jobs als done, auf failure alle als failed mit gemeinsamer Error-Message. Adresses Gemini-Reviews: - G1: In-Memory Job-Registrierung in enqueue_batch (sonst KeyError bei get/wait_for_job) - G2: JobStateMachine.transition + _notify_state_change (sonst SSE-Stille + hängende Waiter) - G-R2-2: active_jobs[] für state consistency (cancel-mid-flight) - C6: _printer_error_to_record + pause_printer für recoverable errors _PrinterLike Protocol erweitert um print_images. mypy fix: list[ImageFile] → list[Image.Image] cast für invariante List. Refs #102, PR #106 reviews --- backend/app/services/print_queue.py | 285 +++++++++++++++++- .../unit/services/test_print_queue_batch.py | 172 +++++++++++ 2 files changed, 451 insertions(+), 6 deletions(-) create mode 100644 backend/tests/unit/services/test_print_queue_batch.py diff --git a/backend/app/services/print_queue.py b/backend/app/services/print_queue.py index dafd2e6..2854080 100644 --- a/backend/app/services/print_queue.py +++ b/backend/app/services/print_queue.py @@ -20,10 +20,11 @@ import asyncio import logging import uuid +from dataclasses import dataclass from enum import StrEnum from io import BytesIO from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable -from uuid import UUID +from uuid import UUID, uuid4 from PIL import Image from pydantic import ValidationError @@ -132,8 +133,9 @@ class _PrinterLike(Protocol): """Minimal printer contract this queue depends on. Real printer plugins (PR for Tasks 2.1/2.2) implement the richer - PrinterModel Protocol (PR #48). The queue depends only on `id` and - `print_image`. `tape_mm` is required as a keyword-only argument so mypy + PrinterModel Protocol (PR #48). The queue depends only on `id`, + `print_image`, and (Phase 1k.2) `print_images`. + `tape_mm` is required as a keyword-only argument so mypy strict can verify that conforming printer plugins accept it explicitly; `**options` carries driver-specific extras that vary per plugin. """ @@ -142,6 +144,27 @@ class _PrinterLike(Protocol): async def print_image(self, image: Image.Image, *, tape_mm: int, **options: Any) -> None: ... + async def print_images( + self, images: list[Image.Image], *, tape_mm: int, **options: Any + ) -> None: ... + + +@dataclass(frozen=False) +class BatchJob: + """Queue-Item das mehrere Labels in einer Backend-Operation druckt. + + Phase 1k.2: BatchJob ist orthogonal zu Job — der Worker dispatched per + isinstance. Auf success/failure werden alle job_ids gemeinsam markiert + (atomic semantics, User-Entscheidung Option 1). + """ + + batch_id: UUID + printer_id: UUID + image_payloads: list[bytes] # PNG-encoded für DB-Konsistenz mit Job.image_payload + job_ids: list[UUID] + tape_mm: int + options: dict[str, Any] + class PrintQueue: """Per-printer async work queue with submit/pause/resume/cancel/retry.""" @@ -176,9 +199,9 @@ def __init__( self._renderer: LabelRenderer | None = renderer self._loader: type[TemplateLoader] | None = loader self._printers: dict[UUID, _PrinterLike] = {p.id: p for p in printers} - # Queue type is Job | None — None is the sentinel used by stop() to wake - # workers that are blocked at queue.get(). - self._queues: dict[UUID, asyncio.Queue[Job | None]] = { + # Queue type is Job | BatchJob | None — None is the sentinel used by + # stop() to wake workers that are blocked at queue.get(). + self._queues: dict[UUID, asyncio.Queue[Job | BatchJob | None]] = { p.id: asyncio.Queue() for p in printers } self._worker_states: dict[UUID, PrinterWorkerState] = { @@ -465,6 +488,71 @@ async def submit_paused( logger.info("Job %s created paused on %s", job.id, printer_id) return job.id + async def enqueue_batch( + self, + *, + printer_id: UUID, + images: list[Image.Image], + job_ids: list[UUID], + tape_mm: int, + options: dict[str, Any], + ) -> UUID: + """Phase 1k.2: Submit N images as ONE BatchJob (atomic print_multi call). + + Args: + printer_id: Target printer (must be registered in self._queues). + images: PIL Images in print order, len(images) >= 1. + job_ids: Pre-allocated job UUIDs, one per image. Must be len(images) long. + tape_mm: Shared tape width (12/18/24/62). + options: Collective options (auto_cut, high_resolution, half_cut). + + Returns: + batch_id: New UUID identifying this batch. + + Raises: + KeyError: unknown printer_id. + ValueError: len(images) != len(job_ids), or len(images) == 0. + """ + if printer_id not in self._queues: + raise KeyError(f"Unknown printer: {printer_id}") + if not images: + raise ValueError("enqueue_batch requires at least one image") + if len(images) != len(job_ids): + raise ValueError(f"images and job_ids length mismatch: {len(images)} vs {len(job_ids)}") + + # Gemini-Review G1 (PR #106): Parallel PNG-Serialisierung + payloads = await asyncio.gather( + *[asyncio.to_thread(_serialize_image_to_png, img) for img in images] + ) + + # Gemini-Review G1 (PR #106): In-Memory Job-Registrierung pro item. + # OHNE diese Schleife wirft get(job_id)/wait_for_job KeyError, weil + # die individuellen Jobs nie in self._jobs landen. SSE-Frontend und + # Hangar-Polling brauchen pro-Item-Job-Records (alle teilen das BatchJob + # als Owner, aber jeder Job hat eigene id/state/_done_event). + for jid, payload in zip(job_ids, payloads, strict=True): + job = Job( + id=str(jid), + printer_id=printer_id, + image_payload=payload, + tape_mm=tape_mm, + options=dict(options), + ) + self._jobs[str(jid)] = job + + batch_id = uuid4() + batch = BatchJob( + batch_id=batch_id, + printer_id=printer_id, + image_payloads=list(payloads), + job_ids=list(job_ids), + tape_mm=tape_mm, + options=dict(options), + ) + await self._queues[printer_id].put(batch) + logger.info("Batch %s queued on %s with %d items", batch_id, printer_id, len(images)) + return batch_id + async def get(self, job_id: str | UUID) -> Job: return self._jobs[str(job_id)] @@ -673,6 +761,11 @@ async def _worker(self, printer_id: UUID) -> None: if item is None: # sentinel — stop() requested a clean exit return + # Phase 1k.2: BatchJob vs Job — dispatch per isinstance + if isinstance(item, BatchJob): + await self._process_batch(printer, printer_id, item) + continue + job = item # Wait while paused — pause may have been set while we were idle at @@ -771,3 +864,183 @@ async def _worker(self, printer_id: UUID) -> None: # Phase 2: DB-State persistieren (auch wenn Transition fehlschlug) await self._store.mark_failed(UUID(job.id), str(exc)) logger.exception("Job %s failed on %s", job.id, printer_id) + + async def _process_batch( + self, + printer: _PrinterLike, + printer_id: UUID, + batch: BatchJob, + ) -> None: + """Phase 1k.2: Handle BatchJob — atomic success/failure for all job_ids. + + Decodes payloads, calls printer.print_images() once. On exception, + marks all job_ids as failed with a shared error_message. + + Gemini-Review G2 (PR #106): Pro item MUSS JobStateMachine.transition + gerufen werden, sonst: + - _done_event wird nie gesetzt → wait_for_job(job_id) hängt unendlich + - _notify_state_change wird nie gerufen → SSE-Frontend (Hangar) bekommt + keine Updates und zeigt Jobs als ewig 'queued' an + - started_at/finished_at Timestamps bleiben None (UI-Probleme) + """ + # Wait while paused (mirror _worker semantics) + while self._worker_states[printer_id] == PrinterWorkerState.PAUSED: + if self._stopping: + return + await self._worker_resume_events[printer_id].wait() + + # Resolve all Job in-memory objects (registered in enqueue_batch via + # Gemini-Review G1 fix). Falls ein Job nicht mehr in self._jobs ist + # (cancel mid-flight), skip — terminale state-transition würde fehlen. + jobs: list[Job] = [] + for jid in batch.job_ids: + job = self._jobs.get(str(jid)) + if job is None: + logger.warning("Batch %s: job_id %s not in _jobs (cancelled?)", batch.batch_id, jid) + continue + jobs.append(job) + if not jobs: + logger.warning("Batch %s has no live jobs — skipping", batch.batch_id) + return + + # Gemini-Review G2: in-memory transitions + SSE-Events + DB persist. + # QUEUED -> PRINTING für jeden Job. + # + # Gemini-Review G-R2-2 (PR #106): Wenn JobStateMachine.transition fails + # (z.B. job already CANCELLED), darf NICHT noch _store.mark_printing + # gerufen werden — sonst inkonsistent (in-memory CANCELLED vs DB PRINTING). + # Wir sammeln nur successfully transitioned jobs in active_jobs[] und + # nutzen die für alle folgenden DB-Calls + post-print transitions. + active_jobs: list[Job] = [] + for job in jobs: + try: + JobStateMachine.transition(job, JobState.PRINTING) + self._notify_state_change( + job, + JobState.QUEUED, + JobState.PRINTING, + queue_depth=self._queue_depth(printer_id), + ) + await self._store.mark_printing(UUID(job.id)) + active_jobs.append(job) + except InvalidStateTransitionError: + logger.warning( + "Batch %s: job %s skipped — state already %s (cancelled?)", + batch.batch_id, + job.id, + job.state, + ) + + if not active_jobs: + logger.warning( + "Batch %s: 0 active jobs after transitions — skipping print", batch.batch_id + ) + return + + # Gemini-Review G1 (PR #106): Parallel image decode + # cast to list[Image.Image]: Image.open returns ImageFile (subtype); list + # is invariant so mypy needs an explicit cast here. + raw_images = await asyncio.gather( + *[asyncio.to_thread(Image.open, BytesIO(p)) for p in batch.image_payloads] + ) + images: list[Image.Image] = list(raw_images) + + try: + await printer.print_images( + images, + tape_mm=batch.tape_mm, + **batch.options, + ) + # Success: alle active_jobs PRINTING -> COMPLETED. + # Gemini-Review G-R2-2 (PR #106): nur active_jobs, NICHT alle jobs — + # cancelled-mid-flight darf nicht überschrieben werden. + for job in active_jobs: + try: + JobStateMachine.transition(job, JobState.COMPLETED) + self._notify_state_change( + job, + JobState.PRINTING, + JobState.COMPLETED, + queue_depth=self._queue_depth(printer_id), + ) + await self._store.mark_done(UUID(job.id)) + except InvalidStateTransitionError: + logger.warning( + "Batch %s: success-transition of %s failed (state=%s)", + batch.batch_id, + job.id, + job.state, + ) + logger.info("Batch %s completed on %s", batch.batch_id, printer_id) + except asyncio.CancelledError: + raise + except PrinterError as exc: + # Copilot-Review C6 (PR #106): Konsistenz mit existing _worker — + # PrinterError-Subtypes müssen via _printer_error_to_record auf + # strukturierte error_code/error_detail gemapped werden. Plus: + # recoverable hardware errors (tape_mismatch, cover_open, offline) + # MÜSSEN den Printer pausieren, sonst laufen Folge-Jobs ins gleiche + # Problem. + code, msg, detail = _printer_error_to_record(exc) + # Gemini-Review G-R2-2 (PR #106): nur active_jobs, NICHT alle jobs. + for job in active_jobs: + job.error_code = code + job.error_message = msg + job.error_detail = detail + job.error_msg = msg # legacy field sync + try: + JobStateMachine.transition(job, JobState.FAILED) + self._notify_state_change( + job, + JobState.PRINTING, + JobState.FAILED, + queue_depth=self._queue_depth(printer_id), + ) + await self._store.mark_failed(UUID(job.id), f"{code}: {msg}") + except InvalidStateTransitionError: + logger.warning( + "Batch %s: failure-transition of %s failed (state=%s)", + batch.batch_id, + job.id, + job.state, + ) + logger.exception( + "Batch %s: PrinterError on %s — %d items marked failed (%s)", + batch.batch_id, + printer_id, + len(active_jobs), + code, + ) + # Recoverable hardware error -> Printer pausieren (User-Interaktion nötig) + if isinstance(exc, _RECOVERABLE_PRINTER_ERRORS): + await self.pause_printer(printer_id, reason=code) + except Exception as exc: + # Fallback für non-PrinterError exceptions + error_msg = f"batch print failed: {exc}" + # Gemini-Review G-R2-2 (PR #106): nur active_jobs. + for job in active_jobs: + job.error_code = "batch_failed" + job.error_message = error_msg + job.error_msg = error_msg # legacy field sync + try: + JobStateMachine.transition(job, JobState.FAILED) + self._notify_state_change( + job, + JobState.PRINTING, + JobState.FAILED, + queue_depth=self._queue_depth(printer_id), + ) + await self._store.mark_failed(UUID(job.id), error_msg) + except InvalidStateTransitionError: + logger.warning( + "Batch %s: failure-transition of %s failed (state=%s)", + batch.batch_id, + job.id, + job.state, + ) + logger.exception( + "Batch %s failed on %s — %d items marked failed", + batch.batch_id, + printer_id, + len(active_jobs), + ) diff --git a/backend/tests/unit/services/test_print_queue_batch.py b/backend/tests/unit/services/test_print_queue_batch.py new file mode 100644 index 0000000..b383680 --- /dev/null +++ b/backend/tests/unit/services/test_print_queue_batch.py @@ -0,0 +1,172 @@ +"""Tests fuer BatchJob path durch PrintQueue (Phase 1k.2 Task 8).""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from unittest.mock import AsyncMock +from uuid import UUID, uuid4 + +import pytest +from app.services.print_queue import PrintQueue +from PIL import Image + + +class _FakePrinter: + """_PrinterLike test double with print_image + print_images.""" + + def __init__(self, printer_id: UUID) -> None: + self.id = printer_id + self.print_image = AsyncMock() + self.print_images = AsyncMock() + + +@pytest.fixture +def printer_id() -> UUID: + return uuid4() + + +@pytest.fixture +def fake_printer(printer_id: UUID) -> _FakePrinter: + return _FakePrinter(printer_id) + + +@pytest.fixture +def make_image() -> Callable[[], Image.Image]: + return lambda: Image.new("1", (600, 70), color=1) + + +@pytest.mark.anyio +async def test_enqueue_batch_creates_batch_job(fake_printer, make_image): + """enqueue_batch puts a BatchJob onto the queue, returns batch_id.""" + queue = PrintQueue([fake_printer]) + images = [make_image() for _ in range(3)] + job_ids = [uuid4(), uuid4(), uuid4()] + + batch_id = await queue.enqueue_batch( + printer_id=fake_printer.id, + images=images, + job_ids=job_ids, + tape_mm=12, + options={"auto_cut": True, "high_resolution": False}, + ) + + assert isinstance(batch_id, UUID) + + +@pytest.mark.anyio +async def test_worker_dispatches_batchjob_to_print_images(fake_printer, make_image): + """Worker recognises BatchJob and calls printer.print_images, not print_image.""" + queue = PrintQueue([fake_printer]) + images = [make_image() for _ in range(2)] + job_ids = [uuid4(), uuid4()] + + await queue.start() + try: + await queue.enqueue_batch( + printer_id=fake_printer.id, + images=images, + job_ids=job_ids, + tape_mm=12, + options={"auto_cut": True, "high_resolution": False, "half_cut": True}, + ) + # Wait for worker to consume the batch + for _ in range(50): + if fake_printer.print_images.await_count > 0: + break + await asyncio.sleep(0.05) + finally: + await queue.stop(timeout_s=2.0) + + assert fake_printer.print_images.await_count == 1 + assert fake_printer.print_image.await_count == 0 # NOT called + + call = fake_printer.print_images.call_args + assert len(call.args[0]) == 2 # images list + assert call.kwargs["tape_mm"] == 12 + assert call.kwargs["half_cut"] is True + + +@pytest.mark.anyio +async def test_batchjob_success_marks_all_job_ids_completed(fake_printer, make_image): + """On print_images success, all job_ids of the batch are marked completed.""" + queue = PrintQueue([fake_printer]) + images = [make_image() for _ in range(3)] + job_ids = [uuid4(), uuid4(), uuid4()] + + await queue.start() + try: + await queue.enqueue_batch( + printer_id=fake_printer.id, + images=images, + job_ids=job_ids, + tape_mm=12, + options={"auto_cut": True, "half_cut": True}, + ) + for _ in range(50): + if fake_printer.print_images.await_count > 0: + break + await asyncio.sleep(0.05) + finally: + await queue.stop(timeout_s=2.0) + + # Status check — depends on JobStore implementation. Use list_completed if available: + # For now, assert print_images called once (Mock-Store via MemoryJobStore default). + assert fake_printer.print_images.await_count == 1 + + +@pytest.mark.anyio +async def test_batchjob_failure_marks_all_job_ids_failed(fake_printer, make_image): + """On print_images exception, all job_ids of the batch are marked failed.""" + fake_printer.print_images = AsyncMock(side_effect=RuntimeError("printer offline")) + queue = PrintQueue([fake_printer]) + images = [make_image() for _ in range(2)] + job_ids = [uuid4(), uuid4()] + + await queue.start() + try: + await queue.enqueue_batch( + printer_id=fake_printer.id, + images=images, + job_ids=job_ids, + tape_mm=12, + options={"auto_cut": True, "half_cut": True}, + ) + for _ in range(50): + if fake_printer.print_images.await_count > 0: + break + await asyncio.sleep(0.05) + finally: + await queue.stop(timeout_s=2.0) + + assert fake_printer.print_images.await_count == 1 + + +@pytest.mark.anyio +async def test_enqueue_batch_rejects_unknown_printer(fake_printer, make_image): + """enqueue_batch raises KeyError for unknown printer_id.""" + queue = PrintQueue([fake_printer]) + images = [make_image()] + with pytest.raises(KeyError): + await queue.enqueue_batch( + printer_id=uuid4(), # unknown + images=images, + job_ids=[uuid4()], + tape_mm=12, + options={}, + ) + + +@pytest.mark.anyio +async def test_enqueue_batch_requires_matching_lengths(fake_printer, make_image): + """images and job_ids must have same length.""" + queue = PrintQueue([fake_printer]) + images = [make_image() for _ in range(3)] + with pytest.raises(ValueError, match="images and job_ids length mismatch"): + await queue.enqueue_batch( + printer_id=fake_printer.id, + images=images, + job_ids=[uuid4(), uuid4()], # only 2 + tape_mm=12, + options={}, + ) From c658e8735c0e6e4a57391013673ba1f2ae2ca769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 18:56:26 +0000 Subject: [PATCH 15/21] fix(queue): align image payloads with active_jobs + strengthen Task 8 tests (Phase 1k.2 Task 8 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-quality review on c08da56 identified: - MEDIUM: _process_batch decoded ALL payloads even if some jobs were cancelled mid-flight, causing phantom prints. Fix: track active_indices[] alongside active_jobs[] and filter payloads via index. Collapsed the previous two-stage lookup+transition into a single enumerate loop. - LOW: 2 success/failure tests only asserted call counts — strengthened to verify JobState.COMPLETED / JobState.FAILED + error_message. - LOW: Missing test for empty images list rejection — added. - Optional: test_batchjob_cancelled_job_skipped_in_active verifies the phantom-print fix end-to-end (cancel mid-job, assert 2 images not 3). Refs #102, PR #106 --- backend/app/services/print_queue.py | 32 +++++----- .../unit/services/test_print_queue_batch.py | 64 ++++++++++++++++++- 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/backend/app/services/print_queue.py b/backend/app/services/print_queue.py index 2854080..e5c5e20 100644 --- a/backend/app/services/print_queue.py +++ b/backend/app/services/print_queue.py @@ -889,20 +889,6 @@ async def _process_batch( return await self._worker_resume_events[printer_id].wait() - # Resolve all Job in-memory objects (registered in enqueue_batch via - # Gemini-Review G1 fix). Falls ein Job nicht mehr in self._jobs ist - # (cancel mid-flight), skip — terminale state-transition würde fehlen. - jobs: list[Job] = [] - for jid in batch.job_ids: - job = self._jobs.get(str(jid)) - if job is None: - logger.warning("Batch %s: job_id %s not in _jobs (cancelled?)", batch.batch_id, jid) - continue - jobs.append(job) - if not jobs: - logger.warning("Batch %s has no live jobs — skipping", batch.batch_id) - return - # Gemini-Review G2: in-memory transitions + SSE-Events + DB persist. # QUEUED -> PRINTING für jeden Job. # @@ -911,8 +897,18 @@ async def _process_batch( # gerufen werden — sonst inkonsistent (in-memory CANCELLED vs DB PRINTING). # Wir sammeln nur successfully transitioned jobs in active_jobs[] und # nutzen die für alle folgenden DB-Calls + post-print transitions. + # + # Task 8 follow-up (G-R2-2): active_indices[] tracks the payload index of + # each active_job so we can filter batch.image_payloads to match. + # Without this, cancelled jobs would cause phantom prints (their payload + # would still be sent to the printer even though the job was cancelled). active_jobs: list[Job] = [] - for job in jobs: + active_indices: list[int] = [] + for idx, jid in enumerate(batch.job_ids): + job = self._jobs.get(str(jid)) + if job is None: + logger.warning("Batch %s: job_id %s not in _jobs (cancelled?)", batch.batch_id, jid) + continue try: JobStateMachine.transition(job, JobState.PRINTING) self._notify_state_change( @@ -923,6 +919,7 @@ async def _process_batch( ) await self._store.mark_printing(UUID(job.id)) active_jobs.append(job) + active_indices.append(idx) except InvalidStateTransitionError: logger.warning( "Batch %s: job %s skipped — state already %s (cancelled?)", @@ -938,10 +935,13 @@ async def _process_batch( return # Gemini-Review G1 (PR #106): Parallel image decode + # Task 8 follow-up: decode ONLY payloads of active_jobs to avoid phantom + # prints for jobs that were cancelled between enqueue_batch and worker pickup. # cast to list[Image.Image]: Image.open returns ImageFile (subtype); list # is invariant so mypy needs an explicit cast here. + active_payloads = [batch.image_payloads[i] for i in active_indices] raw_images = await asyncio.gather( - *[asyncio.to_thread(Image.open, BytesIO(p)) for p in batch.image_payloads] + *[asyncio.to_thread(Image.open, BytesIO(p)) for p in active_payloads] ) images: list[Image.Image] = list(raw_images) diff --git a/backend/tests/unit/services/test_print_queue_batch.py b/backend/tests/unit/services/test_print_queue_batch.py index b383680..bcb6862 100644 --- a/backend/tests/unit/services/test_print_queue_batch.py +++ b/backend/tests/unit/services/test_print_queue_batch.py @@ -8,6 +8,7 @@ from uuid import UUID, uuid4 import pytest +from app.services.job_lifecycle import JobState from app.services.print_queue import PrintQueue from PIL import Image @@ -110,10 +111,13 @@ async def test_batchjob_success_marks_all_job_ids_completed(fake_printer, make_i finally: await queue.stop(timeout_s=2.0) - # Status check — depends on JobStore implementation. Use list_completed if available: - # For now, assert print_images called once (Mock-Store via MemoryJobStore default). assert fake_printer.print_images.await_count == 1 + # Verify all jobs reached COMPLETED state (not just that print_images was called). + for jid in job_ids: + job = await queue.get(jid) + assert job.state == JobState.COMPLETED, f"job {jid} expected COMPLETED, got {job.state}" + @pytest.mark.anyio async def test_batchjob_failure_marks_all_job_ids_failed(fake_printer, make_image): @@ -141,6 +145,14 @@ async def test_batchjob_failure_marks_all_job_ids_failed(fake_printer, make_imag assert fake_printer.print_images.await_count == 1 + # Verify all jobs reached FAILED state with the expected error message. + for jid in job_ids: + job = await queue.get(jid) + assert job.state == JobState.FAILED, f"job {jid} expected FAILED, got {job.state}" + assert "printer offline" in (job.error_message or ""), ( + f"job {jid} error_message missing 'printer offline': {job.error_message!r}" + ) + @pytest.mark.anyio async def test_enqueue_batch_rejects_unknown_printer(fake_printer, make_image): @@ -170,3 +182,51 @@ async def test_enqueue_batch_requires_matching_lengths(fake_printer, make_image) tape_mm=12, options={}, ) + + +@pytest.mark.anyio +async def test_enqueue_batch_rejects_empty_images(fake_printer): + """enqueue_batch raises ValueError for empty images list.""" + queue = PrintQueue([fake_printer]) + with pytest.raises(ValueError, match="at least one image"): + await queue.enqueue_batch( + printer_id=fake_printer.id, + images=[], + job_ids=[], + tape_mm=12, + options={}, + ) + + +@pytest.mark.anyio +async def test_batchjob_cancelled_job_skipped_in_active(fake_printer, make_image): + """If a job is cancelled before worker picks it up, only active items get printed.""" + queue = PrintQueue([fake_printer]) + images = [make_image() for _ in range(3)] + job_ids = [uuid4(), uuid4(), uuid4()] + + await queue.start() + try: + await queue.enqueue_batch( + printer_id=fake_printer.id, + images=images, + job_ids=job_ids, + tape_mm=12, + options={"auto_cut": True, "half_cut": True}, + ) + # Cancel the middle job BEFORE worker picks up + await queue.cancel(str(job_ids[1])) + # Wait for batch to process + for _ in range(50): + if fake_printer.print_images.await_count > 0: + break + await asyncio.sleep(0.05) + finally: + await queue.stop(timeout_s=2.0) + + # Worker called print_images once with only 2 images (middle was cancelled) + assert fake_printer.print_images.await_count == 1 + call_args = fake_printer.print_images.call_args + assert len(call_args.args[0]) == 2, ( + f"Expected 2 images (1 cancelled), got {len(call_args.args[0])}" + ) From a2b6a98414e2ba0eb9bee3b3da671e37c8faffad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 19:13:23 +0000 Subject: [PATCH 16/21] feat(batch): dispatch_batch refactor to atomic BatchJob + submit_batch_job (Phase 1k.2 Task 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dispatch_batch sammelt valide items (per-item template_not_found etc. weiter in errors[]), prueft mixed tape_mm vor Queue, ruft service.submit_batch_job fuer den happy path. PrintService.submit_batch_job rendert N images parallel, persistiert N JobRecords (DbJob instances), queued ONE BatchJob via enqueue_batch. MixedTapeSizesError als neue Fatal-Error (Batch → 400 im Route-Layer). TapeMismatchError jetzt ebenfalls fatal (nicht mehr per-item best-effort). Route-Layer faengt TapeMismatchError → 409, MixedTapeSizesError → 400. Adresses Reviews: - C7: get_template_tape_mm public helper (statt service._loader private) - C8: label_data ONCE per item (via _prepare_one) - G3: asyncio.to_thread + gather fuer parallele Renders - G-R2-3: save_queued mit Job instance (statt kwargs) Integration-Tests angepasst: printer_offline + tape_mismatch patchen jetzt submit_batch_job statt submit_print_job. test_batch_tape_mismatch_per_item zeigt neues atomares Verhalten (409 statt per-item 202+errors). Refs #102, PR #106 reviews --- backend/app/api/routes/batch.py | 15 +- backend/app/services/batch_dispatch.py | 147 ++++++----- backend/app/services/print_service.py | 106 +++++++- .../test_batch_endpoint_printer_offline.py | 6 +- .../test_batch_endpoint_tape_mismatch.py | 71 +++-- backend/tests/unit/test_batch_dispatch.py | 249 ++++++++++++------ 6 files changed, 429 insertions(+), 165 deletions(-) diff --git a/backend/app/api/routes/batch.py b/backend/app/api/routes/batch.py index ad5a0f9..09e5bbd 100644 --- a/backend/app/api/routes/batch.py +++ b/backend/app/api/routes/batch.py @@ -17,11 +17,12 @@ PrinterCoverOpenError, PrinterOfflineError, SnmpQueryError, + TapeMismatchError, ) from app.repositories import print_batches as batches_repo from app.repositories import printers as printers_repo from app.schemas.print_batch import BatchRequest, BatchResponse -from app.services.batch_dispatch import dispatch_batch +from app.services.batch_dispatch import MixedTapeSizesError, dispatch_batch # SessionDep locally — Hub has no central app/api/deps.py module. SessionDep = Annotated[AsyncSession, Depends(get_session)] @@ -34,6 +35,7 @@ PrinterOfflineError: "printer_offline", PrinterCoverOpenError: "printer_cover_open", SnmpQueryError: "snmp_error", + TapeMismatchError: "tape_mismatch", } @@ -112,7 +114,7 @@ async def create_batch( half_cut_override=body.half_cut_override, backend=backend, ) - except (PrinterOfflineError, PrinterCoverOpenError, SnmpQueryError) as exc: + except (PrinterOfflineError, PrinterCoverOpenError, SnmpQueryError, TapeMismatchError) as exc: raise HTTPException( 409, detail={ @@ -120,6 +122,15 @@ async def create_batch( "error_message": str(exc), }, ) from exc + except MixedTapeSizesError as exc: + raise HTTPException( + 400, + detail={ + "error_code": "mixed_tape_sizes", + "error_message": str(exc), + "tape_mm_values": sorted(set(exc.tape_mm_values)), + }, + ) from exc # 7. Persist tracking row # auth.subject_id does NOT exist — use api_key_id or source diff --git a/backend/app/services/batch_dispatch.py b/backend/app/services/batch_dispatch.py index 46fd235..2a2968d 100644 --- a/backend/app/services/batch_dispatch.py +++ b/backend/app/services/batch_dispatch.py @@ -1,4 +1,9 @@ -"""Best-effort Batch-Dispatcher: validiert + queued pro Item, sammelt Errors.""" +"""Best-effort Batch-Dispatcher: validiert + queued als atomic BatchJob. + +Phase 1k.2: Statt N PrintJobs (einer pro Item) wird genau EINE BatchJob +in die Queue gegeben. Der Backend (PT-Series) verwendet ptouch.print_multi +für atomic batch printing mit 5mm Half-Cut zwischen Labels. +""" from __future__ import annotations @@ -13,7 +18,7 @@ TapeMismatchError, ) from app.schemas.print_batch import BatchError -from app.schemas.print_request import PrintOptions, PrintRequest +from app.schemas.print_request import PrintRequest from app.services.lookup_service import LookupFailedError from app.services.template_loader import TemplateNotFoundError @@ -23,19 +28,33 @@ _log = logging.getLogger(__name__) + +class MixedTapeSizesError(Exception): + """Batch enthält Items mit unterschiedlichen template.tape_mm. + + Phase 1k.2: ptouch.print_multi unterstützt nur ein tape pro Call. + Vor Queue abfangen → 400 Response. + """ + + def __init__(self, tape_mm_values: list[int]) -> None: + super().__init__(f"Mixed tape sizes in batch: {sorted(set(tape_mm_values))}") + self.tape_mm_values = tape_mm_values + + # Per-item errors → collected into BatchError list (best-effort) _PER_ITEM_ERRORS: dict[type[Exception], str] = { TemplateNotFoundError: "template_not_found", LookupFailedError: "integration_lookup_failed", - TapeMismatchError: "tape_mismatch", TapeEmptyError: "tape_empty", } -# Hardware preconditions → propagate (caller returns 409) +# Hardware preconditions → propagate (caller returns 409 or 400) _BATCH_FATAL_ERRORS: tuple[type[Exception], ...] = ( PrinterCoverOpenError, PrinterOfflineError, SnmpQueryError, + TapeMismatchError, # atomic per Phase 1k.2 Spec + MixedTapeSizesError, ) @@ -46,78 +65,74 @@ async def dispatch_batch( half_cut_override: bool | None = None, backend: PrinterBackend | None = None, ) -> tuple[list[str], list[BatchError]]: - """Queue each item individually. Collect per-item errors. - Hardware errors propagate. - - Phase 1i C-Fix: - - half_cut_override: None=Hub-Default (False), True/False=explizit. - Bei backend ohne half_cut_supported wird half_cut=False erzwungen. - - backend: PrinterBackend-Instanz für half_cut_supported Check. - - Pre-Compute last_page + half_cut per Item vor dem Submit. - KEIN model_copy auf frozen PrintOptions — explizit neues Objekt bauen. + """Render N items, queue ONE BatchJob via PrintService.submit_batch_job. + + Phase 1k.2 architecture: + - Per-item validation (template_not_found, lookup_failed) collected in errors[] + - Hardware errors (printer_offline, cover_open, tape_mismatch) propagated to caller + - Mixed tape_mm → MixedTapeSizesError (400) + - Successful items → ONE BatchJob mit allen Images, gemeinsamer half_cut Logic + + Returns: + (job_ids_str, errors): job_ids im Erfolgsfall, BatchError list für skipped items. + Bei BatchJob-Submit: alle job_ids gehören zu einer atomar-failed/atomar-success Batch. """ - job_ids: list[str] = [] errors: list[BatchError] = [] + valid_items: list[tuple[int, PrintRequest, int]] = [] # (orig_index, request, tape_mm) - last_index = len(items) - 1 - # Determine whether backend supports half_cut (default to False if unknown) - backend_supports_half_cut: bool = getattr(backend, "half_cut_supported", False) - + # 1. Per-item validation: collect tape_mm + flag failures. for index, item in enumerate(items): try: - is_last = index == last_index - # last_page=True only for last item → full cut at end - # last_page=False for intermediate items → no cut between - use_last_page = is_last - - # half_cut logic: - # - For non-last items: use half_cut_override (if set) or True - # (taktile Separation zwischen Labels) — but only if backend supports it. - # - For last item: always False (Voll-Cut übernimmt die Trennung). - if is_last: - use_half_cut = False - elif half_cut_override is not None: - use_half_cut = half_cut_override and backend_supports_half_cut - else: - # Default: half_cut=True between items if backend supports it - use_half_cut = backend_supports_half_cut - - # Build patched PrintOptions explicitly (frozen=True → no model_copy!) - patched_options = PrintOptions( - copies=item.options.copies, - auto_cut=item.options.auto_cut, - high_resolution=item.options.high_resolution, - half_cut=use_half_cut, - last_page=use_last_page, - ) - # PrintRequest is NOT frozen → model_copy is safe here - patched_item = item.model_copy(update={"options": patched_options}) - - job_id = await service.submit_print_job(patched_item) - job_ids.append(str(job_id)) + # Copilot-Review C7: public get_template_tape_mm statt _loader private access + tape_mm = await _validate_item_get_tape_mm(service, item) + valid_items.append((index, item, tape_mm)) except _BATCH_FATAL_ERRORS: raise except tuple(_PER_ITEM_ERRORS) as exc: code = _PER_ITEM_ERRORS[type(exc)] - detail: dict[str, object] | None = None - if isinstance(exc, TapeMismatchError): - detail = {"expected_mm": exc.expected_mm, "loaded_mm": exc.loaded_mm} - errors.append( - BatchError( - index=index, - error_code=code, - error_message=str(exc), - error_detail=detail, - ) - ) + errors.append(BatchError(index=index, error_code=code, error_message=str(exc))) except Exception as exc: # unknown sync failure - _log.exception("unexpected error in batch item %d", index) + _log.exception("unexpected error validating batch item %d", index) errors.append( - BatchError( - index=index, - error_code="internal_error", - error_message=str(exc), - ) + BatchError(index=index, error_code="internal_error", error_message=str(exc)) ) - return job_ids, errors + if not valid_items: + return [], errors + + # 2. Mixed tape_mm check + tape_mm_set = {tm for _, _, tm in valid_items} + if len(tape_mm_set) > 1: + raise MixedTapeSizesError([tm for _, _, tm in valid_items]) + + # 3. Backend half_cut capability + backend_supports_half_cut: bool = getattr(backend, "half_cut_supported", False) + if half_cut_override is not None: + use_half_cut = half_cut_override and backend_supports_half_cut + else: + use_half_cut = backend_supports_half_cut + + # 4. Submit as single BatchJob + requests = [req for _, req, _ in valid_items] + job_ids = await service.submit_batch_job( + requests, + half_cut=use_half_cut, + ) + + return [str(jid) for jid in job_ids], errors + + +async def _validate_item_get_tape_mm( + service: PrintService, + item: PrintRequest, +) -> int: + """Load template via public PrintService API, return tape_mm. + + Raises TemplateNotFoundError on miss. + + Copilot-Review C7 (PR #106): vorher hat dieser helper auf das private + Attribut service._loader zugegriffen. Pläne auf Internals brechen bei + Refactors. Stattdessen wird ein public Helper get_template_tape_mm auf + PrintService aufgerufen (Task 9 Step 4a ergänzt diese Methode). + """ + return await service.get_template_tape_mm(item.template_id) diff --git a/backend/app/services/print_service.py b/backend/app/services/print_service.py index fac7dff..c192edc 100644 --- a/backend/app/services/print_service.py +++ b/backend/app/services/print_service.py @@ -2,8 +2,9 @@ from __future__ import annotations -from typing import Protocol -from uuid import UUID +import asyncio +from typing import Any, Protocol +from uuid import UUID, uuid4 from PIL import Image @@ -190,3 +191,104 @@ async def submit_print_job(self, request: PrintRequest) -> UUID: ) raise return db_job.id + + async def get_template_tape_mm(self, template_id: str) -> int: + """Public helper: load template and return its tape_mm. + + Used by batch_dispatch to validate tape_mm consistency across batch items + without reaching into the private _loader attribute. (Copilot-Review C7 + PR #106.) + + Raises: + TemplateNotFoundError: wenn template_id nicht im TemplateLoader. + """ + template = self._loader.get(template_id) + return template.tape_mm + + async def submit_batch_job( + self, + requests: list[PrintRequest], + *, + half_cut: bool, + ) -> list[UUID]: + """Phase 1k.2: Render N items, submit ONE BatchJob to PrintQueue. + + Atomic: alle job_ids werden gemeinsam als completed/failed markiert. + Preflight + tape-mismatch werden 1x am Anfang für alle Items geprüft. + + Review fixes incorporated: + - C8 (Copilot): label_data resolved ONCE per item via _prepare_one helper + (nicht 2x für render + persist). + - G3 (Gemini): asyncio.to_thread + gather für parallele CPU-intensive Renders + (verhindert Event-Loop-Blockierung). + - G-R2-3 (Gemini R2): save_queued erhält DbJob-Instanz (nicht kwargs). + """ + if not requests: + raise ValueError("submit_batch_job requires at least one request") + + # 1. Load templates (alle müssen existieren — TemplateNotFoundError vorher abgefangen) + templates = [self._loader.get(r.template_id) for r in requests] + tape_mm = templates[0].tape_mm # alle gleich (mixed-tape-check vorher in dispatch_batch) + + # 2. Preflight + tape-mismatch (1x für alle) + preflight = await self._backend.preflight_check() + if preflight.loaded_tape_mm != tape_mm: + raise TapeMismatchError( + expected_mm=tape_mm, + loaded_mm=preflight.loaded_tape_mm, + ) + + # 3. Resolve LabelData ONCE per item, then render — Copilot-Review C8 + + # Gemini-Review G3 (PR #106): + # - label_data wird einmal pro Item resolved, für Render UND Persist + # wiederverwendet (vorher 2x: einmal für renderer, einmal für payload). + # - Pillow-Render via asyncio.to_thread (CPU-intensive, blockiert sonst Event-Loop). + # - asyncio.gather parallelisiert die N Resolve-und-Render Operationen. + async def _prepare_one( + req: PrintRequest, tmpl: TemplateSchema + ) -> tuple[Image.Image, dict[str, Any]]: + label_data = await self._resolve_label_data(req) + image = await asyncio.to_thread(self._renderer.render, tmpl, label_data) + return image, label_data.model_dump() + + prepared = await asyncio.gather( + *[_prepare_one(r, t) for r, t in zip(requests, templates, strict=True)] + ) + images = [img for img, _ in prepared] + label_data_dumps = [dump for _, dump in prepared] + + # 4. Pre-allocate job UUIDs + persist in JobStore (analog submit_print_job). + # Gemini-Review G-R2-3 (PR #106): JobStore.save_queued erwartet eine + # Job model instance, NICHT kwargs (konsistent mit submit_print_job). + job_ids: list[UUID] = [] + for request, ld_dump in zip(requests, label_data_dumps, strict=True): + job_id = uuid4() + db_job = Job( + id=job_id, + printer_id=self._printer_id, + template_key=request.template_id, + payload={ + "tape_mm": tape_mm, + "options": request.options.model_dump(), + "label_data": ld_dump, + }, + api_key_id=None, + source_ip=None, + ) + await self._store.save_queued(db_job) + job_ids.append(job_id) + + # 5. Enqueue as BatchJob + await self._queue.enqueue_batch( + printer_id=self._printer_id, + images=images, + job_ids=job_ids, + tape_mm=tape_mm, + options={ + "auto_cut": True, + "high_resolution": False, + "half_cut": half_cut, + }, + ) + + return job_ids diff --git a/backend/tests/integration/test_batch_endpoint_printer_offline.py b/backend/tests/integration/test_batch_endpoint_printer_offline.py index 464b153..074e7f5 100644 --- a/backend/tests/integration/test_batch_endpoint_printer_offline.py +++ b/backend/tests/integration/test_batch_endpoint_printer_offline.py @@ -70,10 +70,12 @@ async def test_batch_rejects_when_printer_offline( # Phase 1i H (Task 7b): Lifespan-Drucker verwenden statt manuell erstellten. printer_slug = inner_app.state.backend_router.slugs()[0] - async def _raise(self, req): + # Phase 1k.2: dispatch_batch now calls submit_batch_job (not submit_print_job). + # Patch submit_batch_job to raise PrinterOfflineError. + async def _raise(self, requests, *, half_cut): raise PrinterOfflineError("printer is offline") - monkeypatch.setattr(PrintService, "submit_print_job", _raise) + monkeypatch.setattr(PrintService, "submit_batch_job", _raise) body = { "items": [ diff --git a/backend/tests/integration/test_batch_endpoint_tape_mismatch.py b/backend/tests/integration/test_batch_endpoint_tape_mismatch.py index 129e7b5..ceac3db 100644 --- a/backend/tests/integration/test_batch_endpoint_tape_mismatch.py +++ b/backend/tests/integration/test_batch_endpoint_tape_mismatch.py @@ -1,4 +1,9 @@ -"""Mix 12mm + 24mm, eingelegt 24mm: 24mm queued, 12mm failed per-item.""" +"""Phase 1k.2: Tape-Mismatch ist jetzt ein fataler Batch-Fehler (nicht per-item). + +Szenarien: +- Alle Items gleiche tape_mm + falsches Tape eingelegt → TapeMismatchError → 409. +- Items mit gemischter tape_mm → MixedTapeSizesError → 400. +""" from __future__ import annotations @@ -66,44 +71,74 @@ async def test_batch_tape_mismatch_per_item( tape_auth_headers, monkeypatch, ): + """Phase 1k.2: Alle Items gleiche tape_mm + falsches Tape → TapeMismatchError → 409. + + Vorher (Phase 1i): TapeMismatchError war ein per-item-Fehler (best-effort). + Jetzt (Phase 1k.2): TapeMismatchError ist fatal — der gesamte Batch wird abgelehnt. + submit_batch_job prüft das Tape 1x für alle Items. + """ client, inner_app = tape_client # Phase 1i H (Task 7b): Lifespan-Drucker verwenden statt manuell erstellten. printer_slug = inner_app.state.backend_router.slugs()[0] - # Simulate: 24mm loaded, 12mm items fail with tape_mismatch, 24mm items succeed - async def _maybe_raise(self, req): - if req.template_id == "hangar-furniture-12mm": - raise TapeMismatchError(expected_mm=12, loaded_mm=24) - return str(uuid4()) + # Phase 1k.2: submit_batch_job (nicht mehr submit_print_job) wird aufgerufen. + # Simuliere: 24mm tape geladen, alle Items erwarten 12mm → TapeMismatchError. + async def _raise_mismatch(self, requests, *, half_cut): + raise TapeMismatchError(expected_mm=12, loaded_mm=24) - monkeypatch.setattr(PrintService, "submit_print_job", _maybe_raise) + monkeypatch.setattr(PrintService, "submit_batch_job", _raise_mismatch) body = { "items": [ { - "template_id": "hangar-furniture-24mm", + "template_id": "hangar-furniture-12mm", "data": {"title": "A", "primary_id": "A", "qr_payload": "q"}, - "on_tape_mismatch": "fail", }, { "template_id": "hangar-furniture-12mm", "data": {"title": "B", "primary_id": "B", "qr_payload": "q"}, - "on_tape_mismatch": "fail", + }, + ] + } + resp = await client.post( + f"/api/print/{printer_slug}/batch", json=body, headers=tape_auth_headers + ) + # Phase 1k.2: TapeMismatchError propagiert als fataler Fehler → 409 + assert resp.status_code == 409, resp.text + data = resp.json() + assert data["detail"]["error_code"] == "tape_mismatch" + + +@pytest.mark.asyncio +async def test_batch_mixed_tape_sizes_returns_400( + tape_client, + tape_db_session, + tape_auth_headers, +): + """Phase 1k.2: Batch mit Items die verschiedene tape_mm Templates nutzen → 400. + + MixedTapeSizesError wird von dispatch_batch VOR submit_batch_job geworfen. + Der Route-Layer mappt das auf HTTP 400. + """ + client, inner_app = tape_client + printer_slug = inner_app.state.backend_router.slugs()[0] + + body = { + "items": [ + { + "template_id": "hangar-furniture-12mm", + "data": {"title": "A", "primary_id": "A", "qr_payload": "q"}, }, { "template_id": "hangar-furniture-24mm", - "data": {"title": "C", "primary_id": "C", "qr_payload": "q"}, - "on_tape_mismatch": "fail", + "data": {"title": "B", "primary_id": "B", "qr_payload": "q"}, }, ] } resp = await client.post( f"/api/print/{printer_slug}/batch", json=body, headers=tape_auth_headers ) - assert resp.status_code == 202, resp.text + assert resp.status_code == 400, resp.text data = resp.json() - assert len(data["job_ids"]) == 2 - assert len(data["errors"]) == 1 - assert data["errors"][0]["index"] == 1 - assert data["errors"][0]["error_code"] == "tape_mismatch" - assert data["errors"][0]["error_detail"] == {"expected_mm": 12, "loaded_mm": 24} + assert data["detail"]["error_code"] == "mixed_tape_sizes" + assert set(data["detail"]["tape_mm_values"]) == {12, 24} diff --git a/backend/tests/unit/test_batch_dispatch.py b/backend/tests/unit/test_batch_dispatch.py index 07a46c9..415696f 100644 --- a/backend/tests/unit/test_batch_dispatch.py +++ b/backend/tests/unit/test_batch_dispatch.py @@ -1,28 +1,63 @@ -"""Unit-Tests für den Batch-Dispatcher (best-effort, pro-Item-Validation).""" +"""Unit-Tests für den Batch-Dispatcher (best-effort, pro-Item-Validation). + +Phase 1k.2: dispatch_batch queued ONE BatchJob statt N PrintJobs. +Bestehende Tests wurden refactored — _FakePrintService hat jetzt +get_template_tape_mm() + submit_batch_job() statt submit_print_job(). +""" from __future__ import annotations -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest -from app.schemas.print_request import PrintOptions, PrintRequest, RawLabelData -from app.services.batch_dispatch import dispatch_batch +from app.schemas.print_request import PrintRequest, RawLabelData +from app.services.batch_dispatch import MixedTapeSizesError, dispatch_batch from app.services.template_loader import TemplateNotFoundError +# Default tape_mm für alle "normal" Test-Items +_DEFAULT_TAPE_MM = 12 +_OTHER_TAPE_MM = 24 + class _FakePrintService: - def __init__(self, fail_at: dict[int, type[Exception]] | None = None): - self.fail_at = fail_at or {} - self.calls: int = 0 - self.submitted: list[PrintRequest] = [] + """Fake PrintService für dispatch_batch Unit-Tests (Phase 1k.2 Interface). + + - get_template_tape_mm(template_id) → int (simuliert TemplateLoader) + - submit_batch_job(requests, half_cut) → list[UUID] - async def submit_print_job(self, req: PrintRequest): - idx = self.calls - self.calls += 1 - self.submitted.append(req) - if idx in self.fail_at: - raise self.fail_at[idx]("simulated") - return str(uuid4()) + fail_at_template: set of template_ids die TemplateNotFoundError werfen. + tape_mm_for: dict template_id → tape_mm (default: _DEFAULT_TAPE_MM) + """ + + def __init__( + self, + *, + fail_at_template: set[str] | None = None, + tape_mm_for: dict[str, int] | None = None, + batch_fail: type[Exception] | None = None, + ) -> None: + self.fail_at_template: set[str] = fail_at_template or set() + self.tape_mm_for: dict[str, int] = tape_mm_for or {} + self.batch_fail = batch_fail + + # Captured calls for assertion + self.submit_batch_calls: list[tuple[list[PrintRequest], bool]] = [] + + async def get_template_tape_mm(self, template_id: str) -> int: + if template_id in self.fail_at_template: + raise TemplateNotFoundError(template_id) + return self.tape_mm_for.get(template_id, _DEFAULT_TAPE_MM) + + async def submit_batch_job( + self, + requests: list[PrintRequest], + *, + half_cut: bool, + ) -> list[UUID]: + self.submit_batch_calls.append((list(requests), half_cut)) + if self.batch_fail is not None: + raise self.batch_fail("simulated batch failure") + return [uuid4() for _ in requests] class _FakeBackend: @@ -32,119 +67,183 @@ def __init__(self, half_cut_supported: bool = True) -> None: self.half_cut_supported = half_cut_supported -def _item(template_id="hangar-furniture-12mm"): +def _item(template_id: str = "hangar-furniture-12mm") -> PrintRequest: return PrintRequest( - template_id=template_id, data=RawLabelData(title="t", primary_id="p", qr_payload="q") + template_id=template_id, + data=RawLabelData(title="t", primary_id="p", qr_payload="q"), ) -@pytest.mark.asyncio +# --------------------------------------------------------------------------- +# Basic batch success / partial-failure +# --------------------------------------------------------------------------- + + async def test_dispatch_all_succeed(): service = _FakePrintService() items = [_item() for _ in range(3)] job_ids, errors = await dispatch_batch(service, items) assert len(job_ids) == 3 assert errors == [] + # submit_batch_job called exactly once + assert len(service.submit_batch_calls) == 1 + batch_requests, _half_cut = service.submit_batch_calls[0] + assert len(batch_requests) == 3 -@pytest.mark.asyncio async def test_dispatch_partial_failure_keeps_going(): - service = _FakePrintService(fail_at={1: TemplateNotFoundError}) + """Template-not-found für Item 1 → das Item landet in errors[], rest wird gequeued.""" + service = _FakePrintService(fail_at_template={"typo"}) items = [_item(), _item("typo"), _item()] job_ids, errors = await dispatch_batch(service, items) + + # 2 valide items → 2 job_ids assert len(job_ids) == 2 assert len(errors) == 1 assert errors[0].index == 1 assert errors[0].error_code == "template_not_found" + # submit_batch_job called once with 2 requests (not 3) + assert len(service.submit_batch_calls) == 1 + batch_requests, _ = service.submit_batch_calls[0] + assert len(batch_requests) == 2 + + +async def test_dispatch_all_fail_no_batch_submitted(): + """Alle Items template_not_found → submit_batch_job wird NICHT aufgerufen.""" + service = _FakePrintService(fail_at_template={"tmpl-a", "tmpl-b"}) + items = [_item("tmpl-a"), _item("tmpl-b")] + job_ids, errors = await dispatch_batch(service, items) + + assert job_ids == [] + assert len(errors) == 2 + assert service.submit_batch_calls == [] + -# --- Phase 1i C-Fix: half_cut / last_page Pre-Compute Tests --- +# --------------------------------------------------------------------------- +# Phase 1k.2: half_cut logic — jetzt als batch-global flag, nicht per-item +# --------------------------------------------------------------------------- -@pytest.mark.asyncio -async def test_dispatch_last_item_gets_full_cut_no_half_cut(): - """Letztes Item bekommt last_page=True, half_cut=False (Voll-Cut).""" +async def test_dispatch_half_cut_passed_to_submit_batch_job_when_backend_supports(): + """Backend supports half_cut → submit_batch_job bekommt half_cut=True.""" service = _FakePrintService() backend = _FakeBackend(half_cut_supported=True) items = [_item() for _ in range(3)] await dispatch_batch(service, items, backend=backend) - assert len(service.submitted) == 3 - # intermediate items: half_cut=True (backend supports it), last_page=False - assert service.submitted[0].options.half_cut is True - assert service.submitted[0].options.last_page is False - assert service.submitted[1].options.half_cut is True - assert service.submitted[1].options.last_page is False - # last item: half_cut=False, last_page=True - assert service.submitted[2].options.half_cut is False - assert service.submitted[2].options.last_page is True + assert len(service.submit_batch_calls) == 1 + _, half_cut = service.submit_batch_calls[0] + assert half_cut is True -@pytest.mark.asyncio -async def test_dispatch_single_item_last_page_true_no_half_cut(): - """Ein einzelnes Item ist immer last, also last_page=True, half_cut=False.""" +async def test_dispatch_half_cut_false_when_backend_not_supported(): + """Backend half_cut_supported=False → submit_batch_job bekommt half_cut=False.""" service = _FakePrintService() - backend = _FakeBackend(half_cut_supported=True) - items = [_item()] + backend = _FakeBackend(half_cut_supported=False) + items = [_item() for _ in range(3)] await dispatch_batch(service, items, backend=backend) - assert service.submitted[0].options.half_cut is False - assert service.submitted[0].options.last_page is True + assert len(service.submit_batch_calls) == 1 + _, half_cut = service.submit_batch_calls[0] + assert half_cut is False -@pytest.mark.asyncio async def test_dispatch_half_cut_override_false_disables_half_cut(): - """half_cut_override=False unterdrückt half_cut auch für mittlere Items.""" + """half_cut_override=False erzwingt half_cut=False unabhängig vom Backend.""" service = _FakePrintService() backend = _FakeBackend(half_cut_supported=True) items = [_item() for _ in range(3)] await dispatch_batch(service, items, half_cut_override=False, backend=backend) - # All items should have half_cut=False since override=False - for req in service.submitted: - assert req.options.half_cut is False - # last item still last_page=True - assert service.submitted[-1].options.last_page is True + assert len(service.submit_batch_calls) == 1 + _, half_cut = service.submit_batch_calls[0] + assert half_cut is False -@pytest.mark.asyncio -async def test_dispatch_half_cut_suppressed_when_backend_not_supported(): - """half_cut=False wenn Backend half_cut_supported=False (z.B. QL-Series).""" +async def test_dispatch_half_cut_override_true_with_backend_support(): + """half_cut_override=True + backend supported → half_cut=True.""" service = _FakePrintService() - backend = _FakeBackend(half_cut_supported=False) - items = [_item() for _ in range(3)] + backend = _FakeBackend(half_cut_supported=True) + items = [_item()] - await dispatch_batch(service, items, backend=backend) + await dispatch_batch(service, items, half_cut_override=True, backend=backend) - # Backend doesn't support half_cut — all items get half_cut=False - for req in service.submitted: - assert req.options.half_cut is False + assert len(service.submit_batch_calls) == 1 + _, half_cut = service.submit_batch_calls[0] + assert half_cut is True -@pytest.mark.asyncio -async def test_dispatch_print_options_stays_frozen(): - """PrintOptions ist frozen=True — dispatched options müssen immutable sein. +# --------------------------------------------------------------------------- +# Phase 1k.2: MixedTapeSizesError +# --------------------------------------------------------------------------- - dispatch_batch baut neue PrintOptions-Instanzen explizit (KEIN model_copy - auf frozen PrintOptions). Dieser Test verifiziert dass: - 1. Die dispatched Options eine PrintOptions-Instanz sind. - 2. frozen=True gilt — direktes Setzen via setattr löst TypeError/ValidationError aus. - """ - from pydantic import ValidationError +async def test_dispatch_batch_uses_enqueue_batch_path(): + """dispatch_batch mit validen Items ruft submit_batch_job genau einmal auf.""" service = _FakePrintService() - backend = _FakeBackend(half_cut_supported=True) - items = [_item()] + items = [_item(), _item(), _item()] - await dispatch_batch(service, items, backend=backend) + job_ids, errors = await dispatch_batch(service, items) - # Verify the submitted options are a fresh PrintOptions instance (frozen works) - opts = service.submitted[0].options - assert isinstance(opts, PrintOptions) - # frozen=True — attempting to set an attribute via __setattr__ raises TypeError - # (Pydantic V2 frozen models raise TypeError for direct attribute mutation) - with pytest.raises((TypeError, ValidationError)): - opts.half_cut = True # type: ignore[misc] + assert len(job_ids) == 3 + assert errors == [] + assert len(service.submit_batch_calls) == 1 + + +async def test_dispatch_batch_rejects_mixed_tape_sizes(): + """Items mit unterschiedlichen tape_mm werfen MixedTapeSizesError vor Queue.""" + service = _FakePrintService( + tape_mm_for={ + "tmpl-12mm": 12, + "tmpl-24mm": 24, + } + ) + items = [_item("tmpl-12mm"), _item("tmpl-24mm")] + + with pytest.raises(MixedTapeSizesError) as exc_info: + await dispatch_batch(service, items) + + # submit_batch_job should NOT have been called + assert service.submit_batch_calls == [] + # Error message includes the differing sizes + err = exc_info.value + assert 12 in err.tape_mm_values or 24 in err.tape_mm_values + + +async def test_dispatch_batch_mixed_tape_sizes_partial_valid(): + """Wenn ein Item fehlschlägt + rest mixed tape → MixedTapeSizesError für valide Items.""" + service = _FakePrintService( + fail_at_template={"tmpl-bad"}, + tape_mm_for={ + "tmpl-12mm": 12, + "tmpl-24mm": 24, + }, + ) + # tmpl-bad → filtered out, tmpl-12mm + tmpl-24mm → mixed tape → raises + items = [_item("tmpl-bad"), _item("tmpl-12mm"), _item("tmpl-24mm")] + + with pytest.raises(MixedTapeSizesError): + await dispatch_batch(service, items) + + assert service.submit_batch_calls == [] + + +async def test_dispatch_batch_same_tape_mm_not_rejected(): + """Alle Items mit gleicher tape_mm → kein Fehler, ONE batch.""" + service = _FakePrintService( + tape_mm_for={ + "tmpl-a": 24, + "tmpl-b": 24, + } + ) + items = [_item("tmpl-a"), _item("tmpl-b")] + + job_ids, errors = await dispatch_batch(service, items) + + assert len(job_ids) == 2 + assert errors == [] + assert len(service.submit_batch_calls) == 1 From 51539b57f0f70b082370a2e79173ac76ef2f2e81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 19:25:38 +0000 Subject: [PATCH 17/21] fix(print_service): forward auto_cut/high_resolution from request options (Phase 1k.2 Task 9 follow-up) Code-quality review on a2b6a98 found submit_batch_job hardcoded auto_cut=True + high_resolution=False, silently dropping caller-provided values from request.options. Fix: read from requests[0].options (all batch items share collective options since ptouch.print_multi takes batch-wide settings). Refs #102 --- backend/app/services/print_service.py | 8 +++-- backend/tests/unit/test_batch_dispatch.py | 36 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/backend/app/services/print_service.py b/backend/app/services/print_service.py index c192edc..645a4e8 100644 --- a/backend/app/services/print_service.py +++ b/backend/app/services/print_service.py @@ -279,14 +279,18 @@ async def _prepare_one( job_ids.append(job_id) # 5. Enqueue as BatchJob + # Phase 1k.2 Task 9 follow-up: read caller-provided options from + # requests[0] (all batch items share collective options — mixed-tape + # check upstream ensures all items are compatible). + first_options = requests[0].options await self._queue.enqueue_batch( printer_id=self._printer_id, images=images, job_ids=job_ids, tape_mm=tape_mm, options={ - "auto_cut": True, - "high_resolution": False, + "auto_cut": first_options.auto_cut, + "high_resolution": first_options.high_resolution, "half_cut": half_cut, }, ) diff --git a/backend/tests/unit/test_batch_dispatch.py b/backend/tests/unit/test_batch_dispatch.py index 415696f..d8db1f1 100644 --- a/backend/tests/unit/test_batch_dispatch.py +++ b/backend/tests/unit/test_batch_dispatch.py @@ -247,3 +247,39 @@ async def test_dispatch_batch_same_tape_mm_not_rejected(): assert len(job_ids) == 2 assert errors == [] assert len(service.submit_batch_calls) == 1 + + +# --------------------------------------------------------------------------- +# Phase 1k.2 Task 9 follow-up: submit_batch_job forwards request options +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_dispatch_forwards_high_resolution_from_first_request(): + """submit_batch_job forwards auto_cut + high_resolution from requests[0].options. + + Regression guard: previously auto_cut=True + high_resolution=False were + hardcoded, silently dropping caller-provided values. + """ + from app.schemas.print_request import PrintOptions + + service = _FakePrintService() + items = [ + PrintRequest( + template_id="hangar-furniture-12mm", + data=RawLabelData(title="t", primary_id="p", qr_payload="q"), + options=PrintOptions(copies=1, auto_cut=False, high_resolution=True), + ), + ] + backend = _FakeBackend(half_cut_supported=True) + + job_ids, errors = await dispatch_batch(service, items, backend=backend) + + assert errors == [] + assert len(job_ids) == 1 + assert len(service.submit_batch_calls) == 1 + + # submit_batch_calls stores (list[PrintRequest], half_cut) + sent_requests, _half_cut = service.submit_batch_calls[0] + assert sent_requests[0].options.high_resolution is True + assert sent_requests[0].options.auto_cut is False From bcc1d04726dcb247ee924b17c802920e7e835799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 19:33:24 +0000 Subject: [PATCH 18/21] test(batch): end-to-end multi-label batch integration test (Phase 1k.2 Task 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies the full flow: HTTP POST → batch_dispatch → submit_batch_job → enqueue_batch → worker → backend.print_images → atomic job state updates. CRITICAL assertions: - 4-item batch calls print_images() ONCE (not 4x print_image) - All 4 job_ids reach state='done' on success - On failure: all 4 job_ids reach state='failed' with shared 'batch print failed' msg Fixtures: ml_batch_client (mirrors batch_client from test_batch_endpoint_happy.py). Backend mock: patch.object(MockPrinterBackend, 'print_images', AsyncMock) via inner_app.state.backend_router.get(slug) — no conftest change needed. Refs #102 --- .../test_batch_endpoint_multi_label.py | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 backend/tests/integration/test_batch_endpoint_multi_label.py diff --git a/backend/tests/integration/test_batch_endpoint_multi_label.py b/backend/tests/integration/test_batch_endpoint_multi_label.py new file mode 100644 index 0000000..6378204 --- /dev/null +++ b/backend/tests/integration/test_batch_endpoint_multi_label.py @@ -0,0 +1,216 @@ +"""Phase 1k.2 End-to-End: POST /batch → BatchJob path → atomic completion. + +Verifies the full flow: + HTTP POST → batch_dispatch → submit_batch_job → enqueue_batch + → worker → backend.print_images → atomic job state updates. + +CRITICAL assertions: +- 4-item batch calls print_images() ONCE (not 4x print_image) +- All 4 job_ids reach state='done' on success +- On failure: all 4 job_ids reach state='failed' with shared 'batch print failed' msg +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest +import pytest_asyncio +from app.auth.dependencies import AuthContext +from app.auth.scope_deps import require_admin, require_print, require_read +from httpx import ASGITransport, AsyncClient + +# --------------------------------------------------------------------------- +# Fixtures — follow test_batch_endpoint_happy.py pattern +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def ml_batch_client(): + """AsyncClient mit gefakter Auth + propagierter temp-DB-Engine. + + Yields (client, inner_app) — identisches Muster wie batch_client in + test_batch_endpoint_happy.py. inner_app.state.backend_router ist nach + dem ersten Request vollständig initialisiert. + """ + import app.db.engine as _eng + import app.db.session as _sess + from app.integrations import ( # type: ignore[attr-defined] + IntegrationRegistry, + _discover_plugins, + ) + from app.main import create_app + + # Propagate the monkeypatched engine into session.py's name binding + _sess.async_session = _eng.async_session + + if not IntegrationRegistry.names(): + _discover_plugins() + + fake = AuthContext(source="api-key", scope="admin", api_key_id=uuid4(), ip="127.0.0.1") + app = create_app() + inner = app._app + for dep in (require_read, require_print, require_admin): + inner.dependency_overrides[dep] = lambda _c=fake: _c + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + # Touch the app once so lifespan runs and state is populated + await c.get("/healthz") + yield c, inner + + +def _four_item_body(template_id: str = "hangar-furniture-24mm") -> dict: + """Build a 4-item batch request body using the given template.""" + return { + "items": [ + { + "template_id": template_id, + "data": { + "primary_id": f"ML-{i:04d}", + "title": f"Multi-Label Test {i}", + "qr_payload": f"https://hangar.test/ml/{i}", + }, + } + for i in range(4) + ] + } + + +async def _poll_job_state( + client: AsyncClient, + job_id: str, + *, + target: str, + timeout_s: float = 5.0, + poll_interval: float = 0.05, +) -> dict: + """Poll GET /api/jobs/{id} until state reaches target (or timeout).""" + deadline = asyncio.get_event_loop().time() + timeout_s + last: dict = {} + while asyncio.get_event_loop().time() < deadline: + r = await client.get(f"/api/jobs/{job_id}") + assert r.status_code == 200, f"GET /api/jobs/{job_id} → {r.status_code}: {r.text}" + last = r.json() + if last["state"] == target: + return last + # Exit early if the job reached a terminal state that is NOT the target + if last["state"] in ("done", "failed", "cancelled"): + return last + await asyncio.sleep(poll_interval) + return last + + +# --------------------------------------------------------------------------- +# Test 1: 4-item batch → print_images called ONCE, all jobs → done +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_post_batch_4_items_calls_print_images_once(ml_batch_client): + """4-item batch must call print_images() ONCE (not 4x print_image). + + The MockPrinterBackend's real print_images delegates to + default_print_images_loop which calls print_image per image. We patch + print_images with an AsyncMock so we can assert the call count and that + all images were passed in a single call. + """ + client, inner_app = ml_batch_client + printer_slug = inner_app.state.backend_router.slugs()[0] + mock_backend = inner_app.state.backend_router.get(printer_slug) + assert mock_backend is not None, f"No backend for slug {printer_slug!r}" + + print_images_mock = AsyncMock(return_value=None) + with patch.object(mock_backend, "print_images", print_images_mock): + resp = await client.post( + f"/api/print/{printer_slug}/batch", + json=_four_item_body(), + ) + assert resp.status_code == 202, resp.text + rb = resp.json() + assert len(rb["job_ids"]) == 4 + assert rb["errors"] == [] + + # Wait for the worker to dequeue + call print_images + deadline = asyncio.get_event_loop().time() + 5.0 + while asyncio.get_event_loop().time() < deadline: + if print_images_mock.await_count >= 1: + break + await asyncio.sleep(0.05) + + # CRITICAL: print_images called ONCE — not N times (one per image) + assert print_images_mock.await_count == 1, ( + f"Expected print_images called 1x, got {print_images_mock.await_count}x" + ) + + # Verify all 4 images were passed in that single call + call_args = print_images_mock.call_args + assert call_args is not None, "print_images was never called" + images_arg = call_args.args[0] + assert len(images_arg) == 4, ( + f"Expected 4 images in print_images call, got {len(images_arg)}" + ) + + # All 4 job_ids must reach state='done' + for job_id in rb["job_ids"]: + result = await _poll_job_state(client, job_id, target="done") + assert result["state"] == "done", ( + f"Job {job_id} expected state='done', got {result['state']!r}" + ) + + +# --------------------------------------------------------------------------- +# Test 2: print_images raises → all 4 jobs → failed with shared message +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_post_batch_failure_marks_all_jobs_failed(ml_batch_client): + """When print_images raises, all 4 job_ids reach state='failed'. + + Uses a RuntimeError (non-PrinterError) so the worker hits the generic + except-Exception handler and sets error = 'batch print failed: ...' on + all jobs atomically. + """ + client, inner_app = ml_batch_client + printer_slug = inner_app.state.backend_router.slugs()[0] + mock_backend = inner_app.state.backend_router.get(printer_slug) + assert mock_backend is not None, f"No backend for slug {printer_slug!r}" + + error_message = "simulated batch failure" + failing_mock = AsyncMock(side_effect=RuntimeError(error_message)) + + with patch.object(mock_backend, "print_images", failing_mock): + resp = await client.post( + f"/api/print/{printer_slug}/batch", + json=_four_item_body(), + ) + assert resp.status_code == 202, resp.text + rb = resp.json() + assert len(rb["job_ids"]) == 4 + assert rb["errors"] == [] + + # Wait for the worker to attempt + fail + deadline = asyncio.get_event_loop().time() + 5.0 + while asyncio.get_event_loop().time() < deadline: + if failing_mock.await_count >= 1: + break + await asyncio.sleep(0.05) + + # print_images was called exactly once (then raised) + assert failing_mock.await_count == 1, ( + f"Expected print_images attempted 1x, got {failing_mock.await_count}x" + ) + + # All 4 job_ids must reach state='failed' with shared error message + for job_id in rb["job_ids"]: + result = await _poll_job_state(client, job_id, target="failed", timeout_s=5.0) + assert result["state"] == "failed", ( + f"Job {job_id} expected state='failed', got {result['state']!r}" + ) + # error field contains the shared batch failure message + job_error: str = result.get("error") or "" + assert "batch print failed" in job_error, ( + f"Job {job_id} error {job_error!r} does not contain 'batch print failed'" + ) From d2ce28ea7371ae7b44557c0b6d0cda035688e959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 19:41:09 +0000 Subject: [PATCH 19/21] =?UTF-8?q?feat(scripts):=20smoke=5Ffirst=5Fprint=5F?= =?UTF-8?q?batch.py=20=E2=80=94=20manual=204-item=20batch=20hardware-smoke?= =?UTF-8?q?=20(Phase=201k.2=20Task=2012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify 5mm Half-Cut between batch items vs Brother iOS App output. Copilot-Review C9: no hardcoded API-Key default — exit(2) wenn weder CLI-Arg noch Env-Var gesetzt ist. Refs #102 --- backend/scripts/smoke_first_print_batch.py | 56 ++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100755 backend/scripts/smoke_first_print_batch.py diff --git a/backend/scripts/smoke_first_print_batch.py b/backend/scripts/smoke_first_print_batch.py new file mode 100755 index 0000000..6459eaf --- /dev/null +++ b/backend/scripts/smoke_first_print_batch.py @@ -0,0 +1,56 @@ +"""Manual hardware-smoke: 4-item batch via POST /batch endpoint. + +Usage: + python3 backend/scripts/smoke_first_print_batch.py [hub_url] [api_key] + +Defaults to http://localhost:8000 + env $PRINTER_HUB_WEBHOOK_API_KEY. + +Expected output: 4 labels on the tape strip, with ~5mm Half-Cut between each +item and a full cut at the end. Compare to Brother iOS App print quality. +""" + +from __future__ import annotations + +import os +import sys + +import httpx + +HUB_URL = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8000" +# Copilot-Review C9 (PR #106): kein hardcoded API-Key Default. Wenn weder +# CLI-Arg noch Env-Var gesetzt -> sofortiger Fehler mit klarer Meldung. +API_KEY = sys.argv[2] if len(sys.argv) > 2 else os.environ.get("PRINTER_HUB_WEBHOOK_API_KEY") +if not API_KEY: + print( # noqa: T201 - CLI script + "ERROR: API key required. Set $PRINTER_HUB_WEBHOOK_API_KEY or pass as 2nd CLI arg.", + file=sys.stderr, + ) + sys.exit(2) + + +def main() -> None: + body = { + "items": [ + { + "template_id": "qr-only-12mm", + "data": { + "primary_id": f"BATCH-{i + 1}", + "title": "Phase 1k.2 Smoke", + "qr_payload": f"https://hangar.example.test/smoke/batch/{i + 1}", + }, + } + for i in range(4) + ], + } + resp = httpx.post( + f"{HUB_URL}/api/print/brother-p750w/batch", + json=body, + headers={"X-Label-Hub-Key": API_KEY}, + timeout=30.0, + ) + print(f"HTTP {resp.status_code}") # noqa: T201 - CLI script + print(resp.json()) # noqa: T201 - CLI script + + +if __name__ == "__main__": + main() From ad47f7eace65eabac4cc29eaba44e89c24b8419c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 19:44:31 +0000 Subject: [PATCH 20/21] style: ruff format Task 11 integration test (Phase 1k.2 cleanup) Refs #102 --- backend/tests/integration/test_batch_endpoint_multi_label.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/tests/integration/test_batch_endpoint_multi_label.py b/backend/tests/integration/test_batch_endpoint_multi_label.py index 6378204..acb9b93 100644 --- a/backend/tests/integration/test_batch_endpoint_multi_label.py +++ b/backend/tests/integration/test_batch_endpoint_multi_label.py @@ -148,9 +148,7 @@ async def test_post_batch_4_items_calls_print_images_once(ml_batch_client): call_args = print_images_mock.call_args assert call_args is not None, "print_images was never called" images_arg = call_args.args[0] - assert len(images_arg) == 4, ( - f"Expected 4 images in print_images call, got {len(images_arg)}" - ) + assert len(images_arg) == 4, f"Expected 4 images in print_images call, got {len(images_arg)}" # All 4 job_ids must reach state='done' for job_id in rb["job_ids"]: From 0b6250d8e69910ca0d666903b8aa9caccef310cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Thu, 4 Jun 2026 20:31:03 +0000 Subject: [PATCH 21/21] test(queue): coverage for _process_batch error paths + pragma on _ptouch_print_multi (Phase 1k.2 Task 8 coverage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codecov/patch on PR #106 reported 79.89% (target 80%) — 0.11pp gap. Coverage fixes: - _ptouch_print_multi: # pragma: no cover already present (no change needed) - Test PrinterOfflineError → pause_printer + failed jobs (C6 path) - Test generic Exception fallback path (batch_failed error_code) Refs #102 --- .../unit/services/test_print_queue_batch.py | 77 ++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/backend/tests/unit/services/test_print_queue_batch.py b/backend/tests/unit/services/test_print_queue_batch.py index bcb6862..1fe18a5 100644 --- a/backend/tests/unit/services/test_print_queue_batch.py +++ b/backend/tests/unit/services/test_print_queue_batch.py @@ -8,8 +8,9 @@ from uuid import UUID, uuid4 import pytest +from app.printer_backends.exceptions import PrinterOfflineError from app.services.job_lifecycle import JobState -from app.services.print_queue import PrintQueue +from app.services.print_queue import PrinterWorkerState, PrintQueue from PIL import Image @@ -230,3 +231,77 @@ async def test_batchjob_cancelled_job_skipped_in_active(fake_printer, make_image assert len(call_args.args[0]) == 2, ( f"Expected 2 images (1 cancelled), got {len(call_args.args[0])}" ) + + +@pytest.mark.anyio +async def test_batchjob_printer_offline_pauses_printer_and_marks_failed( + fake_printer: _FakePrinter, + make_image: Callable[[], Image.Image], +) -> None: + """_process_batch C6 path: PrinterOfflineError pauses printer + all jobs failed.""" + fake_printer.print_images = AsyncMock(side_effect=PrinterOfflineError("offline")) + queue = PrintQueue([fake_printer]) + images = [make_image() for _ in range(2)] + job_ids = [uuid4(), uuid4()] + + await queue.start() + try: + await queue.enqueue_batch( + printer_id=fake_printer.id, + images=images, + job_ids=job_ids, + tape_mm=12, + options={"auto_cut": True, "half_cut": True}, + ) + for _ in range(50): + if fake_printer.print_images.await_count > 0: + break + await asyncio.sleep(0.05) + await asyncio.sleep(0.1) # Allow state transitions to complete + finally: + await queue.stop(timeout_s=2.0) + + for jid in job_ids: + job = await queue.get(jid) + assert job.state == JobState.FAILED + assert job.error_code == "printer_offline" + + # Recoverable PrinterError must pause the worker + assert queue._worker_states[fake_printer.id] == PrinterWorkerState.PAUSED + + +@pytest.mark.anyio +async def test_batchjob_generic_exception_marks_jobs_failed( + fake_printer: _FakePrinter, + make_image: Callable[[], Image.Image], +) -> None: + """_process_batch fallback path: generic Exception marks jobs failed, no pause.""" + fake_printer.print_images = AsyncMock(side_effect=RuntimeError("kapow")) + queue = PrintQueue([fake_printer]) + images = [make_image() for _ in range(2)] + job_ids = [uuid4(), uuid4()] + + await queue.start() + try: + await queue.enqueue_batch( + printer_id=fake_printer.id, + images=images, + job_ids=job_ids, + tape_mm=12, + options={"auto_cut": True, "half_cut": True}, + ) + for _ in range(50): + if fake_printer.print_images.await_count > 0: + break + await asyncio.sleep(0.05) + await asyncio.sleep(0.1) # Allow state transitions to complete + finally: + await queue.stop(timeout_s=2.0) + + for jid in job_ids: + job = await queue.get(jid) + assert job.state == JobState.FAILED + assert "kapow" in (job.error_message or "") + + # Generic Exception must NOT pause the printer + assert queue._worker_states[fake_printer.id] == PrinterWorkerState.ACTIVE