diff --git a/backend/alembic/versions/20260605_phase1k1a_drop_preset_template_id.py b/backend/alembic/versions/20260605_phase1k1a_drop_preset_template_id.py new file mode 100644 index 0000000..c47207d --- /dev/null +++ b/backend/alembic/versions/20260605_phase1k1a_drop_preset_template_id.py @@ -0,0 +1,63 @@ +"""Phase 1k.1a: drop presets.template_id column and templates table + +The Template model and TemplateLoader were removed in Phase 1k.1a (layout +engine replaces template-based rendering). This migration: + 1. Drops the presets.template_id column (includes its FK to templates.id) + 2. Drops the templates table (no longer managed by any SQLModel class) + +SQLite note: SQLite does not support named FK constraints. Alembic batch +mode handles FK removal implicitly when the column is dropped — no explicit +drop_constraint call is needed. + +Revision ID: 20260605a1b2c3d4 +Revises: a0516c04278c +Create Date: 2026-06-05 00:00:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "20260605a1b2c3d4" +down_revision: str | Sequence[str] | None = "a0516c04278c" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Drop template_id from presets, then drop templates table.""" + # Batch mode recreates the table without the dropped column, which + # also removes the FK constraint in SQLite (no explicit drop_constraint). + with op.batch_alter_table("presets", schema=None) as batch_op: + batch_op.drop_column("template_id") + + # Drop the templates table — no Python model references it any more. + op.drop_table("templates") + + +def downgrade() -> None: + """Recreate templates table and restore template_id column on presets.""" + # Recreate the minimal templates table schema from migration 54f963fdb994. + op.create_table( + "templates", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("key", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("app", sa.String(), nullable=True), + sa.Column("printer_model", sa.String(), nullable=True), + sa.Column("tape_width_mm", sa.Integer(), nullable=False), + sa.Column("schema_version", sa.Integer(), nullable=False, server_default="1"), + sa.Column("definition", sa.JSON(), nullable=True), + sa.Column("source", sa.String(), nullable=False, server_default="seed"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("key"), + ) + with op.batch_alter_table("presets", schema=None) as batch_op: + batch_op.add_column(sa.Column("template_id", sa.Uuid(), nullable=True)) + # SQLite does not support named FK constraints and alembic batch mode + # requires a name for create_foreign_key. FK is omitted in downgrade + # (column is re-added without FK constraint — acceptable for rollback). diff --git a/backend/alembic/versions/20260605b_jobs_template_key_nullable.py b/backend/alembic/versions/20260605b_jobs_template_key_nullable.py new file mode 100644 index 0000000..685e3e1 --- /dev/null +++ b/backend/alembic/versions/20260605b_jobs_template_key_nullable.py @@ -0,0 +1,41 @@ +"""Phase 1k.1a: make jobs.template_key nullable to match SQLModel definition + +The Job model defines template_key as Optional[str] (nullable=True) but the +initial jobs migration (e0d573b37f5b) created it as NOT NULL. This migration +corrects the nullable mismatch so alembic check passes. + +Revision ID: 20260605b2c3d4e5 +Revises: 20260605a1b2c3d4 +Create Date: 2026-06-05 00:01:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "20260605b2c3d4e5" +down_revision: str | Sequence[str] | None = "20260605a1b2c3d4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Make jobs.template_key nullable (SQLite batch mode).""" + with op.batch_alter_table("jobs", schema=None) as batch_op: + batch_op.alter_column( + "template_key", + existing_type=sa.String(), + nullable=True, + ) + + +def downgrade() -> None: + """Restore jobs.template_key to NOT NULL.""" + with op.batch_alter_table("jobs", schema=None) as batch_op: + batch_op.alter_column( + "template_key", + existing_type=sa.String(), + nullable=False, + ) diff --git a/backend/app/api/error_handlers.py b/backend/app/api/error_handlers.py index 4fd7044..8d5d336 100644 --- a/backend/app/api/error_handlers.py +++ b/backend/app/api/error_handlers.py @@ -15,7 +15,6 @@ docs/superpowers/specs/2026-05-16-phase6a-rest-api-design.md app/services/errors.py app/printer_backends/exceptions.py - app/services/template_loader.py """ from __future__ import annotations @@ -34,7 +33,6 @@ ) from app.schemas.problem import ProblemDetail from app.services.errors import AppLookupNotFoundError -from app.services.template_loader import TemplateNotFoundError # Mapping: exception class → (HTTP status code, problem-type slug) _MAPPING: dict[type[Exception], tuple[int, str]] = { @@ -42,7 +40,6 @@ TapeMismatchError: (409, "tape-mismatch"), TapeEmptyError: (409, "tape-empty"), PrinterCoverOpenError: (409, "printer-cover-open"), - TemplateNotFoundError: (404, "template-not-found"), AppLookupNotFoundError: (404, "app-lookup-not-found"), } diff --git a/backend/app/api/routes/batch.py b/backend/app/api/routes/batch.py index 09e5bbd..2254fb4 100644 --- a/backend/app/api/routes/batch.py +++ b/backend/app/api/routes/batch.py @@ -12,17 +12,19 @@ from app.auth.scope_deps import require_print from app.db.session import get_session from app.models.print_batch import PrintBatch -from app.printer_backends.base import PrinterBackend # noqa: F401 — used by dispatch_batch typing from app.printer_backends.exceptions import ( + ContentTypeDataMismatchError, + NoTapeLoadedError, PrinterCoverOpenError, PrinterOfflineError, SnmpQueryError, TapeMismatchError, + UnsupportedTapeError, ) 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 MixedTapeSizesError, dispatch_batch +from app.services.batch_dispatch import dispatch_batch # SessionDep locally — Hub has no central app/api/deps.py module. SessionDep = Annotated[AsyncSession, Depends(get_session)] @@ -34,7 +36,8 @@ _SYNC_ERROR_MAP: dict[type[Exception], str] = { PrinterOfflineError: "printer_offline", PrinterCoverOpenError: "printer_cover_open", - SnmpQueryError: "snmp_error", + # R2-2: align with print.py — snmp_query_failed (was snmp_error) + SnmpQueryError: "snmp_query_failed", TapeMismatchError: "tape_mismatch", } @@ -46,9 +49,11 @@ tags=["print"], summary="Submit a batch of print jobs", description=( - "Best-effort batch print. Validates each item individually and " - "returns per-item errors. Hardware preconditions (printer_offline, " - "cover_open) reject the entire batch with 409." + "Atomic batch print. Validates all items and enqueues the entire batch " + "as a unit — no per-item errors are returned. Any validation failure " + "or hardware precondition (printer_offline, cover_open, unsupported_tape, " + "no_tape_loaded, content_type_data_mismatch) rejects the whole batch " + "with the appropriate 4xx status code." ), ) async def create_batch( @@ -106,29 +111,67 @@ async def create_batch( }, ) from err - # 6. Best-effort dispatch + # 6. Dispatch (half_cut: override takes precedence over backend capability) + backend_supports_half_cut: bool = getattr(backend, "half_cut_supported", False) + if body.half_cut_override is not None: + use_half_cut = body.half_cut_override and backend_supports_half_cut + else: + use_half_cut = backend_supports_half_cut + try: - job_ids, errors = await dispatch_batch( - service, - body.items, - half_cut_override=body.half_cut_override, - backend=backend, + job_ids = await dispatch_batch( + service=service, + items=body.items, + half_cut=use_half_cut, ) - except (PrinterOfflineError, PrinterCoverOpenError, SnmpQueryError, TapeMismatchError) as exc: + except (PrinterCoverOpenError, TapeMismatchError) as exc: + # 409: hardware state the user can fix (cover open / wrong tape) raise HTTPException( 409, detail={ "error_code": _SYNC_ERROR_MAP[type(exc)], + # str(exc) is safe here: these exceptions carry only hardware-state + # descriptions (e.g. "Expected 12mm tape, loaded 24mm"), no stack + # trace fragments or internal paths. "error_message": str(exc), }, ) from exc - except MixedTapeSizesError as exc: + except (PrinterOfflineError, SnmpQueryError) as exc: + # R2-2: 503 — server-side / network issue, client should retry later. + # Consistent with print.py which maps PrinterOfflineError → 503. raise HTTPException( - 400, + 503, detail={ - "error_code": "mixed_tape_sizes", + "error_code": _SYNC_ERROR_MAP[type(exc)], "error_message": str(exc), - "tape_mm_values": sorted(set(exc.tape_mm_values)), + }, + ) from exc + except (NoTapeLoadedError, UnsupportedTapeError) as exc: + # 409: tape hardware state — client must change tape and retry. + # NOTE: We do NOT expose str(exc) — CWE-209 guard: use a fixed message + # + structured detail instead of raw exception text. + if isinstance(exc, NoTapeLoadedError): + error_code = "no_tape_loaded" + error_msg = "No tape loaded — insert a Brother TZe or DK cartridge." + error_detail: dict[str, object] = {} + else: + error_code = "unsupported_tape" + error_msg = "The currently loaded tape width is not supported by the layout engine." + error_detail = {"tape_mm": exc.tape_mm} + detail: dict[str, object] = {"error_code": error_code, "error_message": error_msg} + if error_detail: + detail["error_detail"] = error_detail + raise HTTPException(409, detail=detail) from exc + except ContentTypeDataMismatchError as exc: + # 422: client-side data error — missing fields for the chosen content type. + raise HTTPException( + 422, + detail={ + "error_code": "content_type_data_mismatch", + "error_message": ( + "The label data is missing fields required for the selected content type." + ), + "error_detail": {"missing_fields": list(exc.missing_fields)}, }, ) from exc @@ -137,7 +180,7 @@ async def create_batch( created_by = str(auth.api_key_id) if auth.api_key_id else auth.source batch_row = PrintBatch( printer_id=printer.id, - job_ids=job_ids, + job_ids=[str(jid) for jid in job_ids], created_by=created_by, ) await batches_repo.create(session, batch_row) @@ -146,6 +189,5 @@ async def create_batch( batch_id=batch_row.id, printer_id=printer.id, queued_at=datetime.now(UTC).isoformat().replace("+00:00", "Z"), - job_ids=job_ids, - errors=errors, + job_ids=[str(jid) for jid in job_ids], ) diff --git a/backend/app/api/routes/print.py b/backend/app/api/routes/print.py index f9fbbe4..03135ed 100644 --- a/backend/app/api/routes/print.py +++ b/backend/app/api/routes/print.py @@ -1,35 +1,47 @@ -"""POST /print + GET /jobs/{job_id} + POST /jobs/{job_id}/resume.""" +"""POST /print + GET /jobs/{job_id} + POST /jobs/{job_id}/resume + POST /api/render/preview.""" from __future__ import annotations +import asyncio +import io import logging from typing import Annotated, Any from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Request, status -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, Response from pydantic import BaseModel from app.auth.dependencies import AuthContext from app.auth.scope_deps import require_print, require_read from app.printer_backends.exceptions import ( + ContentTypeDataMismatchError, + NoTapeLoadedError, PrinterCoverOpenError, PrinterOfflineError, SnmpQueryError, TapeEmptyError, TapeMismatchError, + UnsupportedTapeError, ) from app.printer_backends.snmp_helper import LiveStatus, query_live_status -from app.schemas.print_request import PrintRequest +from app.schemas.content_type import ContentType +from app.schemas.label_data import LabelData +from app.schemas.print_request import PrintRequest, RawLabelData from app.schemas.print_response import PrintJobResponse, PrintJobStatusResponse from app.services.job_lifecycle import JobState +from app.services.layout_engine import LayoutEngine from app.services.lookup_service import LookupFailedError from app.services.print_queue import PrinterAlreadyActiveError -from app.services.template_loader import TemplateNotFoundError _log = logging.getLogger(__name__) router = APIRouter() +# Separate router with /api prefix for the render/preview endpoint so that +# the effective URL matches the approved spec: POST /api/render/preview. +# The legacy /print, /jobs, /printer routes on `router` have no /api prefix +# for backwards-compatibility with existing clients; render/preview is new. +render_router = APIRouter(prefix="/api") class _PrinterResumeResponse(BaseModel): @@ -40,12 +52,14 @@ class _PrinterResumeResponse(BaseModel): _SYNC_ERROR_MAP: dict[type[Exception], tuple[int, str]] = { - TemplateNotFoundError: (404, "template_not_found"), LookupFailedError: (502, "integration_lookup_failed"), TapeMismatchError: (409, "tape_mismatch"), TapeEmptyError: (409, "tape_empty"), + NoTapeLoadedError: (409, "no_tape_loaded"), PrinterCoverOpenError: (409, "printer_cover_open"), PrinterOfflineError: (503, "printer_offline"), + UnsupportedTapeError: (409, "unsupported_tape"), + ContentTypeDataMismatchError: (422, "content_type_data_mismatch"), } @@ -244,3 +258,97 @@ async def resume_job( finished_at=getattr(job, "finished_at", None), live=None, ) + + +class _PreviewRequest(BaseModel): + """Request body for POST /render/preview. + + Phase 1k.1a (Task 25): render-only endpoint — no printer, no queue, no DB. + """ + + content_type: ContentType + data: RawLabelData + tape_mm: int = 12 + + +@render_router.post( + "/render/preview", + tags=["print"], + summary="Render a label preview as PNG", + description=( + "Render a label using the LayoutEngine and return the result as a " + "PNG image (no printer interaction, no job created). " + "Useful for UI preview and debugging. " + "Returns 422 when ``data`` is missing fields required by ``content_type``. " + "Returns 409 when ``tape_mm`` is not a supported tape width." + ), + response_class=Response, + responses={ + 200: {"content": {"image/png": {}}, "description": "PNG label bitmap"}, + 409: {"description": "Unsupported tape width"}, + 422: {"description": "Data missing required fields for content_type"}, + }, +) +async def render_preview( + body: _PreviewRequest, + _auth: Annotated[AuthContext, Depends(require_read)], +) -> Response: + """Render a label preview without touching the printer or DB. + + Uses a fresh LayoutEngine instance so that this endpoint works even + when no printer is configured (dev / CI mode). + + Returns 200 with Content-Type: image/png on success. + Returns 409 (unsupported_tape) when tape_mm is not in TAPE_GEOMETRY. + Returns 422 (content_type_data_mismatch) when data is missing fields for content_type. + """ + engine = LayoutEngine() + label_data = LabelData( + primary_id=body.data.primary_id, + title=body.data.title, + qr_payload=body.data.qr_payload, + source_app="preview", + secondary=body.data.secondary, + items=body.data.items, + ) + + def _render() -> bytes: + image = engine.render(body.tape_mm, body.content_type, label_data) + buf = io.BytesIO() + image.save(buf, format="PNG") + return buf.getvalue() + + # asyncio.to_thread: render() + image.save() are CPU-bound (QR generation, + # font rendering, PNG encoding). Offloading to a thread pool prevents blocking + # the event loop during heavy rendering. + # Errors are raised from the thread and re-raised here for structured handling. + # NOTE: We do NOT expose str(exc) directly to the client — CodeQL CWE-209: + # raw exception strings may contain stack trace fragments or internal paths. + try: + png_bytes = await asyncio.to_thread(_render) + except UnsupportedTapeError as exc: + return JSONResponse( + status_code=status.HTTP_409_CONFLICT, + content={ + "error_code": "unsupported_tape", + "error_message": ( + "The currently loaded tape width is not supported by the layout engine." + ), + "error_detail": {"tape_mm": exc.tape_mm}, + }, + ) + except ContentTypeDataMismatchError as exc: + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + # R2-4: align with /print endpoint (_SYNC_ERROR_MAP) which uses + # "content_type_data_mismatch" — was inconsistently "data_mismatch" + "error_code": "content_type_data_mismatch", + "error_message": ( + "The label data is missing fields required for the selected content type." + ), + "error_detail": {"missing_fields": list(exc.missing_fields)}, + }, + ) + + return Response(content=png_bytes, media_type="image/png") diff --git a/backend/app/api/routes/templates.py b/backend/app/api/routes/templates.py deleted file mode 100644 index ce796c4..0000000 --- a/backend/app/api/routes/templates.py +++ /dev/null @@ -1,244 +0,0 @@ -"""REST endpoints for the Templates aggregate (Phase 6a Task 2 + Bug-3 fix). - -Routes ------- -GET /api/templates?app= — list all templates, optionally - filtered by integration app (snipeit, grocy, spoolman, …) -POST /api/render/preview?key= — render a sample label as PNG - -The preview endpoint is used by the frontend template-detail page to show a -rendered preview image. Sample values are sourced from the template's own -``preview_sample`` block in its definition — the route does NOT fabricate -sample data. Templates without ``preview_sample`` return HTTP 422. - -References: - docs/superpowers/specs/2026-05-16-phase6a-rest-api-design.md — Templates section - docs/superpowers/plans/2026-05-16-phase6a-rest-api.md — Task 2 -""" - -from __future__ import annotations - -import asyncio -import io -import logging -from typing import Annotated, Any - -from fastapi import APIRouter, Depends, HTTPException, Query, Request -from fastapi.responses import Response -from sqlalchemy.ext.asyncio import AsyncSession - -from app.auth.dependencies import AuthContext -from app.auth.scope_deps import require_read -from app.db.session import get_session -from app.repositories import templates as templates_repo -from app.schemas.label_data import LabelData -from app.schemas.template import TemplateSchema -from app.schemas.template_read import TemplateRead -from app.services.label_renderer import LabelRenderer - -_log = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/templates", tags=["templates"]) - -# Separate router for /api/render so the preview endpoint can live here while -# the prefix keeps it at /api/render/preview (not /api/templates/render/preview). -render_router = APIRouter(prefix="/api/render", tags=["templates"]) - -# Type alias for the session dependency -SessionDep = Annotated[AsyncSession, Depends(get_session)] -ReadAuthDep = Annotated[AuthContext, Depends(require_read)] - - -def _build_label_data( - template_key: str, - template_app: str | None, - preview_sample: dict[str, Any], -) -> LabelData: - """Build a LabelData from a template's preview_sample dict. - - The template is responsible for declaring values for every ``field`` - and ``data_field`` its elements reference. Missing values raise - HTTPException 422. - """ - try: - # source_app is filled from the template's own ``app`` field — falls - # back to "generic" for templates without an integration. - return LabelData( - primary_id=str(preview_sample.get("primary_id", "")), - title=str(preview_sample.get("title", "")), - qr_payload=str(preview_sample.get("qr_payload", "")), - source_app=template_app or "generic", - secondary=tuple(preview_sample.get("secondary", ()) or ()), - ) - except Exception as exc: # ValidationError or coercion error - raise HTTPException( - status_code=422, - detail=(f"Template {template_key!r} has an invalid preview_sample block: {exc}"), - ) from exc - - -# LabelData fields that preview_sample values map to (via _build_label_data). -# Elements reference these by name via `field` / `data_field` on LayoutElement. -_LABEL_DATA_FIELDS: frozenset[str] = frozenset({"primary_id", "title", "qr_payload", "secondary"}) - - -def _validate_preview_sample_fields( - template_key: str, - elements: tuple[Any, ...], - preview_sample: dict[str, Any], -) -> None: - """Raise HTTP 422 if preview_sample is missing any field required by elements. - - Each LayoutElement of type ``text`` references a ``field`` on LabelData; - type ``qr`` references a ``data_field``. Both must be present in - ``preview_sample`` so the renderer does not silently produce empty output. - """ - element_fields = set() - for el in elements: - if el.type == "text" and el.field: - element_fields.add(el.field) - elif el.type == "qr" and el.data_field: - element_fields.add(el.data_field) - - # Restrict check to known LabelData fields — unknown names will resolve to - # empty strings via _resolve_field (getattr fallback) which is acceptable. - missing = (element_fields & _LABEL_DATA_FIELDS) - set(preview_sample.keys()) - if missing: - raise HTTPException( - status_code=422, - detail=( - f"Template {template_key!r} preview_sample is missing fields " - f"required by its elements: {sorted(missing)}. " - "Add these keys to the template's 'preview_sample' block." - ), - ) - - -@render_router.post( - "/preview", - response_class=Response, - responses={ - 200: { - "content": {"image/png": {"schema": {"type": "string", "format": "binary"}}}, - "description": "PNG image of the rendered sample label", - } - }, - summary="Render a template preview as PNG", - description=( - "Renders the named template with the sample values declared in the " - "template's own ``preview_sample`` block and returns a PNG image. " - "Returns 404 if the template key is not registered. " - "Returns 422 if the template has no ``preview_sample`` block." - ), -) -async def render_preview( - request: Request, - session: SessionDep, - key: str = Query(description="Template key, e.g. 'snipeit-12mm'"), -) -> Response: - """Render a sample preview PNG for the given template key. - - Sample values are taken from the template's own ``preview_sample`` block - (in ``template.definition``). Templates that do not declare one return - HTTP 422 with a clear error message — the route does NOT fabricate - fallback sample data. - - The LabelRenderer is reused from ``app.state.label_renderer`` (wired by - the lifespan) to avoid per-request font-loading overhead. The CPU-bound - render + PNG encode is offloaded to ``asyncio.to_thread`` so it does not - block the event loop. - """ - template_row = await templates_repo.get_by_key(session, key) - if template_row is None: - raise HTTPException(status_code=404, detail=f"template {key!r} not found") - - definition = dict(template_row.definition) - - # The preview_sample block lives in the template definition. Without it - # the template cannot be previewed — we refuse to guess on its behalf. - preview_sample = definition.get("preview_sample") - if not preview_sample or not isinstance(preview_sample, dict): - raise HTTPException( - status_code=422, - detail=( - f"Template {template_row.key!r} has no preview_sample in its " - "definition. Add a 'preview_sample' block to the template YAML " - "to enable previews." - ), - ) - - # Reconstruct TemplateSchema from the DB row — the definition column stores - # the TemplateSchema field values. Supplement missing fields from the row's - # top-level columns (id→key, tape_mm→tape_width_mm, etc.) so that rows - # created before the definition was normalised can still render. - schema_dict = dict(definition) - schema_dict.setdefault("id", template_row.key) - schema_dict.setdefault("name", template_row.name) - schema_dict.setdefault("app", template_row.app) - schema_dict.setdefault("tape_mm", template_row.tape_width_mm) - schema_dict.setdefault("schema_version", template_row.schema_version) - schema_dict.setdefault("elements", []) - - try: - template_schema = TemplateSchema(**schema_dict) - except Exception as exc: - # Log the sanitised key from the DB row (trusted), NOT the raw query - # parameter, to prevent log injection via crafted key values. - _log.warning("render_preview: invalid definition for key=%r: %s", template_row.key, exc) - raise HTTPException(status_code=422, detail=f"invalid template definition: {exc}") from exc - - # Validate that preview_sample provides all fields referenced by elements. - _validate_preview_sample_fields(template_row.key, template_schema.elements, preview_sample) - - sample_data = _build_label_data(template_row.key, template_row.app, preview_sample) - - # Reuse the shared renderer from app.state (avoids per-request font-loading). - # Fall back to a fresh instance when running outside a full lifespan - # (e.g. unit tests that don't wire app.state). - renderer: LabelRenderer = getattr(request.app.state, "label_renderer", None) or LabelRenderer() - - def _render_and_encode() -> bytes: - """CPU-bound render + PNG encode — runs in a thread pool.""" - try: - img = renderer.render(template_schema, sample_data) - except ValueError as exc: - # Log the sanitised key from the DB row (trusted), NOT the raw query - # parameter, to prevent log injection via crafted key values. - _log.warning("render_preview: render failed for key=%r: %s", template_row.key, exc) - raise - buf = io.BytesIO() - img.save(buf, format="PNG") - return buf.getvalue() - - try: - png_bytes = await asyncio.to_thread(_render_and_encode) - except ValueError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc - - return Response(content=png_bytes, media_type="image/png") - - -@router.get( - "", - response_model=list[TemplateRead], - summary="List all templates", - description=( - "Returns every registered template (seed + user). " - "Pass ``?app=`` to filter to a specific integration " - "(e.g. ``snipeit``, ``grocy``, ``spoolman``). " - "When the query parameter is absent all templates are returned." - ), -) -async def list_templates( - session: SessionDep, - _auth: ReadAuthDep, - app: str | None = Query( - default=None, - description="Filter by integration app (snipeit / grocy / spoolman / …)", - ), -) -> list[TemplateRead]: - """Return all templates, with an optional app-name filter.""" - rows = await templates_repo.list_all(session) - if app is not None: - rows = [r for r in rows if r.app == app] - return [TemplateRead.model_validate(r, from_attributes=True) for r in rows] diff --git a/backend/app/api/routes/templates_preview.py b/backend/app/api/routes/templates_preview.py deleted file mode 100644 index 30a89ed..0000000 --- a/backend/app/api/routes/templates_preview.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Phase 1i Sub-Task A+D: Preview-API für Templates. - -A: GET /api/templates/{key}/preview-png — Bitmap (Diagnose-Tool, 1:1 wie an Drucker) -D: GET /api/templates/{key}/preview-svg — SVG (Live-Preview, Hub-Dashboard-Modal) - -R3-Drift-Behebung #5: LabelData.source_app ist Pflichtfeld — kein Default. -Setzen wir auf "preview" für alle Preview-Aufrufe. -""" - -from __future__ import annotations - -import hashlib -import io -import json -from typing import cast - -from fastapi import APIRouter, Header, HTTPException, Query, Response - -from app.schemas.label_data import LabelData -from app.services.label_renderer import LabelRenderer -from app.services.svg_renderer import render_template_svg -from app.services.template_loader import TemplateLoader, TemplateNotFoundError - -router = APIRouter(prefix="/api/templates", tags=["templates-preview"]) - - -def _resolve_sample( - template_definition: dict[str, object], - primary_id: str | None, - title: str | None, - qr_payload: str | None, -) -> dict[str, object]: - """Merge query-params über template.preview_sample (Query gewinnt).""" - sample: dict[str, object] = dict( - cast(dict[str, object], template_definition.get("preview_sample") or {}) - ) - if primary_id is not None: - sample["primary_id"] = primary_id - if title is not None: - sample["title"] = title - if qr_payload is not None: - sample["qr_payload"] = qr_payload - sample.setdefault("title", "preview") - sample.setdefault("primary_id", "PREVIEW") - sample.setdefault("qr_payload", "https://hangar.example/preview") - return sample - - -def _label_data_from_sample(sample: dict[str, object], source_app: str) -> LabelData: - """Konstruiert LabelData aus dem sample-Dict mit expliziten Feldern. - - Vermeidet **dict[str, object] unpack (mypy: Argument 1 to LabelData incompatible). - cast() ist safe weil _resolve_sample alle Felder als str setzt. - """ - return LabelData( - primary_id=cast(str, sample.get("primary_id", "PREVIEW")), - title=cast(str, sample.get("title", "preview")), - qr_payload=cast(str, sample.get("qr_payload", "https://hangar.example/preview")), - secondary=cast(tuple[str, ...], sample.get("secondary", ())), - source_app=source_app, - ) - - -@router.get("/{key}/preview-png", response_class=Response) -def preview_png( - key: str, - primary_id: str | None = Query(default=None), - title: str | None = Query(default=None), - qr_payload: str | None = Query(default=None), -) -> Response: - """Bitmap-Preview 1:1 wie an den Drucker geht (Diagnose-Tool).""" - try: - template = TemplateLoader.get(key) - except TemplateNotFoundError as e: - raise HTTPException(status_code=404, detail=f"template not found: {key}") from e - - template_definition = template.model_dump() - sample = _resolve_sample(template_definition, primary_id, title, qr_payload) - - renderer = LabelRenderer() - # R3-Drift #5: source_app ist Pflichtfeld - label_data = _label_data_from_sample(sample, source_app="preview") - img = renderer.render(template, label_data) - - buf = io.BytesIO() - img.convert("L").save(buf, format="PNG") - return Response(content=buf.getvalue(), media_type="image/png") - - -@router.get("/{key}/preview-svg", response_class=Response) -def preview_svg( - key: str, - primary_id: str | None = Query(default=None), - title: str | None = Query(default=None), - qr_payload: str | None = Query(default=None), - if_none_match: str | None = Header(default=None, alias="If-None-Match"), -) -> Response: - """SVG-Preview für Hub-Dashboard-Modal und Hangar Print-Form. - - M3-Arch-Fix: ETag nur für Seed-Templates in Phase 1i (sample = stable). - ETag = sha256(key + sorted-json(sample))[:16]. - """ - try: - template = TemplateLoader.get(key) - except TemplateNotFoundError as e: - raise HTTPException(status_code=404, detail=f"template not found: {key}") from e - - template_definition = template.model_dump() - sample = _resolve_sample(template_definition, primary_id, title, qr_payload) - - # ETag aus template-key + sample-Inhalt (stabil für Seed-Templates) - seed_id = f"{key}|{json.dumps(sample, sort_keys=True)}" - etag = hashlib.sha256(seed_id.encode()).hexdigest()[:16] - weak_etag = f'"{etag}"' - - if if_none_match is not None and if_none_match.strip('"') == etag: - return Response(status_code=304, headers={"ETag": weak_etag}) - - # R3-Drift #5: source_app ist Pflichtfeld - label_data = _label_data_from_sample(sample, source_app="preview") - - # R4-A-C1+MA2-Fix: render_template_svg erwartet dicts — .model_dump() konvertiert - svg_str = render_template_svg( - template_definition=template.model_dump(), - sample_data=label_data.model_dump(), - ) - svg_bytes = svg_str.encode("utf-8") if isinstance(svg_str, str) else svg_str - - return Response( - content=svg_bytes, - media_type="image/svg+xml", - headers={"ETag": weak_etag, "Cache-Control": "private, max-age=300"}, - ) diff --git a/backend/app/db/lifespan.py b/backend/app/db/lifespan.py index 94b1a58..6ba9365 100644 --- a/backend/app/db/lifespan.py +++ b/backend/app/db/lifespan.py @@ -37,7 +37,6 @@ from app.models.printer import Printer from app.schemas.printer_config import PrinterYAMLConfig from app.services.printer_identity import derive_printer_id -from app.services.template_loader import TemplateLoader _logger = logging.getLogger(__name__) @@ -133,26 +132,16 @@ async def recover_inflight_jobs(session: AsyncSession) -> int: return await jobs_repo.mark_inflight_as_failed_restart(session) -async def seed_templates(session: AsyncSession, loader: type[TemplateLoader]) -> int: - """Idempotent YAML → DB upsert, delegated to ``loader.seed_db(session)``. +async def seed_templates(_session: AsyncSession, _loader: object = None) -> int: + """No-op stub — template seeding removed in Phase 1k.1a (Task 23/24). - The conversion logic lives on ``TemplateLoader.seed_db`` (Task 8) so - there is a single source of truth for the TemplateSchema → Template - column mapping. This function exists only as a named startup step that - main.py can call by name, and is the natural seam for unit tests that - want to inject a mock loader without touching the real registry. + TemplateLoader and the templates table were deleted. This function is + kept as a named symbol so existing test imports don't break until + Task 25 cleans up the test suite. - Raises RuntimeError if the loader cache is empty — calling seed_templates - without first running TemplateLoader.load_dir() is a lifespan-ordering bug. - - Returns the count of rows touched (inserted or updated). + Returns 0 (no rows touched). """ - if not loader._cache: - raise RuntimeError( - "seed_templates called with empty TemplateLoader cache — " - "TemplateLoader.load_dir() must run before seed_templates()." - ) - return await loader.seed_db(session) + return 0 async def ensure_printer_state(session: AsyncSession) -> int: diff --git a/backend/app/main.py b/backend/app/main.py index b02f3ed..302f47c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -82,11 +82,10 @@ from app.api.routes import lookup as lookup_routes from app.api.routes import printers as printers_routes from app.api.routes import qr as qr_routes -from app.api.routes import templates as templates_routes from app.api.routes import webhooks as webhooks_routes from app.api.routes.admin_api_keys import router as admin_api_keys_router +from app.api.routes.print import render_router from app.api.routes.print import router as print_router -from app.api.routes.templates_preview import router as templates_preview_router from app.auth.dependencies import AuthContext from app.auth.scope_deps import require_read from app.config import get_settings @@ -94,7 +93,6 @@ from app.db.lifespan import ( ensure_printer_state, run_migrations, - seed_templates, upsert_runtime_printers, verify_alembic_at_head, ) @@ -108,7 +106,7 @@ from app.services.cleanup_task import CleanupTask from app.services.event_bus import EventBus from app.services.job_store_sqlite import SQLiteJobStore -from app.services.label_renderer import LabelRenderer +from app.services.layout_engine import LayoutEngine from app.services.lookup_service import AppLookupService from app.services.print_queue import PrintQueue from app.services.print_service import PrintService @@ -118,7 +116,6 @@ from app.services.producers.tape_change_producer import TapeChangeProducer from app.services.readiness import build_readiness_response from app.services.tape_registry import TapeRegistry -from app.services.template_loader import TemplateLoader # Per ADR 0011 we pin the OpenAPI version explicitly rather than relying on # FastAPI's default, so a FastAPI upgrade can't drift the API contract version. @@ -133,7 +130,6 @@ "HUB_REPO_URL", "https://github.com/strausmann/label-printer-hub" ) -_SEED_TEMPLATES_DIR = Path(__file__).parent / "seed" / "templates" _log = logging.getLogger(__name__) @@ -261,31 +257,17 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: await verify_alembic_at_head(settings) # 2. Plugin registries (idempotent — skips already-registered names). - # Must run BEFORE TemplateLoader.load_dir() because load_dir validates - # each template's `app` field against IntegrationRegistry. Re-run if the - # registry was cleared (e.g. by test fixtures that call + # Re-run if the registry was cleared (e.g. by test fixtures that call # IntegrationRegistry._plugins.clear()). if not IntegrationRegistry.names(): _integrations_init._discover_plugins() ModelRegistry.ensure_discovered() - # 3. Populate in-memory template cache BEFORE any DB writes that depend on it. - # load_dir must come after plugin discovery (above) and before seed_templates - # (below) — the seed step reads from the cache that load_dir populates. - if _SEED_TEMPLATES_DIR.exists(): - TemplateLoader.load_dir(_SEED_TEMPLATES_DIR) - else: - raise RuntimeError( - f"Seed templates directory not found: {_SEED_TEMPLATES_DIR}. " - "The application package is incomplete — reinstall or rebuild the image." - ) - - # 4. DB-bound init — plugin registry and template cache are populated. + # 3. DB-bound init — plugin registry is populated. async with async_session() as s: # Phase 2: recover_inflight_jobs() entfernt (Spec R1-C1) — # PrintQueue.start() übernimmt Recovery mit korrekter QUEUED/PRINTING-Differenzierung. - await seed_templates(s, TemplateLoader) db_printer_ids = await upsert_runtime_printers(s, _printer_configs) await ensure_printer_state(s) # upsert_runtime_printers already commits; ensure_printer_state may need commit @@ -329,20 +311,17 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.state.event_bus = event_bus # ----- end SSE ------ - # Shared LabelRenderer reused by both PrintService, preview endpoint and - # PrintQueue Recovery. Constructing it once avoids repeated font-loading - # overhead on every POST /api/render/preview request. - # Moved before PrintQueue construction so Recovery in queue.start() can use it. - shared_renderer = LabelRenderer() - app.state.label_renderer = shared_renderer + # Shared LayoutEngine — stateless, safe to reuse across requests. + # Used by PrintQueue recovery and PrintService rendering. + shared_engine = LayoutEngine() + app.state.engine = shared_engine pq_producer = PrintQueueProducer(bus=event_bus) queue = PrintQueue( printers=queue_printers, on_state_change=pq_producer.handle_transition, store=job_store, - renderer=shared_renderer, - loader=TemplateLoader, + engine=shared_engine, ) await queue.start() @@ -397,9 +376,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # backends implement preflight_check). _BackendProto is a private Protocol # in print_service and cannot be imported here for a clean annotation. service = PrintService( - template_loader=TemplateLoader, - renderer=shared_renderer, - print_queue=queue, + engine=shared_engine, + queue=queue, lookup_service=AppLookupService(), printer_id=printer_id, backend=cast(Any, printer_backend), @@ -649,11 +627,11 @@ async def healthz(request: Request) -> Healthz: summary="Readiness probe", description=( "Deep readiness check: database connectivity, alembic migration " - "state, template seed, printer wiring, SNMP probe recency, " + "state, printer wiring, SNMP probe recency, " "print-queue liveness, and SSE subscriber capacity. " "Returns 200 with status in {ready, degraded} when all critical " "checks pass; 503 with status=not-ready when any critical check " - "(database / alembic / template_seed) fails." + "(database / alembic) fails." ), responses={503: {"model": ReadinessResponse}}, ) @@ -675,18 +653,16 @@ async def readiness( register_error_handlers(app) app.include_router(print_router) + app.include_router(render_router) app.include_router(batch_routes.router) app.include_router(batches_routes.router) app.include_router(events_routes.router) app.include_router(printers_routes.router) - app.include_router(templates_routes.router) - app.include_router(templates_routes.render_router) app.include_router(jobs_routes.router) app.include_router(lookup_routes.router) app.include_router(webhooks_routes.router) app.include_router(qr_routes.router) app.include_router(admin_api_keys_router) - app.include_router(templates_preview_router) _static_dir = Path(__file__).parent / "static" if _static_dir.exists(): diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 714088c..ab33ba6 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -11,7 +11,6 @@ from app.models.printer import Printer from app.models.printer_state import PrinterState from app.models.printer_status_cache import PrinterStatusCache -from app.models.template import Template __all__ = [ "ApiKey", @@ -22,5 +21,4 @@ "Printer", "PrinterState", "PrinterStatusCache", - "Template", ] diff --git a/backend/app/models/job.py b/backend/app/models/job.py index 7d70d34..ea3aef6 100644 --- a/backend/app/models/job.py +++ b/backend/app/models/job.py @@ -33,7 +33,8 @@ class Job(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) printer_id: UUID = Field(foreign_key="printers.id") - template_key: str # snapshot string — survives template deletion + # nullable since Phase 1k.1a (Task 15); Alembic migration in Task 22 + template_key: str | None = Field(default=None) state: str = Field(default=JobState.QUEUED.value) payload: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) result: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) diff --git a/backend/app/models/preset.py b/backend/app/models/preset.py index 11048d6..fc9db42 100644 --- a/backend/app/models/preset.py +++ b/backend/app/models/preset.py @@ -1,4 +1,10 @@ -"""SQLModel table definition for Preset entities.""" +"""SQLModel table definition for Preset entities. + +Phase 1k.1a (Task 25): template_id foreign key removed — the templates table +and Template model were deleted in Phase 1k.1a. Presets are now independent +of templates (template_id column dropped via migration +20260605_phase1k1a_drop_preset_template_id). +""" from __future__ import annotations @@ -16,7 +22,6 @@ class Preset(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) name: str printer_id: UUID | None = Field(default=None, foreign_key="printers.id") - template_id: UUID = Field(foreign_key="templates.id") field_values: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) created_at: datetime = Field( default_factory=lambda: datetime.now(UTC), diff --git a/backend/app/models/template.py b/backend/app/models/template.py deleted file mode 100644 index 09769fc..0000000 --- a/backend/app/models/template.py +++ /dev/null @@ -1,37 +0,0 @@ -"""SQLModel table definition for Template entities.""" - -from __future__ import annotations - -from datetime import UTC, datetime -from typing import Any -from uuid import UUID, uuid4 - -from sqlalchemy import JSON, CheckConstraint, DateTime -from sqlmodel import Column, Field, SQLModel - - -class Template(SQLModel, table=True): - __tablename__ = "templates" - __table_args__ = (CheckConstraint("source IN ('seed', 'user')", name="ck_templates_source"),) - - id: UUID = Field(default_factory=uuid4, primary_key=True) - key: str = Field(index=True, unique=True) - name: str - app: str | None = None - printer_model: str - tape_width_mm: int - schema_version: int = Field(default=1) - definition: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) - source: str = Field(default="user") - created_at: datetime = Field( - default_factory=lambda: datetime.now(UTC), - sa_column=Column(DateTime(timezone=True), nullable=False), - ) - updated_at: datetime = Field( - default_factory=lambda: datetime.now(UTC), - sa_column=Column( - DateTime(timezone=True), - nullable=False, - onupdate=lambda: datetime.now(UTC), - ), - ) diff --git a/backend/app/printer_backends/exceptions.py b/backend/app/printer_backends/exceptions.py index d2717a8..35a41bc 100644 --- a/backend/app/printer_backends/exceptions.py +++ b/backend/app/printer_backends/exceptions.py @@ -50,3 +50,65 @@ class SnmpQueryError(PrinterError): """Live-status SNMP query failed at request time. Non-fatal — the live block is omitted from the response. """ + + +class UnsupportedTapeError(PrinterError): + """Raised when the preflight-detected tape_mm is not in TAPE_GEOMETRY. + + Inherits from PrinterError to keep the exception hierarchy consistent: + all hardware/tape precondition failures share the same base class and + can be caught with a single ``except PrinterError`` if needed. + + HTTP-Status: 409 (Conflict) — same family as TapeEmptyError, CoverOpenError. + The user must switch to a supported tape; retrying with the same loaded + tape will fail again. + + Defensive: with 7 supported sizes (4/6/9/12/18/24/62mm) this should not + occur in typical hardware setups (PT-Serie + QL-820NWB). Die bestehende + TapeRegistry kennt zusätzliche QL-DK-Breiten (29/38/50/54mm), die in + 1k.1 bewusst noch nicht abgedeckt sind — Erweiterung als Folge-Phase + möglich. + """ + + def __init__(self, *, tape_mm: int) -> None: + self.tape_mm = tape_mm + supported = (4, 6, 9, 12, 18, 24, 62) + super().__init__( + f"Tape width {tape_mm}mm is not supported by the layout engine. Supported: {supported}" + ) + + +class NoTapeLoadedError(PrinterError): + """Raised when preflight returns loaded_tape_mm=None (no tape inserted). + + Inherits from PrinterError to keep the exception hierarchy consistent: + all hardware/tape precondition failures share the same base class and + can be caught with a single ``except PrinterError`` if needed. + + HTTP-Status: 409 (Conflict) — physical hardware state, retry needed + after user inserts tape. + """ + + def __init__(self) -> None: + super().__init__("No tape loaded — insert a Brother TZe or DK cartridge.") + + +class ContentTypeDataMismatchError(Exception): + """Raised when LabelData lacks fields required by the chosen ContentType. + + HTTP-Status: 422 (Unprocessable Entity) — client can correct the + request payload and retry without changing hardware state. + """ + + def __init__( + self, + *, + content_type: str, + missing_fields: tuple[str, ...], + ) -> None: + self.content_type = content_type + self.missing_fields = missing_fields + super().__init__( + f"ContentType '{content_type}' requires fields {list(missing_fields)} " + f"in LabelData — please populate them and retry." + ) diff --git a/backend/app/repositories/jobs.py b/backend/app/repositories/jobs.py index e61d260..4d10d21 100644 --- a/backend/app/repositories/jobs.py +++ b/backend/app/repositories/jobs.py @@ -46,7 +46,7 @@ async def create_queued( session: AsyncSession, *, printer_id: UUID, - template_key: str, + template_key: str | None, payload: dict[str, Any], api_key_id: UUID | None = None, source_ip: str | None = None, diff --git a/backend/app/repositories/templates.py b/backend/app/repositories/templates.py deleted file mode 100644 index 32af896..0000000 --- a/backend/app/repositories/templates.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Repository functions for the Template aggregate.""" - -from __future__ import annotations - -from collections.abc import Iterable - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import col - -from app.models.template import Template - - -async def list_all(session: AsyncSession) -> list[Template]: - result = await session.execute(select(Template).order_by(Template.key)) - return list(result.scalars()) - - -async def get_by_key(session: AsyncSession, key: str) -> Template | None: - result = await session.execute( - select(Template).where(col(Template.key) == key) # col() gives proper Column typing - ) - return result.scalar_one_or_none() - - -async def upsert_seed(session: AsyncSession, templates: Iterable[Template]) -> int: - """Idempotent: insert if key missing, update body if key exists with source='seed'. - - Does NOT touch source='user' rows even if their key matches. - Returns count of rows touched. - """ - touched = 0 - for tpl in templates: - existing = await get_by_key(session, tpl.key) - if existing is None: - tpl.source = "seed" - session.add(tpl) - touched += 1 - continue - if existing.source == "user": - continue # never overwrite user rows - # Update seed row in place - existing.name = tpl.name - existing.app = tpl.app - existing.printer_model = tpl.printer_model - existing.tape_width_mm = tpl.tape_width_mm - existing.definition = tpl.definition - existing.schema_version = tpl.schema_version - session.add(existing) - touched += 1 - await session.commit() - return touched - - -async def create_user_template(session: AsyncSession, template: Template) -> Template: - template.source = "user" - session.add(template) - await session.commit() - await session.refresh(template) - return template diff --git a/backend/app/schemas/content_type.py b/backend/app/schemas/content_type.py new file mode 100644 index 0000000..3f4f6fd --- /dev/null +++ b/backend/app/schemas/content_type.py @@ -0,0 +1,36 @@ +"""Semantic content types — tape-independent label descriptions. + +Each ContentType describes WHAT is rendered (QR + N text lines, or listing, +or text-only). The renderer (LayoutEngine) consumes (tape_mm, content_type, +data) and produces a PIL Image — pixel positions are computed from the +TapeGeometry table, not from the ContentType. +""" + +from __future__ import annotations + +from enum import StrEnum + + +class ContentType(StrEnum): + """Tape-independent semantic content types for label rendering.""" + + QR_ONLY = "qr_only" + """QR fills the full tape height; no text.""" + + QR_ONE_LINE = "qr_one_line" + """QR left + 1 text line (XL, vertically centered): qr_payload + primary_id.""" + + QR_TWO_LINES = "qr_two_lines" + """QR left + 2 text lines (XL primary_id + L title).""" + + QR_THREE_LINES = "qr_three_lines" + """QR left + 3 text lines (XL primary_id + L title + S secondary[0]).""" + + TEXT_ONE_LINE = "text_one_line" + """Full-width text XL (primary_id); no QR.""" + + TEXT_TWO_LINES = "text_two_lines" + """2 text lines (XL primary_id + L title); no QR.""" + + QR_WITH_LISTING = "qr_with_listing" + """QR + N item lines (M font); overflow shows "+N more".""" diff --git a/backend/app/schemas/job.py b/backend/app/schemas/job.py index 02bdc7b..5352f57 100644 --- a/backend/app/schemas/job.py +++ b/backend/app/schemas/job.py @@ -23,7 +23,7 @@ class JobRead(BaseModel): id: UUID printer_id: UUID - template_key: str + template_key: str | None state: str # 'queued' | 'printing' | 'done' | 'failed' | 'cancelled' | 'failed_restart' payload: dict[str, Any] result: dict[str, Any] | None diff --git a/backend/app/schemas/label_data.py b/backend/app/schemas/label_data.py index 45a7805..96f22af 100644 --- a/backend/app/schemas/label_data.py +++ b/backend/app/schemas/label_data.py @@ -1,28 +1,53 @@ -"""App-agnostic label data passed from lookup-clients to the LabelRenderer. +"""App-agnostic label data passed from lookup-clients to the LayoutEngine. LabelData is what a `*_client.lookup(id)` call produces. It is the serialisable view of a real-world entity (Snipe-IT asset, Grocy product, -Spoolman spool) condensed into the minimal set of fields a label needs: -a title, an identifier to print, a QR-encodable URL, optional secondary -lines, and a source-app tag for downstream routing. +Spoolman spool, Hangar location) condensed into the minimal set of fields +a label may need: an optional title, an optional identifier, an optional +QR payload, optional secondary lines, and a source-app tag. -Layout, font, geometry, and tape-fit decisions live on the LabelRenderer -side — they are NOT in this model. +Phase 1k.1a: All content fields are optional because ContentType selects +which fields are required for a given render call. The LayoutEngine +validates per-ContentType requirements in `_validate_data()` and raises +`ContentTypeDataMismatchError` if the required fields are missing. + +Only `source_app` remains required — it is used for downstream routing, +logging, and metrics independent of the chosen ContentType. + +Layout, font, geometry, and tape-fit decisions live in TapeGeometry + +LayoutEngine, NOT here. """ from __future__ import annotations from pydantic import BaseModel, ConfigDict +from app.schemas.label_data_item import LabelDataItem + class LabelData(BaseModel): """Immutable, app-agnostic label payload.""" model_config = ConfigDict(frozen=True) - title: str - primary_id: str - qr_payload: str source_app: str + """Source application tag (e.g. 'snipeit', 'grocy', 'spoolman', 'hangar', 'manual').""" + + title: str | None = None + """Optional title; required by qr_two_lines, qr_three_lines, text_two_lines.""" + + primary_id: str | None = None + """Optional primary identifier. + + Required by qr_one_line, *_two_lines, *_three_lines, text_one_line, + qr_with_listing (header). + """ + + qr_payload: str | None = None + """Optional URL/payload for the QR code; required by qr_only, qr_*_line(s), qr_with_listing.""" + secondary: tuple[str, ...] = () - """Additional label lines below the primary identifier.""" + """Optional additional text lines; first entry rendered by qr_three_lines.""" + + items: tuple[LabelDataItem, ...] = () + """Child items for qr_with_listing aggregation labels (Kallax-Regal-Uebersicht etc.).""" diff --git a/backend/app/schemas/label_data_item.py b/backend/app/schemas/label_data_item.py new file mode 100644 index 0000000..69fd9b3 --- /dev/null +++ b/backend/app/schemas/label_data_item.py @@ -0,0 +1,17 @@ +"""Single child entry for qr_with_listing aggregation labels.""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + + +class LabelDataItem(BaseModel): + """One row in a qr_with_listing label (e.g. Kallax-Regal-Uebersicht).""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + item: str + """Display text for this child (e.g. 'A — Schrauben').""" + + qr_payload: str | None = None + """Optional per-child QR payload (reserved; not rendered in 1k.1a).""" diff --git a/backend/app/schemas/lookup.py b/backend/app/schemas/lookup.py index 7b17e09..9b44336 100644 --- a/backend/app/schemas/lookup.py +++ b/backend/app/schemas/lookup.py @@ -28,10 +28,12 @@ class LookupResult(BaseModel): id: str = Field( description="The entity identifier as supplied by the caller", ) - name: str = Field( + name: str | None = Field( + default=None, description="Human-readable display name of the entity", ) - url: str = Field( + url: str | None = Field( + default=None, description=( "Deep-link URL to the entity in the integration's web UI " "(e.g. Snipe-IT asset page, Grocy product page, Spoolman spool page)" diff --git a/backend/app/schemas/print_batch.py b/backend/app/schemas/print_batch.py index 10d2f6a..adbec77 100644 --- a/backend/app/schemas/print_batch.py +++ b/backend/app/schemas/print_batch.py @@ -48,4 +48,5 @@ class BatchResponse(BaseModel): printer_id: UUID queued_at: str # ISO-8601 mit Z-Suffix job_ids: list[str] - errors: list[BatchError] = Field(default_factory=list) + # R2-3: errors field removed — route never populates it after Task-17 refactor. + # BatchError class retained for potential future use (pre-validation per item). diff --git a/backend/app/schemas/print_request.py b/backend/app/schemas/print_request.py index c622d01..ad406f3 100644 --- a/backend/app/schemas/print_request.py +++ b/backend/app/schemas/print_request.py @@ -1,11 +1,19 @@ -"""Request schemas for POST /print and supporting models.""" +"""Request schemas for POST /api/print + supporting models. + +Phase 1k.1a: template_id and on_tape_mismatch removed; content_type added. +RawLabelData mirrors LabelData (minus source_app which is set server-side +to 'manual' for raw requests). +""" from __future__ import annotations -from typing import Literal, Self +from typing import Self from pydantic import BaseModel, ConfigDict, Field, model_validator +from app.schemas.content_type import ContentType +from app.schemas.label_data_item import LabelDataItem + class PrintLookupRequest(BaseModel): """Resolve label data via an integration plugin.""" @@ -22,7 +30,6 @@ class PrintOptions(BaseModel): copies: int = Field(default=1, ge=1, le=10) auto_cut: bool = True high_resolution: bool = False - # Phase 1i C-Fix: half_cut: bool = False last_page: bool = True @@ -30,28 +37,43 @@ class PrintOptions(BaseModel): class RawLabelData(BaseModel): """Raw label payload accepted when the client supplies data directly. - Mirrors LabelData minus `source_app` (always set to "manual" server-side). + Mirrors LabelData minus `source_app` (set server-side to 'manual'). + All content fields are optional — ContentType-specific validation + happens in LayoutEngine._validate_data. """ model_config = ConfigDict(frozen=True, extra="forbid") - title: str - primary_id: str - qr_payload: str - secondary: list[str] = Field(default_factory=list) + title: str | None = None + primary_id: str | None = None + qr_payload: str | None = None + secondary: tuple[str, ...] = () + items: tuple[LabelDataItem, ...] = () class PrintRequest(BaseModel): - """Top-level POST /print body.""" + """POST /api/print body. + + Either `data` (RawLabelData) or `lookup` (PrintLookupRequest) is provided. + Exactly one of the two must be present. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + content_type: ContentType + """Semantic content type — drives LayoutEngine render dispatch.""" + + options: PrintOptions = PrintOptions() + """Per-print options (copies, cut behaviour, etc.).""" - model_config = ConfigDict(extra="forbid") - template_id: str - lookup: PrintLookupRequest | None = None data: RawLabelData | None = None - options: PrintOptions = Field(default_factory=PrintOptions) - on_tape_mismatch: Literal["fail", "queue"] = "fail" + """Raw label data (preferred over lookup).""" + + lookup: PrintLookupRequest | None = None + """Lookup-based label data (resolved via plugin).""" @model_validator(mode="after") - def _exactly_one_source(self) -> Self: - if (self.lookup is None) == (self.data is None): - raise ValueError("Exactly one of `lookup` or `data` must be set.") + def _exactly_one_data_source(self) -> Self: + if (self.data is None) == (self.lookup is None): + msg = "Exactly one of 'data' or 'lookup' must be set." + raise ValueError(msg) return self diff --git a/backend/app/schemas/tape_geometry.py b/backend/app/schemas/tape_geometry.py new file mode 100644 index 0000000..1eb674c --- /dev/null +++ b/backend/app/schemas/tape_geometry.py @@ -0,0 +1,134 @@ +"""Brother printer tape geometry — pixel dimensions per supported tape width. + +Each TapeGeometry entry describes the printable area and layout parameters for +a single tape width. The renderer (LayoutEngine) consumes these to position +QR codes and text deterministically, independent of which ContentType is used. + +The 12mm values are empirically validated (Phase 1i V4-Winner, scan-verified). +Other tape widths are extrapolated via pixel-ratio from 12mm and require +post-deploy smoke-test validation. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class TapeGeometry(BaseModel): + """Render parameters for one supported tape width (all values in pixels). + + Formulas (enforced by TAPE_GEOMETRY entries): + qr_max_px = printable_px - 2 * qr_padding_px + text_start_x = printable_px + qr_padding_px + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + printable_px: int = Field(gt=0) + """Print-pin count per tape (Brother spec).""" + + qr_max_px: int = Field(gt=0) + """Square QR-code edge length: printable_px - 2 * qr_padding_px.""" + + qr_padding_px: int = Field(ge=0) + """Padding around the QR-code (also separator gap before text column).""" + + text_start_x: int = Field(ge=0) + """Absolute X-position where text rendering starts (after QR + gap).""" + + line_spacing_px: int = Field(ge=0) + """Vertical gap between adjacent text lines.""" + + font_xl: int = Field(gt=0) + """primary_id font size.""" + + font_l: int = Field(gt=0) + """title font size.""" + + font_m: int = Field(gt=0) + """listing item / secondary content font size.""" + + font_s: int = Field(gt=0) + """secondary line font size.""" + + +TAPE_GEOMETRY: dict[int, TapeGeometry] = { + 4: TapeGeometry( + printable_px=24, + qr_max_px=20, + qr_padding_px=2, + text_start_x=26, + line_spacing_px=1, + font_xl=8, + font_l=7, + font_m=6, + font_s=5, + ), + 6: TapeGeometry( + printable_px=32, + qr_max_px=28, + qr_padding_px=2, + text_start_x=34, + line_spacing_px=2, + font_xl=10, + font_l=9, + font_m=7, + font_s=6, + ), + 9: TapeGeometry( + printable_px=50, + qr_max_px=46, + qr_padding_px=2, + text_start_x=52, + line_spacing_px=3, + font_xl=14, + font_l=12, + font_m=10, + font_s=8, + ), + 12: TapeGeometry( + printable_px=70, + qr_max_px=66, + qr_padding_px=2, + text_start_x=72, + line_spacing_px=4, + font_xl=22, + font_l=18, + font_m=14, + font_s=10, + ), + 18: TapeGeometry( + printable_px=112, + qr_max_px=108, + qr_padding_px=2, + text_start_x=114, + line_spacing_px=6, + font_xl=32, + font_l=26, + font_m=20, + font_s=14, + ), + 24: TapeGeometry( + printable_px=128, + qr_max_px=124, + qr_padding_px=2, + text_start_x=130, + line_spacing_px=8, + font_xl=36, + font_l=30, + font_m=24, + font_s=18, + ), + 62: TapeGeometry( + printable_px=696, + qr_max_px=688, + qr_padding_px=4, + text_start_x=700, + line_spacing_px=20, + font_xl=120, + font_l=96, + font_m=72, + font_s=48, + ), +} +"""Map int(tape_mm) -> TapeGeometry. 12mm scan-verified, others extrapolated.""" diff --git a/backend/app/schemas/template.py b/backend/app/schemas/template.py deleted file mode 100644 index 9775e0c..0000000 --- a/backend/app/schemas/template.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Label-template schema describing the layout of a printable label. - -A `TemplateSchema` is a recipe for placing QR codes and text on the -printable area of a Brother tape. The renderer consumes a template plus a -`LabelData` payload and emits a 1-bit PIL Image ready for the printer. - -Templates are frozen at construction so they can be safely seeded as -module-level constants (see app/seed/templates.py in PR D2). - -Immutability note: Pydantic `frozen=True` prevents attribute re-assignment -but does NOT deep-freeze container values. The ``preview_sample`` field -therefore uses ``tuple[str, ...]`` for its sequence type (instead of -``list[str]``) so the entire schema is truly immutable after construction. -""" - -from __future__ import annotations - -from typing import Literal - -from pydantic import BaseModel, ConfigDict, model_validator - - -class LayoutElement(BaseModel): - """A single drawable element — either a QR code or a text run. - - The `type` field discriminates which subset of the optional fields - is required. `model_validator(mode="after")` enforces the contract - at construction time so the renderer can trust the shape. - """ - - model_config = ConfigDict(frozen=True) - - type: Literal["qr", "text"] - x: int - y: int - # qr-specific - size: int | None = None - data_field: str | None = None - # text-specific - field: str | None = None - font_size: int | None = None - - @model_validator(mode="after") - def _validate_per_type(self) -> LayoutElement: - if self.type == "qr": - if not self.data_field: - raise ValueError("qr element requires data_field") - if self.size is None or self.size <= 0: - raise ValueError(f"qr element requires a positive size (got {self.size!r})") - else: # type == "text" - if not self.field: - raise ValueError("text element requires field") - if self.font_size is None or self.font_size <= 0: - raise ValueError( - f"text element requires a positive font_size (got {self.font_size!r})" - ) - return self - - -class TemplateSchema(BaseModel): - """A complete label template — identity, target app, tape size, and layout. - - ``app`` is the canonical plugin name (e.g. ``"snipeit"``) matching a - registered ``IntegrationPlugin``. ``app=None`` marks a generic template - (e.g. QR-only) that works with any plugin. The plugin reference is - validated at load time against ``IntegrationRegistry``; the schema - itself accepts any string so plugins can be added without a schema - migration. - - ``preview_sample`` is an optional mapping of field name → sample value - used by the preview-render endpoint (``POST /api/render/preview``). - Each template declares its own preview values so the route never has - to fabricate sample data per-app. Keys must match the ``field`` / - ``data_field`` names referenced by ``elements``; supported keys are - ``primary_id``, ``title``, ``qr_payload``, and optionally ``secondary`` - (list/tuple of additional lines). - """ - - model_config = ConfigDict(frozen=True) - - schema_version: int = 1 - id: str - name: str - app: str | None - tape_mm: int - printer_model: str | None = None - """Optional printer-model hint. ``None`` → seed_db() defaults to - ``'pt-series'`` for backward-compatibility with existing YAMLs. - Use ``'brother_ql'`` for QL Endless (62mm DK) templates. - Used by the print-dispatcher (Phase 2) to route to the correct - hardware backend. - """ - elements: tuple[LayoutElement, ...] - # Values use tuple (not list) so the entire schema is deeply immutable — - # Pydantic frozen=True only prevents attribute re-assignment, not mutation - # of mutable containers stored in those attributes. - preview_sample: dict[str, str | int | float | bool | tuple[str, ...]] | None = None diff --git a/backend/app/schemas/template_read.py b/backend/app/schemas/template_read.py deleted file mode 100644 index c88978c..0000000 --- a/backend/app/schemas/template_read.py +++ /dev/null @@ -1,39 +0,0 @@ -"""API read schema for Template entities (Phase 6a). - -``TemplateRead`` maps the ``templates`` DB table to a JSON-serialisable -Pydantic model for the REST API. It is distinct from ``TemplateSchema`` -(the renderer's layout descriptor) — the DB row carries additional fields -(``id``, ``source``, ``created_at``, etc.) that the renderer does not need. -""" - -from __future__ import annotations - -from datetime import datetime -from typing import Any -from uuid import UUID - -from pydantic import BaseModel, ConfigDict, field_serializer - -from app.schemas._datetime import serialize_datetime_utc - - -class TemplateRead(BaseModel): - """Serialised view of a Template DB row.""" - - model_config = ConfigDict(from_attributes=True) - - id: UUID - key: str - name: str - app: str | None - printer_model: str - tape_width_mm: int - schema_version: int - definition: dict[str, Any] - source: str - created_at: datetime - updated_at: datetime - - @field_serializer("created_at", "updated_at") - def _serialise_datetimes(self, dt: datetime, _info: object) -> str: - return serialize_datetime_utc(dt, _info) diff --git a/backend/app/seed/templates/grocy-12mm.yaml b/backend/app/seed/templates/grocy-12mm.yaml deleted file mode 100644 index dc833ad..0000000 --- a/backend/app/seed/templates/grocy-12mm.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Compact: QR + product id + name. Grocy LabelData has empty secondary, -# so the 12mm layout (no secondary slot) is the natural fit. -# A-Diagnose Fix: Canvas 106px→70px. scale_y=70/106≈0.660. -# QR 58px (y=4: 4+58=62 < 70). Text-Y proportional skaliert. -schema_version: 1 -id: grocy-12mm -name: "Grocy Product (12mm)" -app: grocy -tape_mm: 12 -printer_model: ptouch -elements: - - { type: qr, x: 5, y: 4, size: 58, data_field: qr_payload } - - { type: text, x: 72, y: 4, field: primary_id, font_size: 14 } - - { type: text, x: 72, y: 38, field: title, font_size: 10 } -preview_sample: - primary_id: "Erdbeermarmelade" - title: "Lager > Vorrat" - qr_payload: "https://grocy.example.com/stock/products/47" diff --git a/backend/app/seed/templates/grocy-18mm.yaml b/backend/app/seed/templates/grocy-18mm.yaml deleted file mode 100644 index ec0ec91..0000000 --- a/backend/app/seed/templates/grocy-18mm.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# 18mm — secondary slot reserved (Grocy lookup may populate it later). -# A-Diagnose Fix: Canvas 165px→112px. scale_y=112/165≈0.679. -# QR 95px (y=6: 6+95=101 < 112). Text-Y proportional skaliert. -# 3-Zeilen-Layout (primary+title+secondary) in 112px kompakt. -schema_version: 1 -id: grocy-18mm -name: "Grocy Product (18mm)" -app: grocy -tape_mm: 18 -printer_model: ptouch -elements: - - { type: qr, x: 8, y: 6, size: 95, data_field: qr_payload } - - { type: text, x: 112, y: 6, field: primary_id, font_size: 22 } - - { type: text, x: 112, y: 42, field: title, font_size: 15 } - - { type: text, x: 112, y: 80, field: secondary, font_size: 11 } -preview_sample: - primary_id: "Erdbeermarmelade" - title: "Lager > Vorrat" - qr_payload: "https://grocy.example.com/stock/products/47" - secondary: ["MHD 2027-04-30", "3 Glaeser"] diff --git a/backend/app/seed/templates/grocy-24mm.yaml b/backend/app/seed/templates/grocy-24mm.yaml deleted file mode 100644 index e3bf8d3..0000000 --- a/backend/app/seed/templates/grocy-24mm.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# 24mm — full layout. -# A-Diagnose Fix: Canvas 256px→128px. scale_y=128/256=0.500. -# QR 110px (y=8: 8+110=118 < 128). Text-Y proportional skaliert. -# 3-Zeilen-Layout (primary+title+secondary) in 128px. -schema_version: 1 -id: grocy-24mm -name: "Grocy Product (24mm)" -app: grocy -tape_mm: 24 -printer_model: ptouch -elements: - - { type: qr, x: 8, y: 8, size: 110, data_field: qr_payload } - - { type: text, x: 128, y: 8, field: primary_id, font_size: 28 } - - { type: text, x: 128, y: 48, field: title, font_size: 18 } - - { type: text, x: 128, y: 86, field: secondary, font_size: 13 } -preview_sample: - primary_id: "Erdbeermarmelade" - title: "Lager > Vorrat" - qr_payload: "https://grocy.example.com/stock/products/47" - secondary: ["MHD 2027-04-30", "3 Glaeser"] diff --git a/backend/app/seed/templates/hangar-furniture-12mm.yaml b/backend/app/seed/templates/hangar-furniture-12mm.yaml deleted file mode 100644 index 48f36c9..0000000 --- a/backend/app/seed/templates/hangar-furniture-12mm.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# Hangar Furniture Tag — 12mm Endlostape (TZe-231) -# ptouch PT-P750W 180 DPI: 12mm = 70 print_pins (Tape12mm). -# A-Diagnose Fix: Canvas 106px→70px. Y/size mit scale_y=70/106≈0.660. -# QR 58px (y=4: 4+58=62 < 70). Text-Y proportional skaliert. -# Layout: QR links (58x58), primary_id oben rechts, title kleiner darunter. -schema_version: 1 -id: hangar-furniture-12mm -name: "Hangar Furniture Tag (TZe-231, 12mm)" -app: null -tape_mm: 12 -printer_model: ptouch -elements: - - { type: qr, x: 5, y: 4, size: 58, data_field: qr_payload } - - { type: text, x: 75, y: 5, field: primary_id, font_size: 13 } - - { type: text, x: 75, y: 36, field: title, font_size: 10 } -preview_sample: - primary_id: "HH-AK-KX10-F0203" - title: "Kallax Nr.10 Fach 2-3" - qr_payload: "https://hangar.example.test/loc/HH-AK-KX10-F0203" diff --git a/backend/app/seed/templates/hangar-furniture-18mm.yaml b/backend/app/seed/templates/hangar-furniture-18mm.yaml deleted file mode 100644 index 8572109..0000000 --- a/backend/app/seed/templates/hangar-furniture-18mm.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Hangar Furniture Hauptschild — 18mm extrastark (TZe-S251) -# ptouch PT-P750W 180 DPI: 18mm = 112 print_pins (Tape18mm). -# A-Diagnose Fix: Canvas 165px→112px. Y/size mit scale_y=112/165≈0.679. -# QR 95px (y=6: 6+95=101 < 112). Text-Y proportional skaliert. -schema_version: 1 -id: hangar-furniture-18mm -name: "Hangar Furniture Hauptschild (TZe-S251, 18mm extrastark)" -app: null -tape_mm: 18 -printer_model: ptouch -elements: - - { type: qr, x: 8, y: 6, size: 95, data_field: qr_payload } - - { type: text, x: 115, y: 8, field: primary_id, font_size: 22 } - - { type: text, x: 115, y: 56, field: title, font_size: 14 } -preview_sample: - primary_id: "HH-VK-BY03" - title: "Billy VK Nr.3 Hauptschild" - qr_payload: "https://hangar.example.test/loc/HH-VK-BY03" diff --git a/backend/app/seed/templates/hangar-furniture-24mm.yaml b/backend/app/seed/templates/hangar-furniture-24mm.yaml deleted file mode 100644 index 3b7cf8a..0000000 --- a/backend/app/seed/templates/hangar-furniture-24mm.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Hangar Furniture Tag — 24mm (TZe-251) -# ptouch PT-P750W 180 DPI: 24mm = 128 print_pins (Tape24mm). -# A-Diagnose Fix: Canvas 256px→128px. Y/size mit scale_y=128/256=0.500. -# QR 110px (y=8: 8+110=118 < 128). Text-Y proportional skaliert. -schema_version: 1 -id: hangar-furniture-24mm -name: "Hangar Furniture Tag (TZe-251, 24mm)" -app: null -tape_mm: 24 -printer_model: ptouch -elements: - - { type: qr, x: 10, y: 8, size: 110, data_field: qr_payload } - - { type: text, x: 130, y: 10, field: primary_id, font_size: 24 } - - { type: text, x: 130, y: 60, field: title, font_size: 16 } -preview_sample: - primary_id: "HH-AK-RAUM01" - title: "Arbeitskeller Raum" - qr_payload: "https://hangar.example.test/loc/HH-AK-RAUM01" diff --git a/backend/app/seed/templates/qr-only-12mm.yaml b/backend/app/seed/templates/qr-only-12mm.yaml deleted file mode 100644 index c5e7438..0000000 --- a/backend/app/seed/templates/qr-only-12mm.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# A-Diagnose Fix: Canvas 106px→70px. QR 58px centered vertically: -# y = (70 - 58) // 2 = 6. x = (600 - 58) // 2 = 271 (centered on label length). -schema_version: 1 -id: qr-only-12mm -name: "QR-Code only (12mm)" -app: null -tape_mm: 12 -printer_model: ptouch -elements: - - { type: qr, x: 271, y: 6, size: 58, data_field: qr_payload } -preview_sample: - primary_id: "Sample" - title: "Preview" - qr_payload: "https://example.com/preview" diff --git a/backend/app/seed/templates/qr-only-18mm.yaml b/backend/app/seed/templates/qr-only-18mm.yaml deleted file mode 100644 index 5e4ef5d..0000000 --- a/backend/app/seed/templates/qr-only-18mm.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# A-Diagnose Fix: Canvas 165px→112px. QR 100px centered vertically: -# y = (112 - 100) // 2 = 6. x = (600 - 100) // 2 = 250 (centered on label length). -schema_version: 1 -id: qr-only-18mm -name: "QR-Code only (18mm)" -app: null -tape_mm: 18 -printer_model: ptouch -elements: - - { type: qr, x: 250, y: 6, size: 100, data_field: qr_payload } -preview_sample: - primary_id: "Sample" - title: "Preview" - qr_payload: "https://example.com/preview" diff --git a/backend/app/seed/templates/qr-only-24mm.yaml b/backend/app/seed/templates/qr-only-24mm.yaml deleted file mode 100644 index 6154a52..0000000 --- a/backend/app/seed/templates/qr-only-24mm.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# A-Diagnose Fix: Canvas 256px→128px. QR 120px centered vertically: -# y = (128 - 120) // 2 = 4. x = (600 - 120) // 2 = 240 (centered on label length). -schema_version: 1 -id: qr-only-24mm -name: "QR-Code only (24mm)" -app: null -tape_mm: 24 -printer_model: ptouch -elements: - - { type: qr, x: 240, y: 4, size: 120, data_field: qr_payload } -preview_sample: - primary_id: "Sample" - title: "Preview" - qr_payload: "https://example.com/preview" diff --git a/backend/app/seed/templates/samla-deckel-12mm.yaml b/backend/app/seed/templates/samla-deckel-12mm.yaml deleted file mode 100644 index 7b6d5f6..0000000 --- a/backend/app/seed/templates/samla-deckel-12mm.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Samla Deckel — 12mm Endlostape (TZe-231) -# Für IKEA Samla Behälter: Deckel-Etikett mit QR + Standort-ID + Titel. -# ptouch PT-P750W 180 DPI: 12mm = 70 print_pins (Tape12mm). -# QR 58px (y=4: 4+58=62 < 70). Standort-ID + Titel rechts vom QR. -schema_version: 1 -id: samla-deckel-12mm -name: "Samla Deckel (TZe 12mm)" -app: null -tape_mm: 12 -printer_model: ptouch -elements: - - { type: qr, x: 5, y: 4, size: 58, data_field: qr_payload } - - { type: text, x: 72, y: 5, field: primary_id, font_size: 13 } - - { type: text, x: 72, y: 38, field: title, font_size: 10 } -preview_sample: - primary_id: "HH-AK-SM01-D2" - title: "Samla 11L Deckel" - qr_payload: "https://hangar.example.test/loc/HH-AK-SM01-D2" diff --git a/backend/app/seed/templates/samla-deckel-24mm.yaml b/backend/app/seed/templates/samla-deckel-24mm.yaml deleted file mode 100644 index efbf238..0000000 --- a/backend/app/seed/templates/samla-deckel-24mm.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Samla Deckel — 24mm Endlostape (TZe-251) -# Für IKEA Samla Behälter: Deckel-Etikett mit QR + Standort-ID + Titel. -# ptouch PT-P750W 180 DPI: 24mm = 128 print_pins (Tape24mm). -# QR 110px (y=8: 8+110=118 < 128). Großes Layout für gut lesbare Tags. -schema_version: 1 -id: samla-deckel-24mm -name: "Samla Deckel (TZe 24mm)" -app: null -tape_mm: 24 -printer_model: ptouch -elements: - - { type: qr, x: 8, y: 8, size: 110, data_field: qr_payload } - - { type: text, x: 128, y: 8, field: primary_id, font_size: 24 } - - { type: text, x: 128, y: 60, field: title, font_size: 16 } -preview_sample: - primary_id: "HH-AK-SM01-D2" - title: "Samla 11L Deckel" - qr_payload: "https://hangar.example.test/loc/HH-AK-SM01-D2" diff --git a/backend/app/seed/templates/samla-deckel-62mm.yaml b/backend/app/seed/templates/samla-deckel-62mm.yaml deleted file mode 100644 index f5038f2..0000000 --- a/backend/app/seed/templates/samla-deckel-62mm.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Samla Deckel — 62mm DK Endless (QL) -# Für IKEA Samla Behälter: Deckel-Etikett mit großem QR + Standort-ID + Titel. -# Brother QL 62mm Endless: 696 printable pixels (native QL geometry). -# Großzügiges Layout: QR 220px + großer Text für Fern-Lesbarkeit. -schema_version: 1 -id: samla-deckel-62mm -name: "Samla Deckel (DK 62mm Endless)" -app: null -tape_mm: 62 -printer_model: brother_ql -elements: - - { type: qr, x: 15, y: 15, size: 220, data_field: qr_payload } - - { type: text, x: 250, y: 20, field: primary_id, font_size: 60 } - - { type: text, x: 250, y: 110, field: title, font_size: 36 } -preview_sample: - primary_id: "HH-AK-SM01-D2" - title: "Samla 11L Deckel" - qr_payload: "https://hangar.example.test/loc/HH-AK-SM01-D2" diff --git a/backend/app/seed/templates/samla-stirntag-12mm.yaml b/backend/app/seed/templates/samla-stirntag-12mm.yaml deleted file mode 100644 index b5081f2..0000000 --- a/backend/app/seed/templates/samla-stirntag-12mm.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Samla Stirntag — 12mm Endlostape (TZe-231) -# Für IKEA Samla Behälter: Stirnseiten-Etikett mit QR + Standort-ID + Titel. -# ptouch PT-P750W 180 DPI: 12mm = 70 print_pins (Tape12mm). -# QR 58px (y=4: 4+58=62 < 70). Standort-ID + Titel rechts vom QR. -schema_version: 1 -id: samla-stirntag-12mm -name: "Samla Stirntag (TZe 12mm)" -app: null -tape_mm: 12 -printer_model: ptouch -elements: - - { type: qr, x: 5, y: 4, size: 58, data_field: qr_payload } - - { type: text, x: 72, y: 5, field: primary_id, font_size: 13 } - - { type: text, x: 72, y: 38, field: title, font_size: 10 } -preview_sample: - primary_id: "HH-AK-SM01-D2-S1" - title: "Samla 11L Stirntag" - qr_payload: "https://hangar.example.test/loc/HH-AK-SM01-D2-S1" diff --git a/backend/app/seed/templates/samla-stirntag-24mm.yaml b/backend/app/seed/templates/samla-stirntag-24mm.yaml deleted file mode 100644 index e9d1358..0000000 --- a/backend/app/seed/templates/samla-stirntag-24mm.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Samla Stirntag — 24mm Endlostape (TZe-251) -# Für IKEA Samla Behälter: Stirnseiten-Etikett mit QR + Standort-ID + Titel. -# ptouch PT-P750W 180 DPI: 24mm = 128 print_pins (Tape24mm). -# QR 110px (y=8: 8+110=118 < 128). Großes Layout für gut lesbare Tags. -schema_version: 1 -id: samla-stirntag-24mm -name: "Samla Stirntag (TZe 24mm)" -app: null -tape_mm: 24 -printer_model: ptouch -elements: - - { type: qr, x: 8, y: 8, size: 110, data_field: qr_payload } - - { type: text, x: 128, y: 8, field: primary_id, font_size: 24 } - - { type: text, x: 128, y: 60, field: title, font_size: 16 } -preview_sample: - primary_id: "HH-AK-SM01-D2-S1" - title: "Samla 11L Stirntag" - qr_payload: "https://hangar.example.test/loc/HH-AK-SM01-D2-S1" diff --git a/backend/app/seed/templates/samla-stirntag-62mm.yaml b/backend/app/seed/templates/samla-stirntag-62mm.yaml deleted file mode 100644 index 5a798fd..0000000 --- a/backend/app/seed/templates/samla-stirntag-62mm.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Samla Stirntag — 62mm DK Endless (QL) -# Für IKEA Samla Behälter: Stirnseiten-Etikett mit großem QR + Standort-ID + Titel. -# Brother QL 62mm Endless: 696 printable pixels (native QL geometry). -# Großzügiges Layout: QR 220px + großer Text für Fern-Lesbarkeit. -schema_version: 1 -id: samla-stirntag-62mm -name: "Samla Stirntag (DK 62mm Endless)" -app: null -tape_mm: 62 -printer_model: brother_ql -elements: - - { type: qr, x: 15, y: 15, size: 220, data_field: qr_payload } - - { type: text, x: 250, y: 20, field: primary_id, font_size: 60 } - - { type: text, x: 250, y: 110, field: title, font_size: 36 } -preview_sample: - primary_id: "HH-AK-SM01-D2-S1" - title: "Samla 11L Stirntag" - qr_payload: "https://hangar.example.test/loc/HH-AK-SM01-D2-S1" diff --git a/backend/app/seed/templates/snipeit-12mm.yaml b/backend/app/seed/templates/snipeit-12mm.yaml deleted file mode 100644 index 523e454..0000000 --- a/backend/app/seed/templates/snipeit-12mm.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# Compact layout: QR + primary_id + title fit on a 12mm tape (70px). -# A-Diagnose Fix: Canvas 106px→70px. scale_y=70/106≈0.660. -# QR 58px (y=4: 4+58=62 < 70). Text-Y proportional skaliert. -schema_version: 1 -id: snipeit-12mm -name: "Snipe-IT Asset (12mm)" -app: snipeit -tape_mm: 12 -printer_model: ptouch -elements: - - { type: qr, x: 5, y: 4, size: 58, data_field: qr_payload } - - { type: text, x: 72, y: 4, field: primary_id, font_size: 14 } - - { type: text, x: 72, y: 38, field: title, font_size: 10 } -preview_sample: - primary_id: "ASSET-2024-001" - title: "Dell Latitude 7430" - qr_payload: "https://snipeit.example.com/hardware/123" diff --git a/backend/app/seed/templates/snipeit-18mm.yaml b/backend/app/seed/templates/snipeit-18mm.yaml deleted file mode 100644 index 31136a8..0000000 --- a/backend/app/seed/templates/snipeit-18mm.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# 18mm = 112px (ptouch Tape18mm print_pins). Adds secondary line. -# A-Diagnose Fix: Canvas 165px→112px. scale_y=112/165≈0.679. -# QR 95px (y=6: 6+95=101 < 112). Text-Y proportional skaliert. -schema_version: 1 -id: snipeit-18mm -name: "Snipe-IT Asset (18mm)" -app: snipeit -tape_mm: 18 -printer_model: ptouch -elements: - - { type: qr, x: 8, y: 6, size: 95, data_field: qr_payload } - - { type: text, x: 112, y: 6, field: primary_id, font_size: 22 } - - { type: text, x: 112, y: 42, field: title, font_size: 15 } - - { type: text, x: 112, y: 80, field: secondary, font_size: 11 } -preview_sample: - primary_id: "ASSET-2024-001" - title: "Dell Latitude 7430" - qr_payload: "https://snipeit.example.com/hardware/123" - secondary: ["IT Office", "Bjoern Strausmann"] diff --git a/backend/app/seed/templates/snipeit-24mm.yaml b/backend/app/seed/templates/snipeit-24mm.yaml deleted file mode 100644 index ef9a4d5..0000000 --- a/backend/app/seed/templates/snipeit-24mm.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# 24mm = 128px (ptouch Tape24mm print_pins). Generous layout. -# A-Diagnose Fix: Canvas 256px→128px. scale_y=128/256=0.500. -# QR 110px (y=8: 8+110=118 < 128). Text-Y proportional skaliert. -schema_version: 1 -id: snipeit-24mm -name: "Snipe-IT Asset (24mm)" -app: snipeit -tape_mm: 24 -printer_model: ptouch -elements: - - { type: qr, x: 8, y: 8, size: 110, data_field: qr_payload } - - { type: text, x: 128, y: 8, field: primary_id, font_size: 28 } - - { type: text, x: 128, y: 48, field: title, font_size: 18 } - - { type: text, x: 128, y: 86, field: secondary, font_size: 13 } -preview_sample: - primary_id: "ASSET-2024-001" - title: "Dell Latitude 7430" - qr_payload: "https://snipeit.example.com/hardware/123" - secondary: ["IT Office", "Bjoern Strausmann"] diff --git a/backend/app/seed/templates/spoolman-12mm.yaml b/backend/app/seed/templates/spoolman-12mm.yaml deleted file mode 100644 index a070b97..0000000 --- a/backend/app/seed/templates/spoolman-12mm.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# Compact: QR + spool id + filament title. Canvas 70px (ptouch Tape12mm). -# A-Diagnose Fix: Canvas 106px→70px. scale_y=70/106≈0.660. -# QR 58px (y=4: 4+58=62 < 70). Text-Y proportional skaliert. -schema_version: 1 -id: spoolman-12mm -name: "Spoolman Spool (12mm)" -app: spoolman -tape_mm: 12 -printer_model: ptouch -elements: - - { type: qr, x: 5, y: 4, size: 58, data_field: qr_payload } - - { type: text, x: 72, y: 4, field: primary_id, font_size: 14 } - - { type: text, x: 72, y: 38, field: title, font_size: 10 } -preview_sample: - primary_id: "PLA-Black-1kg" - title: "Spool #7" - qr_payload: "https://spoolman.example.com/spool/7" diff --git a/backend/app/seed/templates/spoolman-18mm.yaml b/backend/app/seed/templates/spoolman-18mm.yaml deleted file mode 100644 index d0a0f68..0000000 --- a/backend/app/seed/templates/spoolman-18mm.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# 18mm — adds the secondary line (remaining grams, colour). Canvas 112px. -# A-Diagnose Fix: Canvas 165px→112px. scale_y=112/165≈0.679. -# QR 95px (y=6: 6+95=101 < 112). Text-Y proportional skaliert. -schema_version: 1 -id: spoolman-18mm -name: "Spoolman Spool (18mm)" -app: spoolman -tape_mm: 18 -printer_model: ptouch -elements: - - { type: qr, x: 8, y: 6, size: 95, data_field: qr_payload } - - { type: text, x: 112, y: 6, field: primary_id, font_size: 22 } - - { type: text, x: 112, y: 42, field: title, font_size: 15 } - - { type: text, x: 112, y: 80, field: secondary, font_size: 11 } -preview_sample: - primary_id: "PLA-Black-1kg" - title: "Spool #7" - qr_payload: "https://spoolman.example.com/spool/7" - secondary: ["780g left", "Prusament"] diff --git a/backend/app/seed/templates/spoolman-24mm.yaml b/backend/app/seed/templates/spoolman-24mm.yaml deleted file mode 100644 index deb40a2..0000000 --- a/backend/app/seed/templates/spoolman-24mm.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# 24mm — full layout. Canvas 128px (ptouch Tape24mm print_pins). -# A-Diagnose Fix: Canvas 256px→128px. scale_y=128/256=0.500. -# QR 110px (y=8: 8+110=118 < 128). Text-Y proportional skaliert. -schema_version: 1 -id: spoolman-24mm -name: "Spoolman Spool (24mm)" -app: spoolman -tape_mm: 24 -printer_model: ptouch -elements: - - { type: qr, x: 8, y: 8, size: 110, data_field: qr_payload } - - { type: text, x: 128, y: 8, field: primary_id, font_size: 28 } - - { type: text, x: 128, y: 48, field: title, font_size: 18 } - - { type: text, x: 128, y: 86, field: secondary, font_size: 13 } -preview_sample: - primary_id: "PLA-Black-1kg" - title: "Spool #7" - qr_payload: "https://spoolman.example.com/spool/7" - secondary: ["780g left", "Prusament"] diff --git a/backend/app/services/batch_dispatch.py b/backend/app/services/batch_dispatch.py index 2a2968d..5c61fff 100644 --- a/backend/app/services/batch_dispatch.py +++ b/backend/app/services/batch_dispatch.py @@ -1,138 +1,46 @@ -"""Best-effort Batch-Dispatcher: validiert + queued als atomic BatchJob. +"""Batch-Dispatcher: orchestriert POST /api/print/{slug}/batch. -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. +Phase 1k.1a Task 17: tape-consistency-Check entfernt, weil tape-unabhängige +ContentTypes gemischte ContentTypes pro Batch erlauben — alle Items rendern +auf der gleichen loaded_tape_mm (wird 1x via Preflight von PrintService gelesen). +MixedTapeSizesError + _validate_item_get_tape_mm vollständig entfernt. """ from __future__ import annotations -import logging from typing import TYPE_CHECKING +from uuid import UUID -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 → collected into BatchError list (best-effort) -_PER_ITEM_ERRORS: dict[type[Exception], str] = { - TemplateNotFoundError: "template_not_found", - LookupFailedError: "integration_lookup_failed", - TapeEmptyError: "tape_empty", -} - -# 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, -) - 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. + half_cut: bool = False, +) -> list[UUID]: + """Submit a batch of mixed-ContentType print requests. - 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 + Phase 1k.1a: tape consistency check entfernt — alle Items rendern auf der + gleichen loaded_tape_mm. PrintService.submit_batch_job erledigt Preflight, + Render und Job-Persistenz. - 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. - """ - 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: - # 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)] - 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, - ) + Args: + service: PrintService-Instanz für den Ziel-Drucker. + items: Liste von PrintRequest-Objekten (mindestens einer). + half_cut: Half-Cut zwischen Labels aktivieren (Standard: False). - 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. + Returns: + list[UUID]: Job-IDs aller eingereihten Print-Jobs. - 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). + Raises: + ValueError: items ist leer. """ - return await service.get_template_tape_mm(item.template_id) + if not items: + msg = "Batch must contain at least one item." + raise ValueError(msg) + return await service.submit_batch_job(items, half_cut=half_cut) diff --git a/backend/app/services/label_renderer.py b/backend/app/services/label_renderer.py deleted file mode 100644 index 7c33cc6..0000000 --- a/backend/app/services/label_renderer.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Compose a 1-bit PIL Image from a TemplateSchema + LabelData. - -The renderer is stateless — one instance can serve concurrent requests. -It does not know the printer or the queue; it only produces the bitmap. -The printer-backend plug-in (Phase 2 hardware tasks) converts the bitmap -to raster bytes for the specific Brother model. - -Coordinate system: top-left origin, pixels at ptouch native 180 DPI. -The print area is constrained by the physical print_pins of the -PT-P750W per tape width — see `TAPE_HEIGHT_PX` for the supported widths. - -A-Diagnose (2026-06-02): previous values (12:106, 18:165, 24:256) were -derived from a brother_ql / 300 DPI geometry and caused 1.5x-2x canvas -overflow. ptouch._prepare_image() crops to print_pins on paste, silently -clipping QR and text elements at the top. Fixed to match ptouch PIN_CONFIGS -print_pins values at 180 DPI. QL 62mm Endless tape is unaffected. -""" - -from __future__ import annotations - -import functools -from typing import Final - -import qrcode -import qrcode.constants -from PIL import Image, ImageChops, ImageDraw, ImageFont - -from app.schemas.label_data import LabelData -from app.schemas.template import LayoutElement, TemplateSchema - -# Tape-mm to printable-area pixel-height — matched to ptouch PT-P750W -# PIN_CONFIGS.print_pins values at native 180 DPI. -# Source: ptouch-py PIN_CONFIGS (Tape12mm=70, Tape18mm=112, Tape24mm=128). -# QL 62mm Endless tape uses brother_ql geometry and is unchanged. -TAPE_HEIGHT_PX: Final[dict[int, int]] = { - 12: 70, # PT-P750W Tape12mm print_pins (was 106 — 1.51x overflow) - 18: 112, # PT-P750W Tape18mm print_pins (was 165 — 1.47x overflow) - 24: 128, # PT-P750W Tape24mm print_pins (was 256 — 2.00x overflow) - 62: 696, # endless QL tape — unchanged -} - -DEFAULT_LABEL_WIDTH_PX: Final[int] = 600 -"""Length-axis canvas width. Decoupled from any specific DPI: -PT-Series renders at native 180 DPI, QL-Series at 300 DPI native. -Templates use this as their coordinate-system width — the backend -maps to print head dots at print time.""" - -# Margin around the inked content when trimming whitespace on the length -# axis. 6 px ≈ 1mm at 180 DPI / 0.5mm at 300 DPI — minimal padding so -# QR scan and the printer cutter both work. -_TRIM_MARGIN_PX: Final[int] = 6 - - -@functools.lru_cache(maxsize=32) -def _load_font_cached(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: - """Load DejaVuSans at `size`px (cached), fall back to PIL's bitmap default if unavailable. - - The cache is bounded at 32 entries — far more than any realistic template uses. - Repeated calls with the same size return the same font instance without disk I/O. - """ - try: - return ImageFont.truetype("DejaVuSans.ttf", size) - except OSError: - return ImageFont.load_default() - - -class LabelRenderer: - """Render a (TemplateSchema, LabelData) pair into a 1-bit PIL Image.""" - - def render(self, template: TemplateSchema, data: LabelData) -> Image.Image: - """Return a 1-bit image sized for the template's tape width. - - Raises: - ValueError: if `template.tape_mm` is not in TAPE_HEIGHT_PX. - """ - height = TAPE_HEIGHT_PX.get(template.tape_mm) - if height is None: - raise ValueError( - f"Unsupported tape_mm: {template.tape_mm}. " - f"Supported widths: {sorted(TAPE_HEIGHT_PX)}" - ) - - img = Image.new("1", (DEFAULT_LABEL_WIDTH_PX, height), color=1) - draw = ImageDraw.Draw(img) - - for element in template.elements: - if element.type == "qr": - self._draw_qr(img, element, data) - else: # element.type == "text" - self._draw_text(draw, element, data) - - # Crop to the inked area on the length axis only — the height (tape - # axis) is pin-locked by the printer geometry and must not change. - # `img.getbbox()` would return the bbox of non-zero pixels, but mode "1" - # uses 1 for white (the background) and 0 for ink, so we invert first. - ink_bbox = ImageChops.invert(img.convert("L")).getbbox() - if ink_bbox is not None: - left, _, right, _ = ink_bbox - new_left = max(0, left - _TRIM_MARGIN_PX) - new_right = min(img.width, right + _TRIM_MARGIN_PX) - img = img.crop((new_left, 0, new_right, height)) - - return img - - def _draw_qr(self, img: Image.Image, element: LayoutElement, data: LabelData) -> None: - # LayoutElement.model_validator guarantees these are non-None for type="qr". - # The asserts document the invariant for readers and mypy; they are NOT - # runtime guards (python -O strips them). - assert element.data_field is not None - assert element.size is not None - - payload = self._resolve_field(data, element.data_field) - qr = qrcode.QRCode( - version=None, - error_correction=qrcode.constants.ERROR_CORRECT_M, - box_size=4, - border=1, - ) - qr.add_data(payload) - qr.make(fit=True) - qr_pil: Image.Image = qr.make_image(fill_color="black", back_color="white").convert("1") - qr_pil = qr_pil.resize((element.size, element.size)) - img.paste(qr_pil, (element.x, element.y)) - - def _draw_text( - self, - draw: ImageDraw.ImageDraw, - element: LayoutElement, - data: LabelData, - ) -> None: - # LayoutElement.model_validator guarantees these are non-None for type="text". - # The asserts document the invariant for readers and mypy; they are NOT - # runtime guards (python -O strips them). - assert element.field is not None - assert element.font_size is not None - - text = self._resolve_field(data, element.field) - font = _load_font_cached(element.font_size) - draw.text((element.x, element.y), text, fill=0, font=font) - - @staticmethod - def _resolve_field(data: LabelData, field: str) -> str: - """Read `field` off `data`, coercing tuples/lists to a single ' | '-joined string.""" - value = getattr(data, field, "") - if isinstance(value, (list, tuple)): - # Separator " | " chosen for single-line tape labels. If a future phase - # adds multi-line text fields, this should become a per-element - # `separator` attribute on LayoutElement. - return " | ".join(str(v) for v in value) - return str(value) diff --git a/backend/app/services/layout_engine.py b/backend/app/services/layout_engine.py new file mode 100644 index 0000000..961ed55 --- /dev/null +++ b/backend/app/services/layout_engine.py @@ -0,0 +1,406 @@ +"""LayoutEngine — semantic layout rendering driven by ContentType + TapeGeometry. + +Replaces the v1 LabelRenderer. Each render() call resolves: + 1. tape_mm -> TapeGeometry (via TAPE_GEOMETRY dict) + 2. content_type-required fields -> validated against LabelData + 3. Dispatched to a per-ContentType _render_*() method + 4. Returns a PIL Image whose height matches geometry.printable_px + +The _render_*() methods are implemented in subsequent tasks (7-13). +""" + +from __future__ import annotations + +from functools import lru_cache +from typing import ClassVar + +import qrcode +import qrcode.constants +from PIL import Image, ImageDraw, ImageFont + +from app.printer_backends.exceptions import ( + ContentTypeDataMismatchError, + UnsupportedTapeError, +) +from app.schemas.content_type import ContentType +from app.schemas.label_data import LabelData +from app.schemas.tape_geometry import TAPE_GEOMETRY, TapeGeometry + + +class LayoutEngine: + """Tape-independent semantic label renderer. + + Stateless — safe to instantiate once and reuse across requests. + """ + + _REQUIRED_FIELDS: ClassVar[dict[ContentType, tuple[str, ...]]] = { + ContentType.QR_ONLY: ("qr_payload",), + ContentType.QR_ONE_LINE: ("qr_payload", "primary_id"), + ContentType.QR_TWO_LINES: ("qr_payload", "primary_id", "title"), + ContentType.QR_THREE_LINES: ("qr_payload", "primary_id", "title", "secondary"), + ContentType.TEXT_ONE_LINE: ("primary_id",), + ContentType.TEXT_TWO_LINES: ("primary_id", "title"), + ContentType.QR_WITH_LISTING: ("qr_payload", "primary_id", "items"), + } + """ContentType -> ordered tuple of LabelData field names that must be set. + + Used by _validate_data to produce ContentTypeDataMismatchError with a + complete missing-fields list (one 422 instead of multiple round-trips). + """ + + def render( + self, + tape_mm: int, + content_type: ContentType, + data: LabelData, + ) -> Image.Image: + """Render a label for the given tape width + content type + data. + + Raises: + UnsupportedTapeError (409): tape_mm not in TAPE_GEOMETRY. + ContentTypeDataMismatchError (422): data missing required fields. + """ + geometry = self._lookup_geometry(tape_mm) + self._validate_data(content_type, data) + + match content_type: + case ContentType.QR_ONLY: + return self._render_qr_only(geometry, data) + case ContentType.QR_ONE_LINE: + return self._render_qr_one_line(geometry, data) + case ContentType.QR_TWO_LINES: + return self._render_qr_two_lines(geometry, data) + case ContentType.QR_THREE_LINES: + return self._render_qr_three_lines(geometry, data) + case ContentType.TEXT_ONE_LINE: + return self._render_text_one_line(geometry, data) + case ContentType.TEXT_TWO_LINES: + return self._render_text_two_lines(geometry, data) + case ContentType.QR_WITH_LISTING: + return self._render_qr_with_listing(geometry, data) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _lookup_geometry(self, tape_mm: int) -> TapeGeometry: + geom = TAPE_GEOMETRY.get(tape_mm) + if geom is None: + raise UnsupportedTapeError(tape_mm=tape_mm) + return geom + + def _validate_data(self, content_type: ContentType, data: LabelData) -> None: + required = self._REQUIRED_FIELDS[content_type] + missing: list[str] = [] + for field_name in required: + value = getattr(data, field_name) + # Empty string, empty tuple, or None counts as missing. + if value is None or (hasattr(value, "__len__") and len(value) == 0): + missing.append(field_name) + if missing: + raise ContentTypeDataMismatchError( + content_type=str(content_type), + missing_fields=tuple(missing), + ) + + # ------------------------------------------------------------------ + # Rendering helpers + # ------------------------------------------------------------------ + + @staticmethod + def _build_qr_image(payload: str, size_px: int) -> Image.Image: + """Render a QR code as a square 1-bit PIL Image at the requested size.""" + qr = qrcode.QRCode( + version=None, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=10, + border=0, + ) + qr.add_data(payload) + qr.make(fit=True) + rendered: Image.Image = qr.make_image(fill_color="black", back_color="white").convert("1") + return rendered.resize((size_px, size_px), Image.Resampling.NEAREST) + + @staticmethod + def _blank_canvas(width: int, height: int) -> Image.Image: + """Return a white 1-bit PIL Image of the given size.""" + return Image.new("1", (width, height), color=1) + + @staticmethod + @lru_cache(maxsize=32) + def _load_font(size_px: int) -> ImageFont.ImageFont | ImageFont.FreeTypeFont: + """Load DejaVuSans TrueType font at the requested pixel size. + + DejaVuSans.ttf is installed via fonts-dejavu-core in the Dockerfile. + On dev machines without the system font, falls back to the default + bitmap font. + + @lru_cache(maxsize=32): font loading is I/O-bound on first call + (truetype parses the .ttf file). Caching eliminates repeated disk + reads per render. 32 slots covers all distinct size_px values used + across tape geometries with room to spare. + Note: @staticmethod must be the outer decorator; @lru_cache wraps + the underlying function before staticmethod descriptor takes effect. + """ + try: + return ImageFont.truetype("DejaVuSans.ttf", size_px) + except OSError: + return ImageFont.load_default() + + @staticmethod + def _measure_text( + text: str, + font: ImageFont.ImageFont | ImageFont.FreeTypeFont, + ) -> tuple[int, int]: + """Return (width, height) bounding box of `text` rendered with `font`.""" + bbox = ImageDraw.Draw(Image.new("1", (1, 1), color=1)).textbbox((0, 0), text, font=font) + return (int(bbox[2] - bbox[0]), int(bbox[3] - bbox[1])) + + # ------------------------------------------------------------------ + # _render_* methods — implemented in Tasks 7-13 + # ------------------------------------------------------------------ + + def _render_qr_only( + self, + geometry: TapeGeometry, + data: LabelData, + ) -> Image.Image: + """QR fills the full printable height, left-padded by qr_padding_px. + + Width = qr_max_px + 2 * qr_padding_px = printable_px (square label). + """ + qr_img = self._build_qr_image( + payload=data.qr_payload or "", + size_px=geometry.qr_max_px, + ) + canvas_width = geometry.printable_px + canvas = self._blank_canvas(canvas_width, geometry.printable_px) + canvas.paste(qr_img, (geometry.qr_padding_px, geometry.qr_padding_px)) + return canvas + + def _render_qr_one_line( + self, + geometry: TapeGeometry, + data: LabelData, + ) -> Image.Image: + """QR left + 1 text line (primary_id, font_xl, vertically centered).""" + qr_img = self._build_qr_image( + payload=data.qr_payload or "", + size_px=geometry.qr_max_px, + ) + font = self._load_font(geometry.font_xl) + text = data.primary_id or "" + text_w, text_h = self._measure_text(text, font) + + canvas_width = geometry.text_start_x + text_w + geometry.qr_padding_px + canvas = self._blank_canvas(canvas_width, geometry.printable_px) + canvas.paste(qr_img, (geometry.qr_padding_px, geometry.qr_padding_px)) + text_y = max(0, (geometry.printable_px - text_h) // 2) + ImageDraw.Draw(canvas).text((geometry.text_start_x, text_y), text, font=font, fill=0) + return canvas + + def _render_qr_two_lines( + self, + geometry: TapeGeometry, + data: LabelData, + ) -> Image.Image: + """QR left + 2 text lines (primary_id XL on top, title L below). + + Phase 1i V4-Winner baseline for 12mm: + - primary_id at y=2 (font_xl=22) + - title at y=42 (font_l=18) + - text_start_x=72 + Generalises to other tape widths via geometry constants. + """ + qr_img = self._build_qr_image( + payload=data.qr_payload or "", + size_px=geometry.qr_max_px, + ) + font_primary = self._load_font(geometry.font_xl) + font_title = self._load_font(geometry.font_l) + + primary_text = data.primary_id or "" + title_text = data.title or "" + primary_w, _ = self._measure_text(primary_text, font_primary) + title_w, _ = self._measure_text(title_text, font_title) + max_text_w = max(primary_w, title_w) + + canvas_width = geometry.text_start_x + max_text_w + geometry.qr_padding_px + canvas = self._blank_canvas(canvas_width, geometry.printable_px) + canvas.paste(qr_img, (geometry.qr_padding_px, geometry.qr_padding_px)) + + draw = ImageDraw.Draw(canvas) + draw.text( + (geometry.text_start_x, geometry.qr_padding_px), + primary_text, + font=font_primary, + fill=0, + ) + title_y = geometry.qr_padding_px + geometry.font_xl + geometry.line_spacing_px + draw.text( + (geometry.text_start_x, title_y), + title_text, + font=font_title, + fill=0, + ) + return canvas + + def _render_qr_three_lines( + self, + geometry: TapeGeometry, + data: LabelData, + ) -> Image.Image: + """QR left + 3 text lines: primary_id XL, title L, secondary[0] S.""" + qr_img = self._build_qr_image( + payload=data.qr_payload or "", + size_px=geometry.qr_max_px, + ) + font_primary = self._load_font(geometry.font_xl) + font_title = self._load_font(geometry.font_l) + font_secondary = self._load_font(geometry.font_s) + + primary_text = data.primary_id or "" + title_text = data.title or "" + secondary_text = data.secondary[0] if data.secondary else "" + + primary_w, _ = self._measure_text(primary_text, font_primary) + title_w, _ = self._measure_text(title_text, font_title) + sec_w, _ = self._measure_text(secondary_text, font_secondary) + max_text_w = max(primary_w, title_w, sec_w) + + canvas_width = geometry.text_start_x + max_text_w + geometry.qr_padding_px + canvas = self._blank_canvas(canvas_width, geometry.printable_px) + canvas.paste(qr_img, (geometry.qr_padding_px, geometry.qr_padding_px)) + + draw = ImageDraw.Draw(canvas) + y = geometry.qr_padding_px + draw.text( + (geometry.text_start_x, y), + primary_text, + font=font_primary, + fill=0, + ) + y += geometry.font_xl + geometry.line_spacing_px + draw.text( + (geometry.text_start_x, y), + title_text, + font=font_title, + fill=0, + ) + y += geometry.font_l + geometry.line_spacing_px + draw.text( + (geometry.text_start_x, y), + secondary_text, + font=font_secondary, + fill=0, + ) + return canvas + + def _render_text_one_line( + self, + geometry: TapeGeometry, + data: LabelData, + ) -> Image.Image: + """Full-width text (primary_id, font_xl, vertically centered).""" + font = self._load_font(geometry.font_xl) + text = data.primary_id or "" + text_w, text_h = self._measure_text(text, font) + + canvas_width = geometry.qr_padding_px + text_w + geometry.qr_padding_px + canvas = self._blank_canvas(canvas_width, geometry.printable_px) + text_y = max(0, (geometry.printable_px - text_h) // 2) + ImageDraw.Draw(canvas).text( + (geometry.qr_padding_px, text_y), + text, + font=font, + fill=0, + ) + return canvas + + def _render_text_two_lines( + self, + geometry: TapeGeometry, + data: LabelData, + ) -> Image.Image: + """2 text lines (primary_id XL + title L), no QR.""" + font_primary = self._load_font(geometry.font_xl) + font_title = self._load_font(geometry.font_l) + primary_text = data.primary_id or "" + title_text = data.title or "" + primary_w, _ = self._measure_text(primary_text, font_primary) + title_w, _ = self._measure_text(title_text, font_title) + max_text_w = max(primary_w, title_w) + + canvas_width = geometry.qr_padding_px + max_text_w + geometry.qr_padding_px + canvas = self._blank_canvas(canvas_width, geometry.printable_px) + + draw = ImageDraw.Draw(canvas) + y = geometry.qr_padding_px + draw.text( + (geometry.qr_padding_px, y), + primary_text, + font=font_primary, + fill=0, + ) + y += geometry.font_xl + geometry.line_spacing_px + draw.text( + (geometry.qr_padding_px, y), + title_text, + font=font_title, + fill=0, + ) + return canvas + + def _render_qr_with_listing( + self, + geometry: TapeGeometry, + data: LabelData, + ) -> Image.Image: + """QR left + N item lines (font_m). Overflow shows '+N more'.""" + qr_img = self._build_qr_image( + payload=data.qr_payload or "", + size_px=geometry.qr_max_px, + ) + font_item = self._load_font(geometry.font_m) + items = list(data.items) + + available_h = geometry.printable_px - 2 * geometry.qr_padding_px + line_h = geometry.font_m + geometry.line_spacing_px + max_lines = max(1, available_h // line_h) + + overflow_text: str | None + if len(items) > max_lines: + visible_count = max_lines - 1 + overflow_text = f"+{len(items) - visible_count} more" + visible = items[:visible_count] + else: + visible = items + overflow_text = None + + widths = [self._measure_text(it.item, font_item)[0] for it in visible] + if overflow_text: + widths.append(self._measure_text(overflow_text, font_item)[0]) + max_text_w = max(widths) if widths else 0 + + canvas_width = geometry.text_start_x + max_text_w + geometry.qr_padding_px + canvas = self._blank_canvas(canvas_width, geometry.printable_px) + canvas.paste(qr_img, (geometry.qr_padding_px, geometry.qr_padding_px)) + + draw = ImageDraw.Draw(canvas) + y = geometry.qr_padding_px + for it in visible: + draw.text( + (geometry.text_start_x, y), + it.item, + font=font_item, + fill=0, + ) + y += line_h + if overflow_text: + draw.text( + (geometry.text_start_x, y), + overflow_text, + font=font_item, + fill=0, + ) + return canvas diff --git a/backend/app/services/print_queue.py b/backend/app/services/print_queue.py index e5c5e20..d69129e 100644 --- a/backend/app/services/print_queue.py +++ b/backend/app/services/print_queue.py @@ -23,13 +23,11 @@ from dataclasses import dataclass from enum import StrEnum from io import BytesIO -from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable +from typing import Any, Protocol, runtime_checkable from uuid import UUID, uuid4 from PIL import Image -from pydantic import ValidationError -from app.models.job import Job as DbJob from app.models.job import JobState as DbJobState from app.printer_backends.exceptions import ( PrinterCoverOpenError, @@ -47,16 +45,7 @@ JobStateMachine, ) from app.services.job_store import JobStore, MemoryJobStore - -# TYPE_CHECKING-Block verhindert zirkuläre Imports zur Laufzeit. -# LabelRenderer und TemplateLoader sind nur für die Recovery-Methode nötig. -if TYPE_CHECKING: - from app.services.label_renderer import LabelRenderer - from app.services.template_loader import TemplateLoader - -# TemplateNotFoundError wird zur Laufzeit benötigt (Recovery-Loop catch), daher -# kein TYPE_CHECKING-Block — aber lazy import um Zirkel zu vermeiden. -from app.services.template_loader import TemplateNotFoundError +from app.services.layout_engine import LayoutEngine # Callback type: called after each state transition. The optional queue_depth @@ -174,8 +163,7 @@ def __init__( printers: list[_PrinterLike], on_state_change: _StateChangeCallback | None = None, store: JobStore | None = None, - renderer: LabelRenderer | None = None, - loader: type[TemplateLoader] | None = None, + engine: LayoutEngine | None = None, ) -> None: """Konstruktor. @@ -189,15 +177,13 @@ def __init__( Default ist ``MemoryJobStore()`` für Backward-Compat mit Pre-Phase-2-Tests — Production-Code wired in Lifespan explizit ``SQLiteJobStore`` ein (Task 9). - renderer: LabelRenderer-Instanz für Recovery in start(). - Optional — wenn None, wirft _rerender_from_db_job() RuntimeError. - loader: TemplateLoader-Klasse für Recovery in start(). - Optional — wenn None, wirft _rerender_from_db_job() RuntimeError. + engine: LayoutEngine-Instanz für Recovery in start(). + Default ist eine neue ``LayoutEngine()`` — stateless, sicher + als Singleton. """ self._on_state_change = on_state_change self._store: JobStore = store if store is not None else MemoryJobStore() - self._renderer: LabelRenderer | None = renderer - self._loader: type[TemplateLoader] | None = loader + self._engine: LayoutEngine = engine if engine is not None else LayoutEngine() self._printers: dict[UUID, _PrinterLike] = {p.id: p for p in printers} # Queue type is Job | BatchJob | None — None is the sentinel used by # stop() to wake workers that are blocked at queue.get(). @@ -247,18 +233,44 @@ async def start(self) -> None: if db_job.state != DbJobState.QUEUED.value: continue # C-1/I-2: fehlerhafte/veraltete Rows dürfen die Recovery nicht abbrechen. - # KeyError (fehlendes label_data), ValidationError (ungültige Struktur), - # TemplateNotFoundError (Template inzwischen gelöscht) → Job FAILED markieren - # und mit dem nächsten Job weitermachen. + # KeyError (fehlendes label_data/content_type), ValidationError + # (ungültige Struktur), ValueError (ungültiger ContentType-Wert) → + # Job FAILED markieren und mit dem nächsten Job weitermachen. + # MED-1/R2-1: Webhook-generated jobs (spoolman/, grocy/) have + # template_key but NO content_type/label_data/rendered_tape_mm in + # their payload. Skip gracefully before attempting rerender. + # R2-1: check all three required keys — a job with content_type + + # label_data but missing rendered_tape_mm would still fail in + # _rerender_from_db_payload; guard must cover the full set. + _required_rerender_keys = {"content_type", "label_data", "rendered_tape_mm"} + _missing_keys = _required_rerender_keys - db_job.payload.keys() + if _missing_keys: + skip_msg = ( + f"Recovery: Skipping job {db_job.id} — payload lacks " + f"{sorted(_missing_keys)} " + f"(template_key={db_job.template_key!r}). " + f"Legacy/webhook-generated jobs cannot be re-rendered." + ) + logger.info(skip_msg) + await self._store.mark_failed( + db_job.id, + f"recovery_skip_legacy_payload: {skip_msg}", + ) + continue try: - image = await self._rerender_from_db_job(db_job) - except (KeyError, ValidationError, TemplateNotFoundError) as exc: + image = self._rerender_from_db_payload(db_job.payload) + except Exception as exc: # defensive: per-job failure must not abort startup logger.warning( - "Recovery: Job %s rerender fehlgeschlagen (%s), FAILED", + "Recovery: Job %s rerender fehlgeschlagen (%s)," + " wird als FAILED markiert und übersprungen", db_job.id, exc.__class__.__name__, + exc_info=True, + ) + await self._store.mark_failed( + db_job.id, + f"recovery_rerender_failed: {exc.__class__.__name__}", ) - await self._store.mark_failed(db_job.id, f"recovery_rerender_failed: {exc}") continue payload_bytes = await asyncio.to_thread(_serialize_image_to_png, image) wrapper = Job( @@ -286,27 +298,29 @@ async def start(self) -> None: self._running = False raise - async def _rerender_from_db_job(self, db_job: DbJob) -> Image.Image: - """Phase 2: Label-Bild aus persistiertem template_key + payload neu rendern. + def _rerender_from_db_payload(self, payload: dict[str, Any]) -> Image.Image: + """Reconstruct a PIL Image from a stored job payload. - Wird während start() Recovery aufgerufen. Benötigt renderer + loader, - die via PrintQueue-Konstruktor verdrahtet werden müssen (Production-Lifespan). + Used during startup recovery for QUEUED jobs persisted before crash. + The payload was produced by PrintService.submit_print_job (Task 15) + and contains content_type, rendered_tape_mm, and label_data snapshot. Raises: - RuntimeError: wenn renderer oder loader nicht gesetzt sind. + KeyError: wenn ``label_data`` oder ``content_type`` fehlt. + ValidationError: wenn ``label_data`` nicht in LabelData passt. + ValueError: wenn ``content_type`` kein gültiger ContentType-Wert ist. """ - if self._renderer is None or self._loader is None: - raise RuntimeError( - "PrintQueue Recovery benötigt renderer + loader " - "(via Konstruktor übergeben — siehe Lifespan-Konfiguration)" - ) - template = self._loader.get(db_job.template_key) - # R2-C4: payload["label_data"] ist ein rohes dict (model_dump()). - # LabelRenderer.render() erwartet ein LabelData-Objekt — KEIN dict. + from app.schemas.content_type import ContentType from app.schemas.label_data import LabelData - label_data = LabelData.model_validate(db_job.payload["label_data"]) - return self._renderer.render(template, label_data) + label_data = LabelData(**payload["label_data"]) + content_type = ContentType(payload["content_type"]) + tape_mm = int(payload["rendered_tape_mm"]) + return self._engine.render( + tape_mm=tape_mm, + content_type=content_type, + data=label_data, + ) async def stop(self, timeout_s: float = 30.0) -> None: """Stop all workers. @@ -425,69 +439,6 @@ async def submit_with_id( logger.info("Job %s (extern-id) queued on %s", job_id, printer_id) return job_id - async def submit_paused_with_id( - self, - job_id: UUID, - printer_id: UUID, - image: Image.Image, - tape_mm: int, - **options: Any, - ) -> UUID: - """Phase 2: Wie submit_paused(), aber mit extern erzeugter job_id. - - Wird von PrintService für den on_tape_mismatch='queue'-Pfad genutzt. - Gibt die job_id unverändert zurück. - """ - if printer_id not in self._queues: - raise KeyError(f"Unknown printer: {printer_id}") - payload = await asyncio.to_thread(_serialize_image_to_png, image) - job = Job( - id=str(job_id), - printer_id=printer_id, - image_payload=payload, - tape_mm=tape_mm, - options=dict(options), - ) - JobStateMachine.transition(job, JobState.PAUSED) - self._jobs[str(job_id)] = job - logger.info("Job %s (extern-id) created paused on %s", job_id, printer_id) - return job_id - - async def submit_paused( - self, - printer_id: UUID, - image: Image.Image, - tape_mm: int, - **options: Any, - ) -> str: - """Create a job in PAUSED state without enqueuing it. - - Use this instead of ``submit()`` + ``JobStateMachine.transition(PAUSED)`` - whenever the caller wants the job to start life paused — typically the - on_tape_mismatch='queue' path in PrintService. - - The job is registered in ``_jobs`` and immediately transitioned to PAUSED - via JobStateMachine so all side-effects (timestamp, _done_event) are - consistent. Crucially, it is **not** placed in the asyncio.Queue, so the - worker can never dequeue it before the caller has a chance to attach - error metadata. Only ``resume_job()`` can promote the job to QUEUED and - enqueue it later. - """ - if printer_id not in self._queues: - raise KeyError(f"Unknown printer: {printer_id}") - payload = await asyncio.to_thread(_serialize_image_to_png, image) - job = Job( - id=str(uuid.uuid4()), - printer_id=printer_id, - image_payload=payload, - tape_mm=tape_mm, - options=dict(options), - ) - JobStateMachine.transition(job, JobState.PAUSED) - self._jobs[job.id] = job - logger.info("Job %s created paused on %s", job.id, printer_id) - return job.id - async def enqueue_batch( self, *, diff --git a/backend/app/services/print_service.py b/backend/app/services/print_service.py index 645a4e8..b2c5de2 100644 --- a/backend/app/services/print_service.py +++ b/backend/app/services/print_service.py @@ -1,209 +1,128 @@ -"""PrintService — orchestrates template, label data, render, queue.submit.""" +"""PrintService — submit_print_job pipeline using LayoutEngine. + +Phase 1k.1a Task 15: TemplateLoader and LabelRenderer removed. The engine renders +for the currently loaded tape (preflight.loaded_tape_mm), so TapeMismatchError +is obsolete in this path. NoTapeLoadedError is raised when preflight returns +loaded_tape_mm=None. + +PAUSED-Job path (submit_paused_with_id, resume_paused_job, on_tape_mismatch +branching) removed — superseded by the LayoutEngine's tape-agnostic rendering. + +submit_batch_job preserved for Phase 1k.2 (batch print endpoint). +""" from __future__ import annotations import asyncio -from typing import Any, Protocol +import logging +from typing import Any, cast from uuid import UUID, uuid4 from PIL import Image from app.models.job import Job -from app.printer_backends.exceptions import TapeMismatchError -from app.printer_backends.snmp_helper import PreflightStatus +from app.printer_backends.exceptions import NoTapeLoadedError from app.schemas.label_data import LabelData from app.schemas.print_request import PrintRequest -from app.schemas.template import TemplateSchema -from app.services.job_store import JobStore, MemoryJobStore -from app.services.print_queue import PrintQueue - - -class _TemplateLoaderProto(Protocol): - def get(self, template_id: str) -> TemplateSchema: ... - +from app.services.layout_engine import LayoutEngine -class _RendererProto(Protocol): - def render(self, template: TemplateSchema, label_data: LabelData) -> Image.Image: ... - - -class _LookupServiceProto(Protocol): - async def lookup(self, app: str, identifier: str) -> LabelData: ... - - -class _BackendProto(Protocol): - async def preflight_check(self) -> PreflightStatus: ... +_log = logging.getLogger(__name__) class PrintService: - """Use-case orchestrator for POST /print.""" + """Orchestrate preflight → render → persist → queue.submit.""" def __init__( self, *, - template_loader: _TemplateLoaderProto, - renderer: _RendererProto, - print_queue: PrintQueue, - lookup_service: _LookupServiceProto, printer_id: UUID, - backend: _BackendProto, - store: JobStore | None = None, + backend: Any, # PrinterBackend protocol + queue: Any, # PrintQueue protocol + store: Any, # JobStore protocol + engine: LayoutEngine, + lookup_service: Any = None, # optional LookupService protocol ) -> None: - self._loader = template_loader - self._renderer = renderer - self._queue = print_queue - self._lookup = lookup_service self._printer_id = printer_id self._backend = backend - # Phase 2: JobStore für Persistierung vor queue.submit. - # Default MemoryJobStore für Backward-Compat mit Pre-Phase-2-Tests — - # Production-Code wired in Lifespan explizit SQLiteJobStore ein (Task 9). - self._store: JobStore = store if store is not None else MemoryJobStore() - - async def _resolve_label_data(self, request: PrintRequest) -> LabelData: - """Resolve label data from lookup or raw request data.""" - if request.lookup is not None: - return await self._lookup.lookup(request.lookup.app, request.lookup.identifier) - assert request.data is not None - return LabelData( - title=request.data.title, - primary_id=request.data.primary_id, - qr_payload=request.data.qr_payload, - secondary=tuple(request.data.secondary), - source_app="manual", - ) + self._queue = queue + self._store = store + self._engine = engine + self._lookup_service = lookup_service async def submit_print_job(self, request: PrintRequest) -> UUID: - """Orchestrate template-load → preflight → render → persist → queue.submit. + """Submit a print job: preflight → render → persist → queue. - Phase-2-Limitationen: - - on_tape_mismatch=queue: PAUSED-Jobs bleiben in-memory-only bis resume. - Hub-Restart während PAUSED löscht den Job — Phase-2-Trade-off. - Phase 3 (Issue #95 wenn erstellt) wird PAUSED in JobState enum aufnehmen - + DB-Migration für persistenten paused-state. - - tape_mismatch Metadaten (error_code, error_message, error_detail) werden - nur in-memory gehalten; keine DB-Row im PAUSED-Pfad. + Raises: + NoTapeLoadedError (409): preflight returned loaded_tape_mm=None. + UnsupportedTapeError (409): tape_mm not in TAPE_GEOMETRY. + ContentTypeDataMismatchError (422): data missing required fields. """ - # 1. Load template — fail fast before any I/O if template is unknown. - template = self._loader.get(request.template_id) - - # 2. SNMP preflight — raises PrinterOfflineError, TapeEmptyError, - # PrinterCoverOpenError synchronously if the printer is not ready. preflight = await self._backend.preflight_check() + if preflight.loaded_tape_mm is None: + raise NoTapeLoadedError() - # 3. Tape-mismatch check — two outcomes depending on on_tape_mismatch. - if preflight.loaded_tape_mm != template.tape_mm: - mismatch = TapeMismatchError( - expected_mm=template.tape_mm, - loaded_mm=preflight.loaded_tape_mm, - ) - if request.on_tape_mismatch == "fail": - raise mismatch - - # "queue" path: PAUSED-Jobs NICHT in DB persistieren. - # C-1-Fix: save_queued würde den Job als QUEUED in DB ablegen, aber - # PAUSED ist kein gültiger JobState-Wert. Nach Hub-Restart würde - # list_pending() den Job als QUEUED finden und sofort drucken — - # obwohl der User noch den Tape wechseln muss (Doppel-Druck-Risiko). - # Trade-off: Job geht bei Hub-Restart verloren, nichts wurde gedruckt. - # R2-M3: PrintRequest hat KEINE api_key_id/source_ip Felder. - # AuthContext-Integration folgt in einem späteren Task. - label_data = await self._resolve_label_data(request) - image = self._renderer.render(template, label_data) - paused_job_id = Job( - printer_id=self._printer_id, - template_key=request.template_id, - payload={ - "label_data": label_data.model_dump(), - "tape_mm": template.tape_mm, - "options": { - "auto_cut": request.options.auto_cut, - "high_resolution": request.options.high_resolution, - "half_cut": request.options.half_cut, # R4-C-1-Fix - "last_page": request.options.last_page, # R4-C-1-Fix - }, - }, - api_key_id=None, # TODO: aus AuthContext wenn Endpoint-Layer angepasst - source_ip=None, # TODO: aus AuthContext wenn Endpoint-Layer angepasst - ) - # Keine save_queued() — Job bleibt in-memory-only bis resume. - await self._queue.submit_paused_with_id( - paused_job_id.id, - self._printer_id, - image, - tape_mm=template.tape_mm, - auto_cut=request.options.auto_cut, - high_resolution=request.options.high_resolution, - half_cut=request.options.half_cut, # R4-C-1-Fix - last_page=request.options.last_page, # R4-C-1-Fix - ) - # Tape-mismatch Metadaten an den in-memory Job anhängen - in_memory_job = await self._queue.get(str(paused_job_id.id)) - in_memory_job.error_code = "tape_mismatch" - in_memory_job.error_message = str(mismatch) - in_memory_job.error_detail = { - "expected_mm": template.tape_mm, - "loaded_mm": preflight.loaded_tape_mm, - } - return paused_job_id.id - - # 4. Happy path: resolve label data, render, submit. - # Phase 2: DB-Row anlegen BEVOR an Queue übergeben (Durability-Garantie). - # R2-M3: PrintRequest hat KEINE api_key_id/source_ip Felder. label_data = await self._resolve_label_data(request) - image = self._renderer.render(template, label_data) - db_job = Job( + # asyncio.to_thread: render() is CPU-bound (QR generation + font rendering). + # Offloading keeps the event loop responsive for concurrent requests. + # Matches the pattern already used in submit_batch_job._prepare_one. + image = await asyncio.to_thread( + self._engine.render, + preflight.loaded_tape_mm, + request.content_type, + label_data, + ) + + job_id = uuid4() + job = Job( + id=job_id, printer_id=self._printer_id, - template_key=request.template_id, + template_key=None, payload={ "label_data": label_data.model_dump(), - "tape_mm": template.tape_mm, - "options": { - # `copies` wird nicht weitergeleitet — Phase-5 Follow-up. - "auto_cut": request.options.auto_cut, - "high_resolution": request.options.high_resolution, - "half_cut": request.options.half_cut, # R4-C-1-Fix - "last_page": request.options.last_page, # R4-C-1-Fix - }, + "content_type": str(request.content_type), + "rendered_tape_mm": preflight.loaded_tape_mm, + "tape_mm": preflight.loaded_tape_mm, + "options": request.options.model_dump(), }, - api_key_id=None, # TODO: aus AuthContext wenn Endpoint-Layer angepasst - source_ip=None, # TODO: aus AuthContext wenn Endpoint-Layer angepasst + api_key_id=None, + source_ip=None, ) - await self._store.save_queued(db_job) - try: - await self._queue.submit_with_id( - db_job.id, - self._printer_id, - image, - tape_mm=template.tape_mm, - auto_cut=request.options.auto_cut, - high_resolution=request.options.high_resolution, - half_cut=request.options.half_cut, # R4-C-1-Fix - last_page=request.options.last_page, # R4-C-1-Fix - ) - except Exception as exc: - # I-1-Fix: in-memory Submit fehlgeschlagen nach DB-Persist — Rollback. - # Ohne diesen Rollback bliebe eine stale QUEUED-Row in der DB ohne - # Worker-Gegenstück, die nach Hub-Restart fälschlicherweise re-enqueued - # würde. mark_failed markiert die Row als FAILED und verhindert das. - await self._store.mark_failed( - db_job.id, - f"submit_failed: {exc.__class__.__name__}: {exc}", - ) - 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.) + await self._store.save_queued(job) + await self._queue.submit_with_id( + job_id, + self._printer_id, + image, + tape_mm=preflight.loaded_tape_mm, + auto_cut=request.options.auto_cut, + high_resolution=request.options.high_resolution, + half_cut=request.options.half_cut, + last_page=request.options.last_page, + ) + return job_id - Raises: - TemplateNotFoundError: wenn template_id nicht im TemplateLoader. - """ - template = self._loader.get(template_id) - return template.tape_mm + async def _resolve_label_data(self, request: PrintRequest) -> LabelData: + """Resolve label data from raw request data or lookup service.""" + if request.data is not None: + raw = request.data + return LabelData( + source_app="manual", + title=raw.title, + primary_id=raw.primary_id, + qr_payload=raw.qr_payload, + secondary=raw.secondary, + items=raw.items, + ) + assert request.lookup is not None + if self._lookup_service is None: + msg = "lookup_service is required for lookup-based PrintRequest" + raise RuntimeError(msg) + return cast( + LabelData, + await self._lookup_service.resolve( + request.lookup.app, + request.lookup.identifier, + ), + ) async def submit_batch_job( self, @@ -214,61 +133,50 @@ async def submit_batch_job( """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. + Preflight wird 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). + NOTE: Diese Methode verwendet noch direkte LayoutEngine-Render-Aufrufe + ohne TapeMismatchError — Phase 1k.2 wird sie vollständig anpassen. """ 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) + # 1. Preflight (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, - ) + if preflight.loaded_tape_mm is None: + raise NoTapeLoadedError() + + tape_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. + # 2. Resolve LabelData ONCE per item, then render via LayoutEngine async def _prepare_one( - req: PrintRequest, tmpl: TemplateSchema + req: PrintRequest, ) -> 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) + image = await asyncio.to_thread( + self._engine.render, + tape_mm, + req.content_type, + 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)] - ) + prepared = await asyncio.gather(*[_prepare_one(r) for r in requests]) 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). + # 3. Pre-allocate job UUIDs + persist in JobStore 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, + template_key=None, payload={ "tape_mm": tape_mm, + "content_type": str(request.content_type), + "rendered_tape_mm": tape_mm, "options": request.options.model_dump(), "label_data": ld_dump, }, @@ -278,10 +186,7 @@ async def _prepare_one( await self._store.save_queued(db_job) 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). + # 4. Enqueue as BatchJob first_options = requests[0].options await self._queue.enqueue_batch( printer_id=self._printer_id, diff --git a/backend/app/services/readiness.py b/backend/app/services/readiness.py index c20d0a6..ac0ae53 100644 --- a/backend/app/services/readiness.py +++ b/backend/app/services/readiness.py @@ -1,15 +1,15 @@ -"""Phase 7b Cluster 1e — readiness aggregator (all 8 checks). +"""Phase 7b Cluster 1e — readiness aggregator (7 checks). Checks implemented: database — SELECT 1 latency (critical) alembic — alembic_version at head (critical) - template_seed — templates table non-empty (critical) printer_runtime — app.state.printer_id set (non-critical) printer_db_sync — runtime printer_id has a DB row (non-critical) snmp_discovery — PrinterStatusCache recency (non-critical) print_queue — print_queue in app.state (non-critical) sse_bus — subscriber capacity (non-critical) +Phase 1k.1a Task 24: template_seed check removed (templates table dropped). F4 wires the FastAPI route + HTTP status mapping. """ @@ -19,16 +19,15 @@ from datetime import UTC, datetime from typing import Any, Literal -from sqlalchemy import func, select, text +from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from app.config import Settings from app.models.printer import Printer from app.models.printer_status_cache import PrinterStatusCache -from app.models.template import Template from app.schemas.readiness import CheckStatus, ReadinessResponse -_CRITICAL_CHECKS = ("database", "alembic", "template_seed") +_CRITICAL_CHECKS = ("database", "alembic") async def _check_database(session: AsyncSession) -> CheckStatus: @@ -51,18 +50,6 @@ async def _check_alembic(settings: Settings) -> CheckStatus: return CheckStatus(status="fail", detail=str(exc)) -async def _check_template_seed(session: AsyncSession) -> CheckStatus: - count = await session.scalar(select(func.count()).select_from(Template)) - cnt = int(count or 0) - if cnt >= 1: - return CheckStatus(status="ok", metric={"templates_in_db": cnt}) - return CheckStatus( - status="fail", - detail="Templates table is empty — lifespan init-order regression?", - metric={"templates_in_db": cnt}, - ) - - def _check_printer_runtime(app_state: Any) -> CheckStatus: pid = getattr(app_state, "printer_id", None) if pid is None: @@ -160,7 +147,6 @@ async def build_readiness_response( checks: dict[str, CheckStatus] = { "database": await _check_database(session), "alembic": await _check_alembic(settings), - "template_seed": await _check_template_seed(session), "printer_runtime": _check_printer_runtime(app_state), "printer_db_sync": await _check_printer_db_sync(session, app_state), "snmp_discovery": await _check_snmp_discovery(session, app_state), diff --git a/backend/app/services/svg_renderer.py b/backend/app/services/svg_renderer.py deleted file mode 100644 index 81420e9..0000000 --- a/backend/app/services/svg_renderer.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Render a label template as a pure-vector SVG string. - -The SVG mirrors the LabelRenderer's pixel coordinate system 1:1 (top-left -origin, 300 DPI Brother geometry) so that SVG previews match what gets printed. - -QR codes are rendered as inline pure-vector ```` elements using -qrcode's SvgPathImage factory. All text elements become ```` nodes — -no raster embeds for text. A gray ```` outlines the tape boundary. - -Coordinate system: - - The tape's printable height is taken from TAPE_HEIGHT_PX, same as - LabelRenderer. - - The canvas width is fixed at DEFAULT_LABEL_WIDTH_PX (600 px), same as - LabelRenderer, so element x/y coordinates translate 1:1. - - An annotation strip of ANNOTATION_HEIGHT_PX is reserved above the tape - rect so the viewBox shows the title line outside the printable area. -""" - -from __future__ import annotations - -import re -import xml.etree.ElementTree as ET - -import qrcode -import qrcode.image.svg - -from app.services.label_renderer import DEFAULT_LABEL_WIDTH_PX, TAPE_HEIGHT_PX - -# Extra vertical space above the tape rect used for the template-key annotation. -_ANNOTATION_HEIGHT_PX: int = 18 - - -def _qr_svg_group(qr_data: str, x: int, y: int, size_px: int) -> str: - """Return an SVG ```` element containing the QR code as a pure-vector path. - - The path is extracted from qrcode's SvgPathImage output and scaled so that - the QR module fills exactly ``size_px x size_px`` pixels at the given (x, y) - position. - - Args: - qr_data: The payload to encode. - x: Left edge in the tape's pixel coordinate system. - y: Top edge in the tape's pixel coordinate system. - size_px: Target width/height in pixels. - - Returns: - A ``...`` string ready for embedding in the SVG. - """ - # Use box_size=1 + border=0 so the path coordinates are in module units - # (integers), making the scale calculation straightforward. - factory = qrcode.image.svg.SvgPathImage - qr_img = qrcode.make( - qr_data, - image_factory=factory, - box_size=1, - border=0, - ) - raw_svg = qr_img.to_string(encoding="unicode") - - # Parse the outer to grab the viewBox dimensions and the element. - root = ET.fromstring(raw_svg) - ns = {"svg": "http://www.w3.org/2000/svg"} - path_el = root.find("svg:path", ns) - if path_el is None: - # Fallback: try without namespace (some qrcode versions omit it) - path_el = root.find("path") - if path_el is None: - raise RuntimeError(f"qrcode SvgPathImage produced no element for data={qr_data!r}") - - path_d = path_el.attrib.get("d", "") - - # Derive the QR grid size from the viewBox. With box_size=1 the viewBox - # width equals the number of modules. - vb = root.attrib.get("viewBox", "") - vb_parts = vb.split() - if len(vb_parts) == 4: - qr_units = float(vb_parts[2]) # width in module units - else: - # Parse from width attribute ("29mm" etc.) as fallback. - w_str = root.attrib.get("width", "1") - qr_units = float(re.sub(r"[^0-9.]", "", w_str) or "1") - - scale = size_px / qr_units if qr_units else 1.0 - - # Shift the annotation offset: QR y is in tape-space so we add - # the annotation strip below in the outer SVG, not here. - return ( - f'' - f'' - f"" - ) - - -def _annotation_label(template_id: str, tape_mm: int, elements: list[dict[str, object]]) -> str: - """Return a short human-readable title string for the SVG annotation strip.""" - text_count = sum(1 for el in elements if el.get("type") == "text") - plural = "s" if text_count != 1 else "" - return f"{template_id} — tape {tape_mm}mm — {text_count} text line{plural}" - - -def _resolve_sample_value(field: str, sample: dict[str, object]) -> str: - """Look up *field* in *sample*, joining list values with ' | '.""" - value = sample.get(field, "") - if isinstance(value, (list, tuple)): - return " | ".join(str(v) for v in value) - return str(value) - - -def render_template_svg( - template_definition: dict[str, object], sample_data: dict[str, object] -) -> str: - """Render a template's preview as a pure-vector SVG string. - - The SVG mirrors the LabelRenderer's pixel coordinate system 1:1 so it - matches the print output. QR codes are rendered as inline ```` - using python-qrcode's SvgPathImage factory; text elements become - ````; the tape outline is a ```` with a 1px gray border. - - Args: - template_definition: contents of Template.definition JSON column - (already deserialised). Must include ``tape_mm`` and ``elements``. - sample_data: per-template preview_sample dict (already validated). - - Returns: - Full SVG XML as a string starting with ````. - """ - tape_mm = int(str(template_definition["tape_mm"])) - tape_h = TAPE_HEIGHT_PX.get(tape_mm) - if tape_h is None: - raise ValueError(f"Unsupported tape_mm: {tape_mm}. Supported: {sorted(TAPE_HEIGHT_PX)}") - - w = DEFAULT_LABEL_WIDTH_PX - raw_elements = template_definition.get("elements", []) - element_list: list[object] = list(raw_elements) if isinstance(raw_elements, list) else [] - elements: list[dict[str, object]] = [dict(el) for el in element_list if isinstance(el, dict)] - template_id = str(template_definition.get("id", "unknown")) - - # Total SVG height = annotation strip + tape body - total_h = _ANNOTATION_HEIGHT_PX + tape_h - - # viewBox: origin is at the top-left of the annotation strip; the tape - # rect starts at y=_ANNOTATION_HEIGHT_PX. - vb = f"0 0 {w} {total_h}" - - lines: list[str] = [] - lines.append( - f'' - ) - - # — Annotation strip (outside the printable tape area) —————————————— - annotation = _annotation_label(template_id, tape_mm, elements) - lines.append( - f' ' - f"{annotation}" - f"" - ) - - # — Tape background + outline —————————————————————————————————————— - ty = _ANNOTATION_HEIGHT_PX # tape top y in SVG coordinates - lines.append( - f' ' - ) - - # — Label elements ————————————————————————————————————————————————— - for el in elements: - el_type = str(el.get("type", "")) - ex = int(str(el.get("x", 0))) - ey = int(str(el.get("y", 0))) - - # Shift element y coordinates by the annotation strip height so that - # the element positions in the SVG match the pixel coordinates used - # by the LabelRenderer on the tape. - svg_y = ty + ey - - if el_type == "qr": - data_field = str(el.get("data_field", "qr_payload")) - size_px = int(str(el.get("size", 80))) - qr_data = _resolve_sample_value(data_field, sample_data) - # QR group: translate to tape-offset-adjusted position. - qr_group = _qr_svg_group(qr_data, ex, svg_y, size_px) - lines.append(f" {qr_group}") - - elif el_type == "text": - field = str(el.get("field", "")) - font_size = int(str(el.get("font_size", 14))) - text_value = _resolve_sample_value(field, sample_data) - # Escape XML special characters in user data. - text_value = ( - text_value.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace('"', """) - ) - lines.append( - f' {text_value}' - ) - - lines.append("") - return "\n".join(lines) diff --git a/backend/app/services/template_loader.py b/backend/app/services/template_loader.py deleted file mode 100644 index 407e179..0000000 --- a/backend/app/services/template_loader.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Load and cache seed templates from YAML files. - -TemplateLoader is class-level state (analogous to IntegrationRegistry). -Importing this module does not load anything — call ``load_dir(path)`` -from ``main.py`` after plugin discovery so the registry-validation -sees all registered plugins. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import ClassVar - -import yaml -from pydantic import ValidationError - -from app.integrations.registry import IntegrationRegistry -from app.schemas.template import TemplateSchema - - -class TemplateValidationError(Exception): - """A YAML file failed to parse into a valid TemplateSchema.""" - - -class TemplateNotFoundError(KeyError): - """Requested template id is not registered. Subclasses KeyError so legacy - callers that catch KeyError keep working.""" - - -class TemplateLoader: - """Class-level cache of seed templates.""" - - _cache: ClassVar[dict[str, TemplateSchema]] = {} - - @classmethod - def _load_single(cls, path: Path) -> TemplateSchema: - """Parse one YAML file, raise TemplateValidationError on any failure.""" - try: - raw = yaml.safe_load(path.read_text(encoding="utf-8")) - except OSError as e: - raise TemplateValidationError(f"{path.name}: could not read file: {e}") from e - except yaml.YAMLError as e: - raise TemplateValidationError(f"{path.name}: YAML parse error: {e}") from e - - if not isinstance(raw, dict): - raise TemplateValidationError( - f"{path.name}: top-level YAML must be a mapping, got {type(raw).__name__}" - ) - - try: - template = TemplateSchema(**raw) - except ValidationError as e: - raise TemplateValidationError(f"{path.name}: schema validation failed: {e}") from e - - if template.app is not None and template.app not in IntegrationRegistry.names(): - raise TemplateValidationError( - f"{path.name}: references unknown integration {template.app!r}. " - f"Registered: {IntegrationRegistry.names()}" - ) - - return template - - @classmethod - def load_dir(cls, directory: Path) -> None: - """Parse every ``*.yaml`` in ``directory`` and cache by template id. - - Atomic: all files are parsed into a staging dict before the cache is - replaced. A failure during any single-file load raises - TemplateValidationError and the cache remains in its previous state. - - Duplicate ids across YAML files raise TemplateValidationError — - silently overwriting a previously-loaded template would mask a - real authoring bug. - """ - staging: dict[str, TemplateSchema] = {} - duplicate_origin: dict[str, str] = {} # id -> filename that first defined it - - for path in sorted(directory.glob("*.yaml")): - template = cls._load_single(path) - if template.id in staging: - raise TemplateValidationError( - f"{path.name}: duplicate template id {template.id!r} " - f"(first defined in {duplicate_origin[template.id]})" - ) - staging[template.id] = template - duplicate_origin[template.id] = path.name - - # Atomic replace — only reached if every file parsed cleanly. - cls._cache = staging - - @classmethod - def get(cls, template_id: str) -> TemplateSchema: - """Return the cached template or raise TemplateNotFoundError.""" - if template_id not in cls._cache: - raise TemplateNotFoundError(template_id) - return cls._cache[template_id] - - @classmethod - def all(cls) -> dict[str, TemplateSchema]: - """Return a shallow copy of the cache (caller may mutate safely).""" - return dict(cls._cache) - - @classmethod - def by_app(cls, app: str | None) -> list[TemplateSchema]: - """Return all templates whose ``app`` matches the argument exactly. - - ``by_app(None)`` returns generic (QR-only) templates. - """ - return [t for t in cls._cache.values() if t.app == app] - - @classmethod - def reload(cls, directory: Path) -> None: - """Replace the cache with templates from ``directory`` atomically. - - Unlike a naive ``clear() + load_dir()``, this method only mutates - ``cls._cache`` if every YAML in the directory parses cleanly. A - broken file (e.g. mid-edit save from the Phase-7 editor) raises - TemplateValidationError and the cache stays on the previous valid - set. - """ - cls.load_dir(directory) # load_dir is now atomic — same semantics - - @classmethod - async def seed_db(cls, session: object) -> int: - """Idempotent YAML-to-DB upsert: convert every cached TemplateSchema - to a ``Template`` row with ``source='seed'`` and call - ``templates_repo.upsert_seed``. - - Mapping (TemplateSchema → Template column): - - ============== ===================================================== - schema.id Template.key — stable identifier - schema.name Template.name - schema.app Template.app (None for generic templates) - schema.tape_mm Template.tape_width_mm - schema.schema_version Template.schema_version - schema.printer_model or "pt-series" - Template.printer_model — YAML value takes precedence, - falls back to "pt-series" for backward-compat - schema.model_dump() Template.definition — serialised body - ============== ===================================================== - - User-created templates (``source='user'``) are never overwritten; - the repository guarantees that contract. - - Returns the count of rows inserted or updated. - """ - - from app.models.template import Template - from app.repositories import templates as templates_repo - - rows: list[Template] = [ - Template( - key=schema.id, - name=schema.name, - app=schema.app, - printer_model=(schema.printer_model or "pt-series"), - tape_width_mm=schema.tape_mm, - schema_version=schema.schema_version, - definition=schema.model_dump(), - source="seed", - ) - for schema in cls._cache.values() - ] - - return await templates_repo.upsert_seed( - session, # type: ignore[arg-type] - rows, - ) diff --git a/backend/scripts/smoke_layout_engine_12mm_v4.py b/backend/scripts/smoke_layout_engine_12mm_v4.py new file mode 100644 index 0000000..d44a398 --- /dev/null +++ b/backend/scripts/smoke_layout_engine_12mm_v4.py @@ -0,0 +1,102 @@ +"""V4-Winner smoke script: LayoutEngine 12mm QR_TWO_LINES. + +Phase 1k.1a (Task 25): Verifies that the LayoutEngine renders a 12mm +QR_TWO_LINES label to a valid PIL image without crashing. + +Phase 1k.1a (Round-1 fix, MED-2): Added SHA-256 pixel-hash to detect +rendering regressions across code changes. EXPECTED_SHA is set to None +until first hardware-verified baseline. Set it to the printed SHA after +verifying the render output is correct on target hardware: + + EXPECTED_SHA = "" + +Run from the backend directory: + + python -m scripts.smoke_layout_engine_12mm_v4 + +No printer hardware required -- pure rendering smoke test. +Exits 0 on success, 1 on failure. +""" + +from __future__ import annotations + +import hashlib +import io +import sys + +from app.schemas.content_type import ContentType +from app.schemas.label_data import LabelData +from app.services.layout_engine import LayoutEngine +from PIL import Image + +# Pinned pixel-hash for regression detection. +# Set to the SHA printed after the first hardware-verified run. +# None = hash is printed but not validated (initial setup mode). +_EXPECTED_SHA: str | None = None + + +def main() -> int: + """Run 12mm QR_TWO_LINES render smoke test.""" + engine = LayoutEngine() + label_data = LabelData( + source_app="smoke", + primary_id="SMOKE-001", + title="12mm V4-Winner", + qr_payload="https://example.com/smoke/12mm", + ) + + print("Rendering 12mm QR_TWO_LINES label...") # noqa: T201 + try: + image = engine.render(12, ContentType.QR_TWO_LINES, label_data) + except Exception as exc: + print(f"FAIL: render raised {type(exc).__name__}: {exc}", file=sys.stderr) # noqa: T201 + return 1 + + if not isinstance(image, Image.Image): + print(f"FAIL: expected PIL Image, got {type(image)!r}", file=sys.stderr) # noqa: T201 + return 1 + + if image.width <= 0 or image.height <= 0: + print( # noqa: T201 + f"FAIL: image has zero/negative dimensions: {image.size}", + file=sys.stderr, + ) + return 1 + + print( # noqa: T201 + f"OK: rendered {image.mode} image {image.width}x{image.height}px for 12mm QR_TWO_LINES" + ) + + # Spot-check: save as PNG bytes (exercises the codec pipeline) + buf = io.BytesIO() + image.save(buf, format="PNG") + png_bytes = buf.getvalue() + if len(png_bytes) < 100: # A valid PNG is always > 100 bytes + print( # noqa: T201 + f"FAIL: PNG encode produced suspiciously small output ({len(png_bytes)} bytes)", + file=sys.stderr, + ) + return 1 + + print(f"OK: PNG encode produced {len(png_bytes)} bytes") # noqa: T201 + + # Pixel-hash: SHA-256 of the raw PNG bytes. Detects rendering regressions + # (font changes, geometry tweaks, QR algorithm changes) across code changes. + # Set EXPECTED_SHA after verifying the render on target hardware. + sha = hashlib.sha256(png_bytes).hexdigest() + print(f"SHA-256: {sha}") # noqa: T201 + + # Compare against pinned baseline (set _EXPECTED_SHA at module top after first verified run). + if _EXPECTED_SHA is not None and sha != _EXPECTED_SHA: + print( # noqa: T201 + f"WARNING: SHA mismatch! Expected {_EXPECTED_SHA}, got {sha}", + file=sys.stderr, + ) + return 1 + + print("SMOKE PASS") # noqa: T201 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/tests/db/test_lifespan.py b/backend/tests/db/test_lifespan.py index 0eaaeaf..dead4af 100644 --- a/backend/tests/db/test_lifespan.py +++ b/backend/tests/db/test_lifespan.py @@ -9,19 +9,22 @@ existing Alembic CLI tests and by the full app startup in CI. We skip a dedicated unit test here and cover only the three helpers that operate on the in-memory session fixture. + +Phase 1k.1a (Task 25): Template model removed — seed_templates is now a no-op +stub and TemplateLoader is deleted. Tests that tested template-seeding behaviour +(test_seed_templates_*) are removed. recover_inflight_jobs and ensure_printer_state +tests remain unaffected. """ from __future__ import annotations import pytest -from app.db.lifespan import ensure_printer_state, recover_inflight_jobs, seed_templates +from app.db.lifespan import ensure_printer_state, recover_inflight_jobs from app.models.job import Job, JobState from app.models.printer import Printer from app.models.printer_state import PrinterState -from app.models.template import Template from app.repositories import jobs as jobs_repo from app.repositories import printers as printers_repo -from app.repositories import templates as templates_repo # --------------------------------------------------------------------------- # Helpers @@ -41,90 +44,11 @@ async def _make_printer(session, *, name: str = "pt-office") -> Printer: return await printers_repo.create(session, p) -class _MockLoader: - """Minimal stand-in for TemplateLoader.all() that returns a fixed dict. - - Avoids loading real YAML files (which would require IntegrationRegistry - to have plugins registered) and keeps the test self-contained. - - ``_cache`` is populated on construction to satisfy the D1 defensive check - in seed_templates (which raises RuntimeError when the cache is empty). - """ - - def __init__(self, count: int = 3) -> None: - self._count = count - self._templates = { - f"tpl-{i}": _schema_stub(f"tpl-{i}", f"Template {i}") for i in range(count) - } - # Mirror _templates in _cache so the D1 check passes — this mock - # represents a loader that has already called load_dir(). - self._cache = dict(self._templates) - - def all(self) -> dict: - return dict(self._templates) - - def __len__(self) -> int: - return self._count - - async def seed_db(self, session) -> int: - """Implement seed_db so seed_templates can delegate (Task 8 interface).""" - rows = [ - Template( - key=schema.id, - name=schema.name, - app=schema.app, - printer_model="pt-series", - tape_width_mm=schema.tape_mm, - schema_version=schema.schema_version, - definition=schema.model_dump(), - source="seed", - ) - for schema in self._templates.values() - ] - return await templates_repo.upsert_seed(session, rows) - - -def _schema_stub(id_: str, name: str): - """Build a minimal TemplateSchema-like object for testing.""" - from app.schemas.template import TemplateSchema - - return TemplateSchema( - id=id_, - name=name, - app=None, - tape_mm=12, - schema_version=1, - elements=( - { - "type": "qr", - "x": 0, - "y": 0, - "size": 80, - "data_field": "url", - }, - ), - ) - - # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- -@pytest.mark.asyncio -async def test_seed_templates_raises_on_empty_loader_cache(): - """Cluster 1a defensive check: empty TemplateLoader cache → RuntimeError, no silent no-op.""" - from app.services.template_loader import TemplateLoader - - original_cache = dict(TemplateLoader._cache) - TemplateLoader._cache.clear() - try: - with pytest.raises(RuntimeError, match="empty"): - await seed_templates(None, TemplateLoader) # type: ignore[arg-type] - finally: - TemplateLoader._cache = original_cache - - @pytest.mark.asyncio async def test_recover_marks_inflight_as_failed_restart(session): """recover_inflight_jobs sweeps QUEUED jobs to FAILED_RESTART.""" @@ -145,21 +69,6 @@ async def test_recover_marks_inflight_as_failed_restart(session): assert refreshed.state == JobState.FAILED_RESTART.value -@pytest.mark.asyncio -async def test_seed_templates_idempotent(session): - """seed_templates called twice produces exactly N rows — no duplicates.""" - loader = _MockLoader(count=3) - - count_first = await seed_templates(session, loader) - count_second = await seed_templates(session, loader) - - assert count_first == len(loader) - assert count_second == len(loader) - - all_rows = await templates_repo.list_all(session) - assert len(all_rows) == len(loader) - - @pytest.mark.asyncio async def test_ensure_printer_state_creates_missing(session): """ensure_printer_state creates one row per printer; second call creates none.""" diff --git a/backend/tests/db/test_presets_repo.py b/backend/tests/db/test_presets_repo.py deleted file mode 100644 index 7a57710..0000000 --- a/backend/tests/db/test_presets_repo.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Tests for the presets repository — FK enforcement + nullable printer_id.""" - -from uuid import uuid4 - -import pytest -import sqlalchemy.exc -from app.models.preset import Preset -from app.models.printer import Printer -from app.models.template import Template -from app.repositories import presets, printers, templates - - -def _printer() -> Printer: - return Printer( - name="test-printer", - model="pt-series", - backend="ptouch", - connection={"interface": "usb"}, - ) - - -def _template() -> Template: - return Template( - key="test-label", - name="Test Label", - printer_model="pt-series", - tape_width_mm=12, - definition={"elements": []}, - source="seed", - ) - - -@pytest.mark.asyncio -async def test_create_with_template_required(session): - """Creates a Template then a Preset referencing it; asserts both columns populated.""" - tpl = await templates.create_user_template(session, _template()) - p = await printers.create(session, _printer()) - - preset = Preset( - name="office-label", - printer_id=p.id, - template_id=tpl.id, - field_values={"greeting": "Hello"}, - ) - created = await presets.create(session, preset) - - assert created.id is not None - assert created.template_id == tpl.id - assert created.printer_id == p.id - assert created.field_values == {"greeting": "Hello"} - - fetched = await presets.get(session, created.id) - assert fetched is not None - assert fetched.name == "office-label" - - -@pytest.mark.asyncio -async def test_printer_id_optional(session): - """Preset with printer_id=None round-trips fine.""" - tpl = await templates.create_user_template(session, _template()) - - preset = Preset( - name="no-printer-preset", - printer_id=None, - template_id=tpl.id, - field_values={"line1": "Hi"}, - ) - created = await presets.create(session, preset) - - assert created.id is not None - assert created.printer_id is None - assert created.template_id == tpl.id - - fetched = await presets.get(session, created.id) - assert fetched is not None - assert fetched.printer_id is None - - -@pytest.mark.asyncio -async def test_fk_to_missing_template_fails(session): - """FK enforcement: template_id referencing a non-existent UUID raises IntegrityError.""" - preset = Preset( - name="orphan-preset", - printer_id=None, - template_id=uuid4(), # does not exist in templates table - field_values={}, - ) - with pytest.raises(sqlalchemy.exc.IntegrityError): - await presets.create(session, preset) diff --git a/backend/tests/db/test_template_seed_db.py b/backend/tests/db/test_template_seed_db.py deleted file mode 100644 index c9f0961..0000000 --- a/backend/tests/db/test_template_seed_db.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Tests for TemplateLoader.seed_db() — the canonical YAML-to-DB conversion. - -These tests exercise the public classmethod directly, using a controlled set -of YAML-parsed TemplateSchema objects loaded into the class cache so there is -no dependency on the real seed-template directory or IntegrationRegistry. -""" - -from __future__ import annotations - -import pytest -from app.models.template import Template -from app.repositories import templates as templates_repo -from app.services.template_loader import TemplateLoader - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _load_stub_cache(ids: list[str], *, printer_model: str | None = None) -> dict: - """Build synthetic TemplateSchema objects and inject them into the class - cache without touching the filesystem or IntegrationRegistry. - - Uses ``TemplateSchema`` directly so the objects match exactly what a - real YAML load produces, making the conversion path in seed_db identical - to the production path. - - ``printer_model`` is forwarded to every schema; ``None`` leaves the field - unset (i.e. the YAML had no printer_model key) which tests the backward- - compat fallback to ``'pt-series'``. - """ - from app.schemas.template import TemplateSchema - - cache = {} - for id_ in ids: - kwargs: dict = { - "id": id_, - "name": f"Template {id_}", - "app": None, # generic — no integration dependency - "tape_mm": 12, - "schema_version": 1, - "elements": ( - { - "type": "qr", - "x": 0, - "y": 0, - "size": 80, - "data_field": "url", - }, - ), - } - if printer_model is not None: - kwargs["printer_model"] = printer_model - cache[id_] = TemplateSchema(**kwargs) - return cache - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_seed_db_idempotent(session): - """seed_db called twice returns the same count with no duplicate rows.""" - original_cache = dict(TemplateLoader._cache) - try: - TemplateLoader._cache = _load_stub_cache(["alpha", "beta", "gamma"]) - - count_first = await TemplateLoader.seed_db(session) - count_second = await TemplateLoader.seed_db(session) - - assert count_first == 3 - assert count_second == 3 - - rows = await templates_repo.list_all(session) - assert len(rows) == 3 - - # Verify all three keys are present - keys = {r.key for r in rows} - assert keys == {"alpha", "beta", "gamma"} - - # All rows must be marked as seed - assert all(r.source == "seed" for r in rows) - finally: - TemplateLoader._cache = original_cache - - -@pytest.mark.asyncio -async def test_seed_db_user_template_survives(session): - """A user-created template with a non-conflicting key is untouched by seed_db.""" - original_cache = dict(TemplateLoader._cache) - try: - # Create a user row in the DB first - user_tpl = Template( - key="my-custom-label", - name="My Company Label", - printer_model="pt-series", - tape_width_mm=18, - definition={"custom": True}, - source="user", - ) - await templates_repo.create_user_template(session, user_tpl) - - # Seed the cache with different keys - TemplateLoader._cache = _load_stub_cache(["seed-a", "seed-b"]) - await TemplateLoader.seed_db(session) - - # The user row must still exist and be unchanged - found = await templates_repo.get_by_key(session, "my-custom-label") - assert found is not None - assert found.source == "user" - assert found.name == "My Company Label" - assert found.tape_width_mm == 18 - - # Total rows = 1 user + 2 seed - all_rows = await templates_repo.list_all(session) - assert len(all_rows) == 3 - finally: - TemplateLoader._cache = original_cache - - -@pytest.mark.asyncio -async def test_seed_db_printer_model_from_yaml(session): - """printer_model in YAML is forwarded to the DB row (C7-Fix). - - A YAML with ``printer_model: brother_ql`` must produce a DB row with - ``printer_model='brother_ql'``, not the default ``'pt-series'``. - """ - original_cache = dict(TemplateLoader._cache) - try: - TemplateLoader._cache = _load_stub_cache(["ql-62mm"], printer_model="brother_ql") - await TemplateLoader.seed_db(session) - - found = await templates_repo.get_by_key(session, "ql-62mm") - assert found is not None - assert found.printer_model == "brother_ql", ( - f"Expected 'brother_ql' from YAML, got {found.printer_model!r}" - ) - finally: - TemplateLoader._cache = original_cache - - -@pytest.mark.asyncio -async def test_seed_db_printer_model_defaults_to_pt_series(session): - """A YAML without printer_model falls back to 'pt-series' (backward-compat). - - Existing seed YAMLs that don't carry the ``printer_model`` key must - continue to work and produce ``printer_model='pt-series'`` in the DB. - """ - original_cache = dict(TemplateLoader._cache) - try: - # printer_model=None → TemplateSchema.printer_model is None → seed_db fallback - TemplateLoader._cache = _load_stub_cache(["legacy-template"], printer_model=None) - await TemplateLoader.seed_db(session) - - found = await templates_repo.get_by_key(session, "legacy-template") - assert found is not None - assert found.printer_model == "pt-series", ( - f"Expected 'pt-series' fallback for YAML without printer_model, " - f"got {found.printer_model!r}" - ) - finally: - TemplateLoader._cache = original_cache diff --git a/backend/tests/db/test_templates_repo.py b/backend/tests/db/test_templates_repo.py deleted file mode 100644 index cd9f193..0000000 --- a/backend/tests/db/test_templates_repo.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Tests for the templates repository — seed/user split contract.""" - -import pytest -from app.models.template import Template -from app.repositories import templates - - -def _seed(key: str, name: str = "x", w: int = 12) -> Template: - return Template( - key=key, - name=name, - printer_model="pt-series", - tape_width_mm=w, - definition={"elements": []}, - source="seed", - ) - - -@pytest.mark.asyncio -async def test_seed_idempotent(session): - await templates.upsert_seed(session, [_seed("a"), _seed("b")]) - await templates.upsert_seed(session, [_seed("a"), _seed("b")]) - all_ = await templates.list_all(session) - assert len(all_) == 2 - - -@pytest.mark.asyncio -async def test_seed_does_not_overwrite_user(session): - user = Template( - key="custom", - name="user-edited", - printer_model="pt-series", - tape_width_mm=12, - definition={"v": 1}, - source="user", - ) - await templates.create_user_template(session, user) - - # Try to upsert a seed with the same key - await templates.upsert_seed(session, [_seed("custom", name="seed-name")]) - - found = await templates.get_by_key(session, "custom") - assert found.source == "user" - assert found.name == "user-edited" diff --git a/backend/tests/integration/api/conftest.py b/backend/tests/integration/api/conftest.py index 37a1655..7704322 100644 --- a/backend/tests/integration/api/conftest.py +++ b/backend/tests/integration/api/conftest.py @@ -1,40 +1,33 @@ -"""Shared fixtures for API integration tests.""" +"""Shared fixtures for API integration tests. -from __future__ import annotations +Phase 1k.1a (Task 25): Removed template_loader and seed_templates references. +Templates are deleted in Phase 1k.1a — the fixture now creates a plain app +client without template seeding. +""" -from pathlib import Path +from __future__ import annotations import pytest_asyncio from httpx import ASGITransport, AsyncClient -# TODO(#22): simplify this fixture once Phase 7b Task D2 lands — -# the lifespan re-order (load_dir BEFORE seed_templates) will let us drop -# the manual TemplateLoader.load_dir() + seed_templates() pre-seeding here. - -_SEED_DIR = Path(__file__).parents[3] / "app" / "seed" / "templates" - @pytest_asyncio.fixture async def api_client_with_seed(): - """AsyncClient against the full app with templates seeded. + """AsyncClient against the full app. Propagates the autouse _temp_db_engine patch from tests/integration/conftest.py into app.db.session (which holds a name-bound `async_session` snapshot taken at import time and is NOT updated automatically when engine.py's namespace gets monkey-patched). + + Phase 1k.1a: Template seeding removed (templates table dropped). """ import app.db.engine as _engine_module import app.db.session as _session_module - from app.db.lifespan import seed_templates from app.main import create_app - from app.services.template_loader import TemplateLoader _session_module.async_session = _engine_module.async_session - # Re-run integration plugin discovery when the lifespan from a previous - # test has cleared IntegrationRegistry (see main.py lifespan shutdown). - # TemplateLoader.load_dir validates template.app against IntegrationRegistry, - # so we must ensure the registry is populated before calling load_dir. from app.integrations import ( # type: ignore[attr-defined] IntegrationRegistry, _discover_plugins, @@ -43,14 +36,6 @@ async def api_client_with_seed(): if not IntegrationRegistry.names(): _discover_plugins() - original_cache = dict(TemplateLoader._cache) - TemplateLoader.load_dir(_SEED_DIR) - try: - async with _engine_module.async_session() as s: - await seed_templates(s, TemplateLoader) - - app = create_app() - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: - yield c - finally: - TemplateLoader._cache = original_cache + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + yield c diff --git a/backend/tests/integration/api/test_api_datetime_format.py b/backend/tests/integration/api/test_api_datetime_format.py index 349f88e..4d965ae 100644 --- a/backend/tests/integration/api/test_api_datetime_format.py +++ b/backend/tests/integration/api/test_api_datetime_format.py @@ -1,5 +1,9 @@ """Phase 7b Cluster 1c contract test — every datetime field in the API -response must include a timezone suffix (Z or +HH:MM).""" +response must include a timezone suffix (Z or +HH:MM). + +Phase 1k.1a (Task 25): test_template_read_has_tz_suffix removed +(/api/templates endpoint deleted with template model). +""" from __future__ import annotations @@ -15,20 +19,6 @@ def _has_tz_suffix(s: str) -> bool: return s.endswith("Z") or "+" in s or "-" in s[10:] -async def test_template_read_has_tz_suffix(api_client_with_seed): - """GET /api/templates returns datetimes with TZ info that fromisoformat can parse.""" - resp = await api_client_with_seed.get("/api/templates", headers={"X-Pangolin-User": "test"}) - assert resp.status_code == 200 - body = resp.json() - assert body, "expected at least one seeded template" - for t in body: - for field in ("created_at", "updated_at"): - assert _has_tz_suffix(t[field]), ( - f"template {t.get('key', '?')}: {field}={t[field]!r} missing TZ suffix" - ) - datetime.fromisoformat(t[field].replace("Z", "+00:00")) - - async def test_printer_read_has_tz_suffix(api_client_with_seed): """GET /api/printers returns datetimes with TZ info. diff --git a/backend/tests/integration/api/test_audit_trail.py b/backend/tests/integration/api/test_audit_trail.py index 5d0e77f..6234734 100644 --- a/backend/tests/integration/api/test_audit_trail.py +++ b/backend/tests/integration/api/test_audit_trail.py @@ -1,11 +1,12 @@ """Integration tests for API key audit trail on jobs — Phase 7c Step 7. Tests that POST /api/print with a key sets api_key_id and source_ip on the Job row. + +Phase 1k.1a (Task 25): Updated to use content_type-based API (template_id removed). """ from __future__ import annotations -from pathlib import Path from uuid import uuid4 import app.models # noqa: F401 @@ -13,8 +14,6 @@ import pytest from app.models.api_key import ApiKey -_SEED_DIR = Path(__file__).parents[3] / "app" / "seed" / "templates" - async def _insert_print_key(factory): plaintext = f"lh_pat_audit_trail_{uuid4().hex[:16]}" @@ -42,7 +41,10 @@ async def test_post_print_without_auth_still_returns_401(api_client_with_seed): """POST /print without auth → 401 (auth wired correctly).""" resp = await api_client_with_seed.post( "/print", - json={"template_id": "t", "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}}, + json={ + "content_type": "qr_two_lines", + "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}, + }, ) assert resp.status_code == 401 @@ -50,10 +52,12 @@ async def test_post_print_without_auth_still_returns_401(api_client_with_seed): @pytest.mark.asyncio async def test_legacy_print_endpoint_requires_auth(api_client_with_seed): """Legacy POST /print endpoint also requires print scope.""" - # Two checks: both /print and the legacy endpoint need auth for endpoint in ["/print"]: resp = await api_client_with_seed.post( endpoint, - json={"template_id": "t", "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}}, + json={ + "content_type": "qr_two_lines", + "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}, + }, ) assert resp.status_code == 401, f"Expected 401 on {endpoint}, got {resp.status_code}" diff --git a/backend/tests/integration/api/test_auth_wiring.py b/backend/tests/integration/api/test_auth_wiring.py index 5c132a3..6040942 100644 --- a/backend/tests/integration/api/test_auth_wiring.py +++ b/backend/tests/integration/api/test_auth_wiring.py @@ -3,19 +3,18 @@ Tests that each category of endpoint: 1. Returns 401 without any auth 2. Returns 200/204 with a valid auth header of the correct scope + +Phase 1k.1a (Task 25): Removed TemplateLoader and /api/templates tests. +Templates are deleted in Phase 1k.1a. """ from __future__ import annotations -from pathlib import Path - import app.models # noqa: F401 import bcrypt import pytest from app.models.api_key import ApiKey -_SEED_DIR = Path(__file__).parents[3] / "app" / "seed" / "templates" - async def _make_print_key(factory): """Insert an api-key with print scope and return (plaintext, ApiKey).""" @@ -72,34 +71,6 @@ async def _make_admin_key(factory): return plaintext -# -------------------------------------------------------------------------- -# Helper: build app client with DB patched -# -------------------------------------------------------------------------- - - -def _make_client_ctx(factory): - import app.db.session as _session_module - from app.main import create_app - - _session_module.async_session = factory - - from app.integrations import ( # type: ignore[attr-defined] - IntegrationRegistry, - _discover_plugins, - ) - - if not IntegrationRegistry.names(): - _discover_plugins() - - from app.services.template_loader import TemplateLoader - - original_cache = dict(TemplateLoader._cache) - TemplateLoader.load_dir(_SEED_DIR) - - app = create_app() - return app, original_cache, TemplateLoader - - @pytest.mark.asyncio async def test_get_printers_without_auth_returns_401(api_client_with_seed): resp = await api_client_with_seed.get("/api/printers") @@ -120,26 +91,6 @@ async def test_get_printers_with_read_key_returns_200(api_client_with_seed): assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}" -@pytest.mark.asyncio -async def test_get_templates_without_auth_returns_401(api_client_with_seed): - resp = await api_client_with_seed.get("/api/templates") - assert resp.status_code == 401, f"Expected 401, got {resp.status_code}" - - -@pytest.mark.asyncio -async def test_get_templates_with_read_key_returns_200(api_client_with_seed): - import app.db.engine as _engine_module - - factory = _engine_module.async_session - read_key = await _make_read_key(factory) - - resp = await api_client_with_seed.get( - "/api/templates", - headers={"X-Label-Hub-Key": read_key}, - ) - assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" - - @pytest.mark.asyncio async def test_get_jobs_without_auth_returns_401(api_client_with_seed): resp = await api_client_with_seed.get("/api/jobs") diff --git a/backend/tests/integration/api/test_readiness_endpoint.py b/backend/tests/integration/api/test_readiness_endpoint.py index b28464b..e5cf0b7 100644 --- a/backend/tests/integration/api/test_readiness_endpoint.py +++ b/backend/tests/integration/api/test_readiness_endpoint.py @@ -1,4 +1,8 @@ -"""Phase 7b Cluster 1e — /readiness deep-check endpoint.""" +"""Phase 7b Cluster 1e — /readiness deep-check endpoint. + +Phase 1k.1a (Task 25): template_seed removed from required checks +(template seeding removed in Phase 1k.1a). +""" from __future__ import annotations @@ -10,7 +14,6 @@ async def test_readiness_returns_200_when_ready(api_client_with_seed): resp = await api_client_with_seed.get("/readiness", headers={"X-Pangolin-User": "test"}) body = resp.json() - # template_seed will be ok (the fixture seeds), other critical checks ok → # printer_runtime may fail (no PT-P750W env) but that's non-critical, so degraded. # Both ready and degraded should be 200. assert resp.status_code == 200 @@ -19,14 +22,17 @@ async def test_readiness_returns_200_when_ready(api_client_with_seed): for required in ( "database", "alembic", - "template_seed", "printer_runtime", "printer_db_sync", - "snmp_discovery", "print_queue", + "snmp_discovery", "sse_bus", ): assert required in body["checks"], f"missing check: {required}" + # Phase 1k.1a: template_seed removed from readiness checks + assert "template_seed" not in body["checks"], ( + "template_seed should not appear in readiness checks after Phase 1k.1a" + ) async def test_readiness_returns_503_when_not_ready(api_client_with_broken_db): diff --git a/backend/tests/integration/api/test_templates_preview_api.py b/backend/tests/integration/api/test_templates_preview_api.py deleted file mode 100644 index 722680b..0000000 --- a/backend/tests/integration/api/test_templates_preview_api.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Phase 1i Sub-Task A+D — Integration tests for preview endpoints. - -A: GET /api/templates/{key}/preview-png -D: GET /api/templates/{key}/preview-svg -""" - -from __future__ import annotations - -import pytest -from httpx import AsyncClient - - -@pytest.mark.anyio -async def test_preview_png_returns_bitmap(api_client_with_seed: AsyncClient) -> None: - """GET /api/templates/{key}/preview.png returnt 200 image/png mit PNG-Magic.""" - response = await api_client_with_seed.get( - "/api/templates/hangar-furniture-12mm/preview-png", - params={ - "primary_id": "HH-AK-KX10-F0101", - "title": "Kallax 4x4 Fach S1 R1", - "qr_payload": "https://hangar.example/loc/HH-AK-KX10-F0101", - }, - ) - assert response.status_code == 200 - assert response.headers["content-type"] == "image/png" - assert response.content[:8] == b"\x89PNG\r\n\x1a\n" - - -@pytest.mark.anyio -async def test_preview_png_unknown_template_404(api_client_with_seed: AsyncClient) -> None: - response = await api_client_with_seed.get("/api/templates/nonexistent/preview-png") - assert response.status_code == 404 - - -@pytest.mark.anyio -async def test_preview_svg_returns_svg(api_client_with_seed: AsyncClient) -> None: - """GET /api/templates/{key}/preview-svg returnt 200 image/svg+xml.""" - response = await api_client_with_seed.get( - "/api/templates/hangar-furniture-12mm/preview-svg", - params={"primary_id": "X", "title": "Y", "qr_payload": "Z"}, - ) - assert response.status_code == 200 - assert response.headers["content-type"].startswith("image/svg+xml") - assert response.content.startswith(b" None: - """ETag-Caching: zweiter Request mit If-None-Match gibt 304 zurück.""" - r1 = await api_client_with_seed.get("/api/templates/hangar-furniture-12mm/preview-svg") - assert r1.status_code == 200 - assert "ETag" in r1.headers - r2 = await api_client_with_seed.get( - "/api/templates/hangar-furniture-12mm/preview-svg", - headers={"If-None-Match": r1.headers["ETag"]}, - ) - assert r2.status_code == 304 diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py index 4343501..52c786b 100644 --- a/backend/tests/integration/conftest.py +++ b/backend/tests/integration/conftest.py @@ -106,27 +106,24 @@ async def _noop_verify(*_args, **_kwargs) -> None: """ -async def _noop_seed_templates(*_args, **_kwargs) -> int: # type: ignore[no-untyped-def] - """Drop-in replacement for seed_templates() in integration test fixtures. - - The D1 defensive check raises RuntimeError when TemplateLoader._cache is - empty. Integration tests exercise the lifespan for other purposes (printer - startup, SSE, healthz) and do not need templates seeded. Patching this - no-op avoids a spurious failure until D2 fixes the load_dir ordering in - main.py lifespan. - """ - return 0 +async def _fail_alembic_check(*_args, **_kwargs) -> None: + """Simulates a DB that has not been alembic-upgraded (drift detected).""" + raise RuntimeError("Alembic migration drift detected: DB at None, expected head 'abc123'") @pytest_asyncio.fixture async def api_client_with_broken_db(tmp_path): - """AsyncClient whose DB has never been alembic-upgraded. + """AsyncClient whose alembic check always fails. - The alembic_version table is absent, so _check_alembic() returns fail - which makes build_readiness_response() return status=not-ready. - /readiness should therefore respond 503. + _check_alembic() calls verify_alembic_at_head() which is patched here to + raise RuntimeError (simulating a DB with no alembic_version row). The + autouse _temp_db_engine fixture patches verify_alembic_at_head to a noop, + so this fixture overrides that with a failure-raising version so that the + readiness endpoint returns status=not-ready → 503. /healthz MUST still respond 200 — it never touches the DB. + + Phase 1k.1a (Task 25): seed_templates patches removed (function deleted). """ import app.db.engine as _eng @@ -134,8 +131,6 @@ async def api_client_with_broken_db(tmp_path): from app.main import create_app from httpx import ASGITransport, AsyncClient - # Point at an empty SQLite file — create_all() gives it the schema - # tables but NOT the alembic_version row, so verify_alembic_at_head fails. db_path = tmp_path / "broken.db" url = f"sqlite+aiosqlite:///{db_path}" eng = create_async_engine(url, echo=False, connect_args={"check_same_thread": False}) @@ -144,11 +139,8 @@ async def api_client_with_broken_db(tmp_path): await conn.run_sync(SQLModel.metadata.create_all) sess = async_sessionmaker(bind=eng, expire_on_commit=False) - # Patch the session but do NOT patch verify_alembic_at_head — we want - # that check to fail so the readiness probe returns not-ready. _sess.async_session = sess - # Patch engine references so create_app() finds the right session. from unittest.mock import patch with ( @@ -156,14 +148,11 @@ async def api_client_with_broken_db(tmp_path): patch.object(_eng, "async_session", sess), patch.object(_main_module, "engine", eng), patch.object(_main_module, "async_session", sess), - # run_migrations uses alembic.ini URL — patch to no-op so lifespan - # doesn't crash before the readiness endpoint is called. patch.object(_lifespan_module, "run_migrations", _noop_migrations), patch.object(_main_module, "run_migrations", _noop_migrations), - # seed_templates needs at least one cached template; patch to no-op - # since we only test /readiness and /healthz here. - patch.object(_lifespan_module, "seed_templates", _noop_seed_templates), - patch.object(_main_module, "seed_templates", _noop_seed_templates), + # Override the autouse noop — we WANT verify_alembic_at_head to fail + # so that _check_alembic() returns CheckStatus("fail") → not-ready → 503. + patch.object(_lifespan_module, "verify_alembic_at_head", _fail_alembic_check), ): from app.integrations import ( # type: ignore[attr-defined] IntegrationRegistry, diff --git a/backend/tests/integration/db/test_alembic_phase7b_migration.py b/backend/tests/integration/db/test_alembic_phase7b_migration.py index c7ec435..6ca65ff 100644 --- a/backend/tests/integration/db/test_alembic_phase7b_migration.py +++ b/backend/tests/integration/db/test_alembic_phase7b_migration.py @@ -1,7 +1,9 @@ """Phase 7b — datetime data migration normalises naive rows to tz-aware UTC. -The migration must be idempotent: running it twice on the same row must NOT -result in `2026-05-17T12:00:00+00:00+00:00`. +Phase 1k.1a (Task 25): test_migration_adds_tz_to_naive_template_row and +test_migration_does_not_touch_already_tz_aware_rows removed — the templates +table is dropped at head by migration 20260605a1b2c3d4. +Only the idempotency test (upgrade to head twice) is retained. """ from __future__ import annotations @@ -10,7 +12,6 @@ from alembic import command from alembic.config import Config -from sqlalchemy import create_engine, text _ALEMBIC_INI = Path(__file__).parents[3] / "alembic.ini" @@ -29,88 +30,8 @@ def _alembic_config(db_path: Path) -> Config: return cfg -def test_migration_adds_tz_to_naive_template_row(tmp_path): - db = tmp_path / "phase7b_data.db" - sync_url = f"sqlite:///{db}" - cfg = _alembic_config(db) - - # Walk schema forward to head (gives us tables with the new column types). - command.upgrade(cfg, "head") - - # Roll back to the migration BEFORE Phase 7b so we can simulate a legacy - # DB with naive datetime rows, then upgrade forward and check the result. - # We target the explicit revision (b2668b6e8845 = before Phase 7b) instead - # of "-1" because later phases (7c+) added migrations on top of 7b. - command.downgrade(cfg, "b2668b6e8845") - - sync_engine = create_engine(sync_url) - with sync_engine.begin() as conn: - conn.execute( - text( - "INSERT INTO templates (id, key, name, app, printer_model, " - "tape_width_mm, schema_version, definition, source, " - "created_at, updated_at) " - "VALUES ('11111111-1111-1111-1111-111111111111', 'k', 'n', NULL, " - "'pt-series', 12, 1, '{}', 'seed', " - "'2026-05-17T12:00:00', '2026-05-17T12:00:00')" - ) - ) - - command.upgrade(cfg, "head") - - with sync_engine.begin() as conn: - row = conn.execute( - text( - "SELECT created_at, updated_at FROM templates " - "WHERE id = '11111111-1111-1111-1111-111111111111'" - ) - ).first() - assert row is not None - for value in row: - assert value.endswith("+00:00") or value.endswith("Z"), ( - f"datetime not normalised: {value!r}" - ) - - sync_engine.dispose() - - def test_migration_is_idempotent(tmp_path): db = tmp_path / "phase7b_idempotent.db" cfg = _alembic_config(db) command.upgrade(cfg, "head") command.upgrade(cfg, "head") # second run must be a no-op - - -def test_migration_does_not_touch_already_tz_aware_rows(tmp_path): - db = tmp_path / "phase7b_already_tz.db" - sync_url = f"sqlite:///{db}" - cfg = _alembic_config(db) - command.upgrade(cfg, "head") - # Roll back to before Phase 7b (explicit revision — see above). - command.downgrade(cfg, "b2668b6e8845") - - sync_engine = create_engine(sync_url) - with sync_engine.begin() as conn: - conn.execute( - text( - "INSERT INTO templates (id, key, name, app, printer_model, " - "tape_width_mm, schema_version, definition, source, " - "created_at, updated_at) " - "VALUES ('22222222-2222-2222-2222-222222222222', 'k2', 'n', NULL, " - "'pt-series', 12, 1, '{}', 'seed', " - "'2026-05-17T12:00:00+00:00', '2026-05-17T12:00:00+00:00')" - ) - ) - - command.upgrade(cfg, "head") - - with sync_engine.begin() as conn: - row = conn.execute( - text( - "SELECT created_at FROM templates WHERE id = '22222222-2222-2222-2222-222222222222'" - ) - ).first() - # Must not be '2026-05-17T12:00:00+00:00+00:00' - assert row[0].count("+00:00") == 1, f"double-suffix detected: {row[0]!r}" - - sync_engine.dispose() diff --git a/backend/tests/integration/test_batch_endpoint_auth.py b/backend/tests/integration/test_batch_endpoint_auth.py index 3accf6c..562a2ea 100644 --- a/backend/tests/integration/test_batch_endpoint_auth.py +++ b/backend/tests/integration/test_batch_endpoint_auth.py @@ -20,7 +20,7 @@ _BODY = { "items": [ { - "template_id": "hangar-furniture-24mm", + "content_type": "qr_two_lines", "data": {"title": "x", "primary_id": "x", "qr_payload": "q"}, } ] diff --git a/backend/tests/integration/test_batch_endpoint_happy.py b/backend/tests/integration/test_batch_endpoint_happy.py index 4a1ab6c..6b1ecec 100644 --- a/backend/tests/integration/test_batch_endpoint_happy.py +++ b/backend/tests/integration/test_batch_endpoint_happy.py @@ -71,11 +71,11 @@ async def test_batch_happy_path(batch_client, batch_db_session, batch_auth_heade printer_id = inner_app.state.printer_id printer_slug = inner_app.state.backend_router.slugs()[0] - # Mock backend defaults to 24mm loaded tape → use 24mm template to avoid mismatch + # Phase 1k.1a: content_type-based API replaces template_id body = { "items": [ { - "template_id": "hangar-furniture-24mm", + "content_type": "qr_two_lines", "data": { "title": f"Item {i}", "primary_id": f"HH-AK-KX10-F{i:04d}", @@ -94,4 +94,5 @@ async def test_batch_happy_path(batch_client, batch_db_session, batch_auth_heade assert "batch_id" in data assert data["printer_id"] == str(printer_id) assert len(data["job_ids"]) == 3 - assert data["errors"] == [] + # R2-3: errors field removed from BatchResponse + assert "errors" not in data diff --git a/backend/tests/integration/test_batch_endpoint_multi_label.py b/backend/tests/integration/test_batch_endpoint_multi_label.py index acb9b93..d639391 100644 --- a/backend/tests/integration/test_batch_endpoint_multi_label.py +++ b/backend/tests/integration/test_batch_endpoint_multi_label.py @@ -61,12 +61,12 @@ async def ml_batch_client(): 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.""" +def _four_item_body(content_type: str = "qr_two_lines") -> dict: + """Build a 4-item batch request body using the given content_type.""" return { "items": [ { - "template_id": template_id, + "content_type": content_type, "data": { "primary_id": f"ML-{i:04d}", "title": f"Multi-Label Test {i}", @@ -130,7 +130,8 @@ async def test_post_batch_4_items_calls_print_images_once(ml_batch_client): assert resp.status_code == 202, resp.text rb = resp.json() assert len(rb["job_ids"]) == 4 - assert rb["errors"] == [] + # R2-3: errors field removed from BatchResponse + assert "errors" not in rb # Wait for the worker to dequeue + call print_images deadline = asyncio.get_event_loop().time() + 5.0 @@ -187,7 +188,8 @@ async def test_post_batch_failure_marks_all_jobs_failed(ml_batch_client): assert resp.status_code == 202, resp.text rb = resp.json() assert len(rb["job_ids"]) == 4 - assert rb["errors"] == [] + # R2-3: errors field removed from BatchResponse + assert "errors" not in rb # Wait for the worker to attempt + fail deadline = asyncio.get_event_loop().time() + 5.0 diff --git a/backend/tests/integration/test_batch_endpoint_partial_failure.py b/backend/tests/integration/test_batch_endpoint_partial_failure.py deleted file mode 100644 index 6c17fa2..0000000 --- a/backend/tests/integration/test_batch_endpoint_partial_failure.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Partial: 3 Items, 1 mit unbekanntem template_id → 2 queued, 1 error.""" - -from __future__ import annotations - -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 - - -@pytest_asyncio.fixture -async def partial_client(): - """AsyncClient mit gefakter Auth + korrekt gepatchter DB-Session. - - Yields (client, inner_app) so tests can set inner_app.state.printer_id - to align with the single-printer-binding check in batch.py. - """ - 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 - - _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 - - -@pytest_asyncio.fixture -async def partial_db_session(): - """DB-Session gegen die per-test temp-Engine.""" - import app.db.engine as eng_mod - - async with eng_mod.async_session() as s: - yield s - - -@pytest.fixture -def partial_auth_headers() -> dict: - return {} - - -@pytest.mark.asyncio -async def test_batch_partial_failure(partial_client, partial_db_session, partial_auth_headers): - client, inner_app = partial_client - # Phase 1i H (Task 7b): Lifespan-Drucker verwenden statt manuell erstellten. - printer_slug = inner_app.state.backend_router.slugs()[0] - - # Mock backend loads 24mm → use 24mm for valid items, unknown ID for the failing one - body = { - "items": [ - { - "template_id": "hangar-furniture-24mm", - "data": {"title": "A", "primary_id": "A", "qr_payload": "qA"}, - }, - { - "template_id": "does-not-exist", - "data": {"title": "B", "primary_id": "B", "qr_payload": "qB"}, - }, - { - "template_id": "hangar-furniture-24mm", - "data": {"title": "C", "primary_id": "C", "qr_payload": "qC"}, - }, - ] - } - resp = await client.post( - f"/api/print/{printer_slug}/batch", json=body, headers=partial_auth_headers - ) - assert resp.status_code == 202, 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"] == "template_not_found" diff --git a/backend/tests/integration/test_batch_endpoint_printer_offline.py b/backend/tests/integration/test_batch_endpoint_printer_offline.py index 074e7f5..2658df6 100644 --- a/backend/tests/integration/test_batch_endpoint_printer_offline.py +++ b/backend/tests/integration/test_batch_endpoint_printer_offline.py @@ -80,7 +80,7 @@ async def _raise(self, requests, *, half_cut): body = { "items": [ { - "template_id": "hangar-furniture-24mm", + "content_type": "qr_two_lines", "data": {"title": "A", "primary_id": "A", "qr_payload": "q"}, } ] @@ -88,5 +88,7 @@ async def _raise(self, requests, *, half_cut): resp = await client.post( f"/api/print/{printer_slug}/batch", json=body, headers=offline_auth_headers ) - assert resp.status_code == 409, resp.text + # R2-2: PrinterOfflineError → 503 (network/server issue, retry later) + # was 409 before R2-2 fix; consistent with print.py behaviour. + assert resp.status_code == 503, resp.text assert resp.json()["detail"]["error_code"] == "printer_offline" diff --git a/backend/tests/integration/test_batch_endpoint_slug_check.py b/backend/tests/integration/test_batch_endpoint_slug_check.py index 7f9f7d2..76b4c0f 100644 --- a/backend/tests/integration/test_batch_endpoint_slug_check.py +++ b/backend/tests/integration/test_batch_endpoint_slug_check.py @@ -59,7 +59,7 @@ async def test_batch_route_rejects_mismatched_printer_slug( body = { "items": [ { - "template_id": "hangar-furniture-24mm", + "content_type": "qr_two_lines", "data": {"title": "A", "primary_id": "A", "qr_payload": "q"}, } ], @@ -80,7 +80,7 @@ async def test_batch_route_accepts_matching_printer_slug(slug_check_client, slug body = { "items": [ { - "template_id": "hangar-furniture-24mm", + "content_type": "qr_two_lines", "data": {"title": "A", "primary_id": "A", "qr_payload": "q"}, } ], @@ -100,7 +100,7 @@ async def test_batch_route_accepts_none_printer_slug(slug_check_client, slug_che body = { "items": [ { - "template_id": "hangar-furniture-24mm", + "content_type": "qr_two_lines", "data": {"title": "A", "primary_id": "A", "qr_payload": "q"}, } ], diff --git a/backend/tests/integration/test_batch_endpoint_tape_mismatch.py b/backend/tests/integration/test_batch_endpoint_tape_mismatch.py deleted file mode 100644 index ceac3db..0000000 --- a/backend/tests/integration/test_batch_endpoint_tape_mismatch.py +++ /dev/null @@ -1,144 +0,0 @@ -"""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 - -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 app.printer_backends.exceptions import TapeMismatchError -from app.services.print_service import PrintService -from httpx import ASGITransport, AsyncClient - - -@pytest_asyncio.fixture -async def tape_client(): - """AsyncClient mit gefakter Auth + korrekt gepatchter DB-Session. - - Yields (client, inner_app) so tests can set inner_app.state.printer_id - to align with the single-printer-binding check in batch.py. - """ - 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 - - _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 - - -@pytest_asyncio.fixture -async def tape_db_session(): - """DB-Session gegen die per-test temp-Engine.""" - import app.db.engine as eng_mod - - async with eng_mod.async_session() as s: - yield s - - -@pytest.fixture -def tape_auth_headers() -> dict: - return {} - - -@pytest.mark.asyncio -async def test_batch_tape_mismatch_per_item( - tape_client, - tape_db_session, - 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] - - # 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_batch_job", _raise_mismatch) - - body = { - "items": [ - { - "template_id": "hangar-furniture-12mm", - "data": {"title": "A", "primary_id": "A", "qr_payload": "q"}, - }, - { - "template_id": "hangar-furniture-12mm", - "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 - ) - # 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": "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 == 400, resp.text - data = resp.json() - assert data["detail"]["error_code"] == "mixed_tape_sizes" - assert set(data["detail"]["tape_mm_values"]) == {12, 24} diff --git a/backend/tests/integration/test_lifespan_multi_printer.py b/backend/tests/integration/test_lifespan_multi_printer.py index d5649f6..0f6b3a9 100644 --- a/backend/tests/integration/test_lifespan_multi_printer.py +++ b/backend/tests/integration/test_lifespan_multi_printer.py @@ -33,13 +33,12 @@ async def _noop_verify(*_args, **_kwargs) -> None: pass -async def _noop_seed_templates(*_args, **_kwargs) -> int: - return 0 - - @pytest.fixture() async def clean_db(monkeypatch: pytest.MonkeyPatch, tmp_path): - """Temp-DB + noop-migrations für Multi-Printer-Test.""" + """Temp-DB + noop-migrations für Multi-Printer-Test. + + Phase 1k.1a (Task 25): seed_templates patches removed (function deleted). + """ import app.db.session as _session_module db_path = tmp_path / "multi_printer_test.db" @@ -59,8 +58,6 @@ async def clean_db(monkeypatch: pytest.MonkeyPatch, tmp_path): monkeypatch.setattr(_main_module, "run_migrations", _noop_migrations) monkeypatch.setattr(_lifespan_module, "verify_alembic_at_head", _noop_verify) monkeypatch.setattr(_main_module, "verify_alembic_at_head", _noop_verify) - monkeypatch.setattr(_lifespan_module, "seed_templates", _noop_seed_templates) - monkeypatch.setattr(_main_module, "seed_templates", _noop_seed_templates) BackendRegistry._factories.clear() BackendRegistry._discovered = False diff --git a/backend/tests/integration/test_lifespan_seeds_and_upserts.py b/backend/tests/integration/test_lifespan_seeds_and_upserts.py index 8cf75a7..5226c2c 100644 --- a/backend/tests/integration/test_lifespan_seeds_and_upserts.py +++ b/backend/tests/integration/test_lifespan_seeds_and_upserts.py @@ -1,26 +1,31 @@ -"""Phase 1i CA-1 / Phase 7b Cluster 1a + 1b end-to-end test: a fresh DB after -lifespan startup contains the seed templates AND one deterministic-id printer, -and app.state.printer_id matches the DB printer.id.""" +"""Phase 1i CA-1 / Phase 7b Cluster 1b end-to-end test: a fresh DB after +lifespan startup contains one deterministic-id printer, and +app.state.printer_id matches the DB printer.id. + +Phase 1k.1a (Task 25): Template seeding removed (templates table dropped). +Test renamed from test_fresh_lifespan_seeds_templates_and_creates_printer +→ test_fresh_lifespan_creates_printer_with_deterministic_id. +Template assertions removed; printer assertions kept verbatim. +""" from __future__ import annotations import app.db.engine as _engine_module import pytest from app.models.printer import Printer -from app.models.template import Template from httpx import ASGITransport, AsyncClient from sqlmodel import select pytestmark = pytest.mark.asyncio -async def test_fresh_lifespan_seeds_templates_and_creates_printer( +async def test_fresh_lifespan_creates_printer_with_deterministic_id( _temp_db_engine, monkeypatch: pytest.MonkeyPatch, tmp_path, ) -> None: - """After lifespan startup, templates are seeded AND printer is upserted, - and app.state.printer_id matches the one Printer row in the DB. + """After lifespan startup, printer is upserted and app.state.printer_id + matches the one Printer row in the DB. Phase 1i CA-1/H (Task 7b): printers.yaml mit nicht-leerem Host statt Env-Vars, damit upsert_runtime_printers() eine echte Printer-Row anlegt. @@ -72,14 +77,8 @@ async def test_fresh_lifespan_seeds_templates_and_creates_printer( # Use the attribute on _engine_module (patched by _temp_db_engine fixture), # not the name bound at test-module import time. async with _engine_module.async_session() as s: - templates = list((await s.execute(select(Template))).scalars()) printers = list((await s.execute(select(Printer))).scalars()) - assert len(templates) >= 1, ( - f"Expected at least one seeded template, got {len(templates)}. " - "Check that TemplateLoader.load_dir() runs BEFORE seed_templates() " - "in the lifespan." - ) assert len(printers) == 1, ( f"Expected exactly one upserted Printer row, got {len(printers)}. " "Check that upsert_runtime_printers() is wired in the lifespan." diff --git a/backend/tests/integration/test_phase6b_sse_with_batch.py b/backend/tests/integration/test_phase6b_sse_with_batch.py index 5934d93..1ce1ffb 100644 --- a/backend/tests/integration/test_phase6b_sse_with_batch.py +++ b/backend/tests/integration/test_phase6b_sse_with_batch.py @@ -180,7 +180,7 @@ async def pump() -> None: body = { "items": [ { - "template_id": "hangar-furniture-24mm", + "content_type": "qr_two_lines", "data": { "title": f"T{i}", "primary_id": f"P{i}", diff --git a/backend/tests/integration/test_phase_1k1a_integration.py b/backend/tests/integration/test_phase_1k1a_integration.py new file mode 100644 index 0000000..8accff5 --- /dev/null +++ b/backend/tests/integration/test_phase_1k1a_integration.py @@ -0,0 +1,210 @@ +"""Phase 1k.1a integration tests — LayoutEngine + /api/render/preview endpoint. + +Task 25: End-to-end verification that the LayoutEngine-based pipeline +produces valid images and the /api/render/preview REST endpoint works correctly. + +Covered: +- POST /api/render/preview 200 → PNG bytes (valid image) +- POST /api/render/preview 409 → unsupported tape_mm +- POST /api/render/preview 422 → data missing required fields +- All 7 ContentTypes render without exception on 12mm, 18mm, 24mm tape +- LayoutEngine is stateless (reuse instance, same output) +""" + +from __future__ import annotations + +import io + +import pytest +from PIL import Image + +pytestmark = pytest.mark.asyncio + +# --------------------------------------------------------------------------- +# /api/render/preview — happy paths +# --------------------------------------------------------------------------- + +_VALID_BODY_QR_TWO_LINES = { + "content_type": "qr_two_lines", + "data": { + "primary_id": "K02", + "title": "Regal Küche", + "qr_payload": "https://example.com/loc/K02", + }, + "tape_mm": 24, +} + + +async def test_render_preview_returns_png(client): + """POST /api/render/preview → 200 with image/png content-type.""" + resp = await client.post( + "/api/render/preview", + json=_VALID_BODY_QR_TWO_LINES, + headers={"X-Pangolin-User": "test"}, + ) + assert resp.status_code == 200, resp.text + assert resp.headers["content-type"] == "image/png" + img = Image.open(io.BytesIO(resp.content)) + assert img.mode in ("1", "L", "RGB", "RGBA") + assert img.width > 0 + assert img.height > 0 + + +async def test_render_preview_12mm(client): + """12mm tape renders a valid PNG.""" + body = { + "content_type": "qr_one_line", + "data": { + "primary_id": "A01", + "qr_payload": "https://example.com/a", + }, + "tape_mm": 12, + } + resp = await client.post( + "/api/render/preview", + json=body, + headers={"X-Pangolin-User": "test"}, + ) + assert resp.status_code == 200, resp.text + img = Image.open(io.BytesIO(resp.content)) + assert img.width > 0 + + +async def test_render_preview_18mm(client): + """18mm tape renders a valid PNG.""" + body = { + "content_type": "qr_two_lines", + "data": { + "primary_id": "B02", + "title": "Test 18mm", + "qr_payload": "https://example.com/b", + }, + "tape_mm": 18, + } + resp = await client.post( + "/api/render/preview", + json=body, + headers={"X-Pangolin-User": "test"}, + ) + assert resp.status_code == 200, resp.text + img = Image.open(io.BytesIO(resp.content)) + assert img.width > 0 + + +# --------------------------------------------------------------------------- +# /api/render/preview — error cases +# --------------------------------------------------------------------------- + + +async def test_render_preview_unsupported_tape_returns_409(client): + """tape_mm=999 → 409 unsupported_tape.""" + body = { + "content_type": "qr_only", + "data": {"qr_payload": "https://example.com"}, + "tape_mm": 999, + } + resp = await client.post( + "/api/render/preview", + json=body, + headers={"X-Pangolin-User": "test"}, + ) + assert resp.status_code == 409, resp.text + assert resp.json()["error_code"] == "unsupported_tape" + + +async def test_render_preview_missing_required_field_returns_422(client): + """qr_two_lines without title → 422 data_mismatch.""" + body = { + "content_type": "qr_two_lines", + "data": { + "primary_id": "K02", + # title intentionally absent — required by qr_two_lines + "qr_payload": "https://example.com/loc/K02", + }, + "tape_mm": 24, + } + resp = await client.post( + "/api/render/preview", + json=body, + headers={"X-Pangolin-User": "test"}, + ) + assert resp.status_code == 422, resp.text + # R2-4: aligned with /print endpoint — was "data_mismatch" + assert resp.json()["error_code"] == "content_type_data_mismatch" + + +async def test_render_preview_qr_only_no_extra_fields(client): + """qr_only needs only qr_payload — no title/primary_id required.""" + body = { + "content_type": "qr_only", + "data": {"qr_payload": "https://example.com/minimal"}, + "tape_mm": 12, + } + resp = await client.post( + "/api/render/preview", + json=body, + headers={"X-Pangolin-User": "test"}, + ) + assert resp.status_code == 200, resp.text + assert resp.headers["content-type"] == "image/png" + + +# --------------------------------------------------------------------------- +# LayoutEngine unit-level: all 7 ContentTypes x tape widths +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "content_type,data_kwargs", + [ + ("qr_only", {"qr_payload": "https://example.com/x"}), + ("qr_one_line", {"qr_payload": "https://example.com/x", "primary_id": "A01"}), + ( + "qr_two_lines", + { + "qr_payload": "https://example.com/x", + "primary_id": "A01", + "title": "Workshop", + }, + ), + ( + "qr_three_lines", + { + "qr_payload": "https://example.com/x", + "primary_id": "A01", + "title": "Workshop", + "secondary": ["Zone 2"], + }, + ), + ("text_one_line", {"primary_id": "A01"}), + ("text_two_lines", {"primary_id": "A01", "title": "Workshop"}), + ( + "qr_with_listing", + { + "qr_payload": "https://example.com/x", + "primary_id": "shelf-1", + "items": [ + {"item": "A — Schrauben", "qr_payload": "https://example.com/i01"}, + {"item": "B — Muttern", "qr_payload": "https://example.com/i02"}, + ], + }, + ), + ], +) +@pytest.mark.parametrize("tape_mm", [12, 18, 24]) +async def test_all_content_types_via_preview_endpoint( + client, + content_type: str, + data_kwargs: dict, + tape_mm: int, +) -> None: + """All 7 ContentTypes x {12, 18, 24}mm produce a valid PNG via /api/render/preview.""" + body = {"content_type": content_type, "data": data_kwargs, "tape_mm": tape_mm} + resp = await client.post( + "/api/render/preview", + json=body, + headers={"X-Pangolin-User": "test"}, + ) + assert resp.status_code == 200, f"ContentType={content_type}, tape_mm={tape_mm}: {resp.text}" + img = Image.open(io.BytesIO(resp.content)) + assert img.width > 0, f"Empty image for {content_type} on {tape_mm}mm" diff --git a/backend/tests/integration/test_print_e2e.py b/backend/tests/integration/test_print_e2e.py index 56534f8..a25bd9b 100644 --- a/backend/tests/integration/test_print_e2e.py +++ b/backend/tests/integration/test_print_e2e.py @@ -1,4 +1,8 @@ -"""End-to-end integration tests for POST /print → GET /jobs/{id}.""" +"""End-to-end integration tests for POST /print → GET /jobs/{id}. + +Phase 1k.1a (Task 25): Adapted from template_id-based to content_type-based API. +test_template_not_found_synchronous_404 removed (TemplateNotFoundError gone). +""" from __future__ import annotations @@ -60,7 +64,7 @@ async def test_happy_path_raw_data() -> None: r = await c.post( "/print", json={ - "template_id": "qr-only-24mm", + "content_type": "qr_two_lines", "data": {"title": "Smoke", "primary_id": "S-1", "qr_payload": "https://e.x"}, }, ) @@ -72,24 +76,6 @@ async def test_happy_path_raw_data() -> None: assert body["error_code"] is None -async def test_template_not_found_synchronous_404() -> None: - """Unknown template_id → synchronous 404, no job record.""" - app = create_app() - _inner = app._app - for _dep in (require_read, require_print): - _inner.dependency_overrides[_dep] = lambda _c=_FAKE_AUTH: _c - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: - r = await c.post( - "/print", - json={ - "template_id": "does-not-exist", - "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}, - }, - ) - assert r.status_code == 404 - assert r.json()["error_code"] == "template_not_found" - - # Failure-mode tests — override the "mock" factory before lifespan starts @@ -107,41 +93,6 @@ def from_settings(cls, _settings): return _Patched -@pytest.fixture -def mismatched_mock_backend(monkeypatch): - BackendRegistry._factories.clear() - BackendRegistry._discovered = True - BackendRegistry.register("mock", _factory_with(loaded_tape_mm=12)) - # Phase 1i H (Task 7b): BackendRouter._build_one patchen statt _build_backend_from_config. - # _mock_backend_env (autouse) überschreibt _build_one auf MockPrinterBackend(). - # Hier setzen wir es auf die spezifische Factory mit loaded_tape_mm=12. - real_factory = _factory_with(loaded_tape_mm=12) - monkeypatch.setattr( - BackendRouter, "_build_one", staticmethod(lambda _cfg: real_factory.from_settings(None)) - ) - yield - - -async def test_tape_mismatch_synchronous_409(mismatched_mock_backend) -> None: - """Tape mismatch now triggers synchronous 409 via preflight (no job created).""" - app = create_app() - _inner = app._app - for _dep in (require_read, require_print): - _inner.dependency_overrides[_dep] = lambda _c=_FAKE_AUTH: _c - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: - r = await c.post( - "/print", - json={ - "template_id": "qr-only-24mm", - "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}, - }, - ) - assert r.status_code == 409 - body = r.json() - assert body["error_code"] == "tape_mismatch" - assert body["error_detail"] == {"expected_mm": 24, "loaded_mm": 12} - - @pytest.fixture def offline_mock_backend(monkeypatch): BackendRegistry._factories.clear() @@ -165,7 +116,7 @@ async def test_offline_synchronous_503(offline_mock_backend) -> None: r = await c.post( "/print", json={ - "template_id": "qr-only-24mm", + "content_type": "qr_two_lines", "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}, }, ) diff --git a/backend/tests/integration/test_print_queue_recovery.py b/backend/tests/integration/test_print_queue_recovery.py index 3a23aaf..bc8d2f0 100644 --- a/backend/tests/integration/test_print_queue_recovery.py +++ b/backend/tests/integration/test_print_queue_recovery.py @@ -6,6 +6,10 @@ 1. PRINTING-Jobs werden als FAILED_RESTART markiert 2. QUEUED-Jobs werden in FIFO-Reihenfolge in die asyncio.Queue re-enqueued +Phase 1k.1a (Task 25): Adapted from template_id/renderer/loader API to +content_type/LayoutEngine API. _SAMPLE_PAYLOAD updated to match new format. +test_recovery_skips_jobs_with_deleted_template removed (TemplateNotFoundError gone). + async_session_factory kommt aus tests/conftest.py (sichtbar für alle Tests). """ @@ -17,13 +21,14 @@ import pytest from app.models.job import Job, JobState from app.services.job_store_sqlite import SQLiteJobStore +from app.services.layout_engine import LayoutEngine from app.services.print_queue import PrintQueue from PIL import Image from sqlalchemy import update from sqlmodel import col -# Minimal-Payload der recovery-fähigen Jobs: label_data + tape_mm wie -# print_service.submit_print_job() es schreibt (Task 5). +# Minimal-Payload der recovery-fähigen Jobs: label_data + content_type + +# rendered_tape_mm wie print_service.submit_print_job() es schreibt (Phase 1k.1a). _SAMPLE_PAYLOAD = { "label_data": { "title": "Test", @@ -31,8 +36,18 @@ "qr_payload": "https://example.com", "source_app": "manual", "secondary": [], + "items": [], }, + "content_type": "qr_two_lines", + "rendered_tape_mm": 24, "tape_mm": 24, + "options": { + "copies": 1, + "auto_cut": True, + "high_resolution": False, + "half_cut": False, + "last_page": True, + }, } @@ -46,25 +61,6 @@ async def print_image(self, image, *, tape_mm, **options): pass -def _make_mock_renderer_and_loader() -> tuple[MagicMock, MagicMock]: - """Renderer + Loader-Mocks für Recovery-Tests. - - renderer.render() gibt ein minimales 1-bit-Image zurück. - loader.get() gibt ein Mock-Template mit tape_mm=24 zurück. - """ - mock_template = MagicMock() - mock_template.tape_mm = 24 - mock_template.elements = [] - - loader = MagicMock() - loader.get.return_value = mock_template - - renderer = MagicMock() - renderer.render.return_value = Image.new("1", (200, 106)) - - return renderer, loader - - @pytest.mark.asyncio async def test_start_marks_printing_as_failed_restart( async_session_factory, @@ -76,7 +72,7 @@ async def test_start_marks_printing_as_failed_restart( # Pre-seed: ein Job der in PRINTING-Zustand steckt (simuliert Absturz) interrupted_job = Job( printer_id=printer_id, - template_key="t", + template_key=None, payload={}, ) await store.save_queued(interrupted_job) @@ -114,18 +110,20 @@ async def test_start_reenqueues_queued_jobs_in_fifo_order( store = SQLiteJobStore(async_session_factory) printer_id = uuid4() - j1 = Job(printer_id=printer_id, template_key="t", payload=_SAMPLE_PAYLOAD) - j2 = Job(printer_id=printer_id, template_key="t", payload=_SAMPLE_PAYLOAD) + j1 = Job(printer_id=printer_id, template_key=None, payload=_SAMPLE_PAYLOAD) + j2 = Job(printer_id=printer_id, template_key=None, payload=_SAMPLE_PAYLOAD) await store.save_queued(j1) await store.save_queued(j2) - renderer, loader = _make_mock_renderer_and_loader() + # Phase 1k.1a: use engine mock instead of renderer + loader + engine_mock = MagicMock(spec=LayoutEngine) + engine_mock.render.return_value = Image.new("1", (200, 106)) + fake_printer = _FakePrinter(printer_id) queue = PrintQueue( printers=[fake_printer], store=store, - renderer=renderer, - loader=loader, + engine=engine_mock, ) await queue.start() @@ -162,29 +160,38 @@ async def test_recovery_skips_jobs_with_missing_label_data( printer_id = uuid4() # Job ohne label_data — simuliert alte Pre-Phase-2-Row oder korrupte Daten - bad_job = Job(printer_id=printer_id, template_key="t", payload={}) + bad_job = Job(printer_id=printer_id, template_key=None, payload={}) await store.save_queued(bad_job) # Gültiger Job der trotzdem verarbeitet werden soll - good_job = Job(printer_id=printer_id, template_key="t", payload=_SAMPLE_PAYLOAD) + good_job = Job(printer_id=printer_id, template_key=None, payload=_SAMPLE_PAYLOAD) await store.save_queued(good_job) - renderer, loader = _make_mock_renderer_and_loader() + # Phase 1k.1a: use engine mock instead of renderer + loader + engine_mock = MagicMock(spec=LayoutEngine) + engine_mock.render.return_value = Image.new("1", (200, 106)) + fake_printer = _FakePrinter(printer_id) queue = PrintQueue( printers=[fake_printer], store=store, - renderer=renderer, - loader=loader, + engine=engine_mock, ) await queue.start() - # bad_job muss als FAILED in der DB stehen + # bad_job muss als FAILED in der DB stehen. + # Phase 1k.1a Round-1 fix (MED-1): Jobs ohne content_type/label_data werden + # als Payload-Skip behandelt statt als allgemeiner Rerender-Fehler. + # R2-1: Prefix jetzt recovery_skip_legacy_payload (deckt alle 3 fehlenden Keys). + # Beide Pfade enden in JobState.FAILED — der Prefix unterscheidet die Ursache. fetched_bad = await store.get(bad_job.id) assert fetched_bad is not None assert fetched_bad.state == JobState.FAILED.value assert fetched_bad.error is not None - assert "recovery_rerender_failed" in fetched_bad.error + assert ( + "recovery_skip_legacy_payload" in fetched_bad.error + or "recovery_rerender_failed" in fetched_bad.error + ) # good_job muss in _jobs registriert worden sein (Recovery hat ihn enqueued). # Wir prüfen _jobs statt die asyncio.Queue, weil der Worker den Job bereits @@ -192,67 +199,3 @@ async def test_recovery_skips_jobs_with_missing_label_data( assert str(good_job.id) in queue._jobs await queue.stop() - - -@pytest.mark.asyncio -async def test_recovery_skips_jobs_with_deleted_template( - async_session_factory, -): - """I-2: Job mit nicht mehr existierendem Template darf Recovery nicht abbrechen. - - loader.get() wirft TemplateNotFoundError für den fehlerhaften Job. - Erwartet: - - Der fehlerhafte Job wird als FAILED markiert. - - Ein weiterer QUEUED-Job mit gültigem Template wird trotzdem re-enqueued. - """ - from app.services.template_loader import TemplateNotFoundError - - store = SQLiteJobStore(async_session_factory) - printer_id = uuid4() - - deleted_template_job = Job( - printer_id=printer_id, - template_key="nonexistent", - payload=_SAMPLE_PAYLOAD, - ) - await store.save_queued(deleted_template_job) - - good_job = Job(printer_id=printer_id, template_key="t", payload=_SAMPLE_PAYLOAD) - await store.save_queued(good_job) - - mock_template = MagicMock() - mock_template.tape_mm = 24 - mock_template.elements = [] - - loader = MagicMock() - - def _get_side_effect(key: str): - if key == "nonexistent": - raise TemplateNotFoundError(key) - return mock_template - - loader.get.side_effect = _get_side_effect - - renderer = MagicMock() - renderer.render.return_value = Image.new("1", (200, 106)) - - fake_printer = _FakePrinter(printer_id) - queue = PrintQueue( - printers=[fake_printer], - store=store, - renderer=renderer, - loader=loader, - ) - await queue.start() - - # deleted_template_job muss als FAILED in der DB stehen - fetched_deleted = await store.get(deleted_template_job.id) - assert fetched_deleted is not None - assert fetched_deleted.state == JobState.FAILED.value - assert fetched_deleted.error is not None - assert "recovery_rerender_failed" in fetched_deleted.error - - # good_job muss in _jobs registriert worden sein (Recovery hat ihn enqueued). - assert str(good_job.id) in queue._jobs - - await queue.stop() diff --git a/backend/tests/integration/test_print_service_persistence.py b/backend/tests/integration/test_print_service_persistence.py index f093c14..4b8afab 100644 --- a/backend/tests/integration/test_print_service_persistence.py +++ b/backend/tests/integration/test_print_service_persistence.py @@ -1,6 +1,7 @@ """PrintService muss Job-Row in DB anlegen BEVOR an PrintQueue übergeben wird. -Task 5 — Phase 2 Job Persistence. +Phase 1k.1a (Task 25): Adapted from template_id/renderer/template_loader API +to content_type/LayoutEngine API. Fixtures erstellen PrintService + SQLiteJobStore + PrintQueue mit echtem DB-Backend. async_session_factory kommt aus tests/conftest.py (sichtbar für alle Tests). @@ -15,9 +16,10 @@ import pytest_asyncio from app.models.job import JobState from app.printer_backends.snmp_helper import PreflightStatus -from app.schemas.label_data import LabelData +from app.schemas.content_type import ContentType from app.schemas.print_request import PrintRequest, RawLabelData from app.services.job_store_sqlite import SQLiteJobStore +from app.services.layout_engine import LayoutEngine from app.services.print_queue import PrintQueue from app.services.print_service import PrintService from PIL import Image @@ -66,9 +68,9 @@ def backend_mock(): @pytest_asyncio.fixture def sample_request(): - """Minimaler PrintRequest mit direktem LabelData.""" + """Minimaler PrintRequest mit ContentType und direktem LabelData.""" return PrintRequest( - template_id="test-label-24mm", + content_type=ContentType.QR_TWO_LINES, data=RawLabelData( title="Regal A-01", primary_id="SHF-001", @@ -81,38 +83,20 @@ def sample_request(): async def print_service(print_queue, sqlite_store, backend_mock): """PrintService mit SQLiteJobStore + submit_with_id-fähiger PrintQueue. - template_loader und renderer sind Mocks; die eigentliche Render-Logik + LayoutEngine ist ein Mock; die eigentliche Render-Logik wird nicht getestet — nur dass save_queued() VOR queue-Submit aufgerufen wird. """ queue_obj, printer_id = print_queue - template = MagicMock() - template.tape_mm = 24 - template.id = "test-label-24mm" - - loader = MagicMock() - loader.get.return_value = template - - renderer = MagicMock() - renderer.render.return_value = Image.new("1", (200, 128)) - - lookup_service = AsyncMock() - lookup_service.lookup.return_value = LabelData( - title="X", - primary_id="1", - qr_payload="u", - source_app="manual", - secondary=(), - ) + engine_mock = MagicMock(spec=LayoutEngine) + engine_mock.render.return_value = Image.new("1", (200, 128)) svc = PrintService( - template_loader=loader, - renderer=renderer, - print_queue=queue_obj, - lookup_service=lookup_service, printer_id=printer_id, backend=backend_mock, + queue=queue_obj, store=sqlite_store, + engine=engine_mock, ) return svc, sqlite_store, printer_id @@ -131,7 +115,7 @@ async def test_submit_persists_queued_job_before_queue( persisted = await sqlite_store.get(job_id) assert persisted is not None, "Job nicht in DB gefunden nach submit_print_job" assert persisted.state == JobState.QUEUED.value - assert persisted.template_key == sample_request.template_id + assert persisted.template_key is None # Phase 1k.1a: template_key immer None assert persisted.printer_id == _printer_id diff --git a/backend/tests/unit/api/test_error_handlers.py b/backend/tests/unit/api/test_error_handlers.py index 72fc679..60ff59f 100644 --- a/backend/tests/unit/api/test_error_handlers.py +++ b/backend/tests/unit/api/test_error_handlers.py @@ -4,7 +4,11 @@ exception, registers the global handlers via :func:`register_error_handlers`, then hits the route and asserts the RFC 7807 ProblemDetail shape + status code. -All six mapped exceptions are covered (one test per mapping in ``_MAPPING``). +All mapped exceptions are covered (one test per mapping in ``_MAPPING``). + +Phase 1k.1a (Task 25): TemplateNotFoundError and template_loader removed. +Tests for template_not_found removed; error_handlers._MAPPING no longer +contains a TemplateNotFoundError entry. """ from __future__ import annotations @@ -18,7 +22,6 @@ TapeMismatchError, ) from app.services.errors import AppLookupNotFoundError -from app.services.template_loader import TemplateNotFoundError from fastapi import FastAPI from fastapi.testclient import TestClient @@ -89,19 +92,6 @@ def test_printer_cover_open_returns_409_problem_detail() -> None: assert body["status"] == 409 -def test_template_not_found_returns_404_problem_detail() -> None: - client = TestClient( - _app_raising(TemplateNotFoundError("tpl-xyz")), - raise_server_exceptions=False, - ) - r = client.get("/boom") - assert r.status_code == 404 - body = r.json() - assert body["type"] == "template-not-found" - assert body["status"] == 404 - assert "tpl-xyz" in body["detail"] - - def test_app_lookup_not_found_returns_404_problem_detail() -> None: client = TestClient( _app_raising(AppLookupNotFoundError("asset 99 not found")), @@ -127,7 +117,6 @@ def test_app_lookup_not_found_returns_404_problem_detail() -> None: TapeMismatchError(expected_mm=6, loaded_mm=None), TapeEmptyError("empty"), PrinterCoverOpenError("open"), - TemplateNotFoundError("missing"), AppLookupNotFoundError("not found"), ], ) diff --git a/backend/tests/unit/api/test_print_routes.py b/backend/tests/unit/api/test_print_routes.py index d7d85f2..8756d00 100644 --- a/backend/tests/unit/api/test_print_routes.py +++ b/backend/tests/unit/api/test_print_routes.py @@ -1,3 +1,11 @@ +"""Unit tests for app.api.routes.print. + +Phase 1k.1a (Task 25): TemplateNotFoundError and template_loader removed. +Tests using old template_id-based API (POST /print with template_id) adapted to +content_type-based API. test_post_print_template_not_found_is_404 removed. +All other tests for jobs, resume, printer-resume kept verbatim. +""" + from __future__ import annotations from datetime import UTC, datetime @@ -13,7 +21,6 @@ from app.printer_backends.snmp_helper import LiveStatus from app.services.job_lifecycle import Job, JobState from app.services.lookup_service import LookupFailedError -from app.services.template_loader import TemplateNotFoundError from fastapi import FastAPI from httpx import ASGITransport, AsyncClient @@ -48,12 +55,17 @@ def _client(app): return AsyncClient(transport=ASGITransport(app=app), base_url="http://t") +# --------------------------------------------------------------------------- +# POST /print — content_type-based API (Phase 1k.1a) +# --------------------------------------------------------------------------- + + async def test_post_print_data_path_returns_202(fake_service, fake_queue) -> None: async with _client(_app(fake_service, fake_queue)) as c: r = await c.post( "/print", json={ - "template_id": "t", + "content_type": "qr_two_lines", "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}, }, ) @@ -67,7 +79,7 @@ async def test_post_print_lookup_path_returns_202(fake_service, fake_queue) -> N r = await c.post( "/print", json={ - "template_id": "t", + "content_type": "qr_two_lines", "lookup": {"app": "snipeit", "identifier": "42"}, }, ) @@ -76,31 +88,17 @@ async def test_post_print_lookup_path_returns_202(fake_service, fake_queue) -> N async def test_post_print_neither_source_is_422(fake_service, fake_queue) -> None: async with _client(_app(fake_service, fake_queue)) as c: - r = await c.post("/print", json={"template_id": "t"}) + r = await c.post("/print", json={"content_type": "qr_two_lines"}) assert r.status_code == 422 -async def test_post_print_template_not_found_is_404(fake_service, fake_queue) -> None: - fake_service.submit_print_job.side_effect = TemplateNotFoundError("missing") - async with _client(_app(fake_service, fake_queue)) as c: - r = await c.post( - "/print", - json={ - "template_id": "missing", - "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}, - }, - ) - assert r.status_code == 404 - assert r.json()["error_code"] == "template_not_found" - - async def test_post_print_lookup_failed_is_502(fake_service, fake_queue) -> None: fake_service.submit_print_job.side_effect = LookupFailedError("upstream down") async with _client(_app(fake_service, fake_queue)) as c: r = await c.post( "/print", json={ - "template_id": "t", + "content_type": "qr_two_lines", "lookup": {"app": "snipeit", "identifier": "x"}, }, ) @@ -186,7 +184,7 @@ async def test_post_print_tape_mismatch_fail_is_409_with_detail(fake_service, fa r = await c.post( "/print", json={ - "template_id": "t", + "content_type": "qr_two_lines", "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}, }, ) @@ -204,7 +202,7 @@ async def test_post_print_tape_mismatch_no_tape_loaded(fake_service, fake_queue) r = await c.post( "/print", json={ - "template_id": "t", + "content_type": "qr_two_lines", "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}, }, ) @@ -222,7 +220,7 @@ async def test_post_print_printer_offline_is_503(fake_service, fake_queue) -> No r = await c.post( "/print", json={ - "template_id": "t", + "content_type": "qr_two_lines", "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}, }, ) @@ -238,7 +236,7 @@ async def test_post_print_tape_empty_is_409(fake_service, fake_queue) -> None: r = await c.post( "/print", json={ - "template_id": "t", + "content_type": "qr_two_lines", "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}, }, ) @@ -254,7 +252,7 @@ async def test_post_print_cover_open_is_409(fake_service, fake_queue) -> None: r = await c.post( "/print", json={ - "template_id": "t", + "content_type": "qr_two_lines", "data": {"title": "X", "primary_id": "1", "qr_payload": "u"}, }, ) diff --git a/backend/tests/unit/api/test_templates_routes.py b/backend/tests/unit/api/test_templates_routes.py deleted file mode 100644 index df0fca2..0000000 --- a/backend/tests/unit/api/test_templates_routes.py +++ /dev/null @@ -1,351 +0,0 @@ -"""Unit tests for app.api.routes.templates — 1 endpoint, 2 scenarios (Phase 6a Task 2). - -Test mapping: - 1. GET /api/templates (unfiltered) → returns all templates - 2. GET /api/templates?app=snipeit → returns only templates with app='snipeit' -""" - -from __future__ import annotations - -from collections.abc import AsyncIterator -from uuid import uuid4 as _uuid4 - -import app.models # noqa: F401 — registers all SQLModel tables with metadata -import pytest -import pytest_asyncio -from app.api.routes.templates import render_router, router -from app.auth.dependencies import AuthContext -from app.auth.scope_deps import require_read -from app.db.engine import _apply_pragmas -from app.db.session import get_session -from app.models.template import Template -from fastapi import FastAPI -from fastapi.testclient import TestClient -from sqlalchemy import event -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from sqlmodel import SQLModel - -# --------------------------------------------------------------------------- -# In-memory DB fixtures (same pattern as test_printers_routes.py) -# --------------------------------------------------------------------------- - - -def _make_engine(): - eng = create_async_engine("sqlite+aiosqlite:///:memory:") - event.listen(eng.sync_engine, "connect", _apply_pragmas) - return eng - - -@pytest_asyncio.fixture -async def engine(): - eng = _make_engine() - async with eng.begin() as conn: - await conn.run_sync(SQLModel.metadata.create_all) - yield eng - await eng.dispose() - - -@pytest_asyncio.fixture -async def session(engine): - factory = async_sessionmaker(engine, expire_on_commit=False) - async with factory() as s: - yield s - - -# --------------------------------------------------------------------------- -# App factory with DB override -# --------------------------------------------------------------------------- - - -def _build_app(session_override: AsyncSession) -> FastAPI: - """Return a FastAPI app with the templates router and the DB overridden.""" - app = FastAPI() - app.include_router(router) - app.include_router(render_router) - - async def _override_session() -> AsyncIterator[AsyncSession]: - yield session_override - - _fake_auth_ctx = AuthContext( - source="api-key", scope="admin", api_key_id=_uuid4(), ip="127.0.0.1" - ) - app.dependency_overrides[require_read] = lambda _c=_fake_auth_ctx: _c - app.dependency_overrides[get_session] = _override_session - return app - - -# --------------------------------------------------------------------------- -# DB helpers -# --------------------------------------------------------------------------- - - -async def _make_template( - session: AsyncSession, - key: str, - name: str, - app_name: str | None = None, - source: str = "seed", - preview_sample: dict[str, object] | None = None, -) -> Template: - definition: dict[str, object] = {"elements": []} - if preview_sample is not None: - definition["preview_sample"] = preview_sample - tpl = Template( - key=key, - name=name, - app=app_name, - printer_model="PT-P750W", - tape_width_mm=12, - source=source, - definition=definition, - ) - session.add(tpl) - await session.commit() - await session.refresh(tpl) - return tpl - - -# --------------------------------------------------------------------------- -# Test 1: GET /api/templates — unfiltered, returns all templates -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_list_templates_unfiltered_returns_all(session) -> None: - """list_templates without ?app= returns every template in the DB.""" - await _make_template(session, "snipeit/asset", "Asset Label", app_name="snipeit") - await _make_template(session, "grocy/product", "Product Label", app_name="grocy") - await _make_template(session, "generic/qr", "Generic QR", app_name=None) - - app = _build_app(session) - client = TestClient(app, raise_server_exceptions=True) - r = client.get("/api/templates") - - assert r.status_code == 200 - body = r.json() - assert isinstance(body, list) - assert len(body) == 3 - keys = {item["key"] for item in body} - assert keys == {"snipeit/asset", "grocy/product", "generic/qr"} - # Spot-check required fields - first = next(item for item in body if item["key"] == "snipeit/asset") - assert first["name"] == "Asset Label" - assert first["app"] == "snipeit" - assert first["tape_width_mm"] == 12 - assert "id" in first - assert "created_at" in first - assert "updated_at" in first - - -# --------------------------------------------------------------------------- -# Test 2: GET /api/templates?app=snipeit — filtered by app -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_list_templates_filtered_by_app_returns_only_matching(session) -> None: - """list_templates with ?app=snipeit returns only templates whose app='snipeit'.""" - await _make_template(session, "snipeit/asset", "Asset Label", app_name="snipeit") - await _make_template(session, "snipeit/location", "Location Label", app_name="snipeit") - await _make_template(session, "grocy/product", "Product Label", app_name="grocy") - - app = _build_app(session) - client = TestClient(app, raise_server_exceptions=True) - r = client.get("/api/templates?app=snipeit") - - assert r.status_code == 200 - body = r.json() - assert isinstance(body, list) - assert len(body) == 2 - for item in body: - assert item["app"] == "snipeit" - keys = {item["key"] for item in body} - assert keys == {"snipeit/asset", "snipeit/location"} - - -# --------------------------------------------------------------------------- -# Direct async tests — bypass TestClient thread to capture coverage correctly -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_list_templates_direct_no_filter(session) -> None: - """list_templates called directly (no ?app=) returns all templates. - - Directly exercises lines 51-54 of templates.py in the pytest async loop - where coverage.py instruments correctly (bypasses TestClient threading). - """ - from app.api.routes.templates import list_templates - - await _make_template(session, "snipeit/asset", "Asset Label", app_name="snipeit") - await _make_template(session, "grocy/product", "Product Label", app_name="grocy") - - result = await list_templates(session=session, app=None, _auth=None) - - assert len(result) == 2 - keys = {r.key for r in result} - assert keys == {"snipeit/asset", "grocy/product"} - - -@pytest.mark.asyncio -async def test_list_templates_direct_with_app_filter(session) -> None: - """list_templates called directly with app='snipeit' returns filtered list. - - Exercises lines 52-53 (the ``if app is not None:`` True branch) of - templates.py in the pytest async loop. - """ - from app.api.routes.templates import list_templates - - await _make_template(session, "snipeit/asset", "Asset Label", app_name="snipeit") - await _make_template(session, "grocy/product", "Product Label", app_name="grocy") - - result = await list_templates(session=session, app="snipeit", _auth=None) - - assert len(result) == 1 - assert result[0].key == "snipeit/asset" - assert result[0].app == "snipeit" - - -@pytest.mark.asyncio -async def test_template_preview_returns_png(session) -> None: - """POST /api/render/preview?key= renders a sample label as PNG bytes. - - Regression for Bug 3 — the backend had no preview endpoint. - The frontend template detail page fell back to preview-placeholder.svg - because POST /api/render/preview always returned 404. - """ - await _make_template( - session, - "snipeit/asset", - "Asset Label", - app_name="snipeit", - preview_sample={ - "primary_id": "ASSET-2024-001", - "title": "Dell Latitude 7430", - "qr_payload": "https://snipeit.example.com/hardware/123", - }, - ) - - app = _build_app(session) - client = TestClient(app, raise_server_exceptions=True) - r = client.post("/api/render/preview?key=snipeit%2Fasset") - - assert r.status_code == 200 - assert r.headers["content-type"] == "image/png" - assert r.content[:8] == b"\x89PNG\r\n\x1a\n" # PNG magic number - - -@pytest.mark.asyncio -async def test_template_preview_unknown_key_returns_404(session) -> None: - """POST /api/render/preview?key= returns 404 for a missing template.""" - app = _build_app(session) - client = TestClient(app, raise_server_exceptions=True) - r = client.post("/api/render/preview?key=no-such-key") - - assert r.status_code == 404 - - -@pytest.mark.asyncio -async def test_template_preview_uses_preview_sample_from_definition(session) -> None: - """The preview endpoint reads sample values from template.definition.preview_sample. - - Regression for Commit 4 refactor — sample data must live in the template - definition, not be hardcoded per-app in the route. A template without - preview_sample must return 422; a template WITH preview_sample renders. - """ - await _make_template( - session, - "custom/key", - "Custom Template", - app_name=None, # no integration app — only works because preview_sample is on the template - preview_sample={ - "primary_id": "CUSTOM-1", - "title": "User-defined preview", - "qr_payload": "https://example.com/custom/1", - }, - ) - - app = _build_app(session) - client = TestClient(app, raise_server_exceptions=True) - r = client.post("/api/render/preview?key=custom%2Fkey") - - assert r.status_code == 200 - assert r.headers["content-type"] == "image/png" - assert r.content[:8] == b"\x89PNG\r\n\x1a\n" - - -@pytest.mark.asyncio -async def test_template_preview_renders_seed_template_via_loader_pipeline(session) -> None: - """End-to-end: a real seed YAML survives the TemplateLoader → seed_db pipeline - with its preview_sample intact, and the preview endpoint renders it. - - This guards against silent loss of preview_sample if a future refactor - breaks the schema_dump → DB → schema_construct round-trip. - """ - from pathlib import Path - - from app.integrations import _discover_plugins - from app.integrations.registry import IntegrationRegistry - from app.services.template_loader import TemplateLoader - - # IntegrationRegistry is a class-level singleton that other tests may have - # cleared. The seed-template loader validates `app` against the registry, - # so re-discover plugins here to make the test hermetic regardless of - # test ordering in the full suite. - if not IntegrationRegistry.names(): - _discover_plugins() - - seed_dir = Path(__file__).resolve().parents[3] / "app" / "seed" / "templates" - # The loader caches at the class level — clear first so the test is hermetic. - TemplateLoader._cache.clear() - TemplateLoader.load_dir(seed_dir) - await TemplateLoader.seed_db(session) - - app = _build_app(session) - client = TestClient(app, raise_server_exceptions=True) - r = client.post("/api/render/preview?key=snipeit-12mm") - - assert r.status_code == 200, r.text - assert r.headers["content-type"] == "image/png" - assert r.content[:8] == b"\x89PNG\r\n\x1a\n" - - -@pytest.mark.asyncio -async def test_template_preview_fails_when_template_lacks_preview_sample(session) -> None: - """Templates without preview_sample return 422 with a clear error message. - - The previous implementation guessed sample data per-app — wrong responsibility - locality. Templates must declare their own preview values; the route no - longer fabricates fallbacks. - """ - await _make_template( - session, - "incomplete/template", - "No Preview Sample", - app_name="snipeit", - preview_sample=None, # explicit: definition has no preview_sample block - ) - - app = _build_app(session) - client = TestClient(app, raise_server_exceptions=True) - r = client.post("/api/render/preview?key=incomplete%2Ftemplate") - - assert r.status_code == 422 - detail = r.json()["detail"] - assert "preview_sample" in detail - assert "incomplete/template" in detail - - -@pytest.mark.asyncio -async def test_list_templates_direct_filter_no_match_returns_empty(session) -> None: - """list_templates with ?app= that matches nothing returns an empty list. - - Exercises line 53 (filter comprehension with no matches) in the async loop. - """ - from app.api.routes.templates import list_templates - - await _make_template(session, "snipeit/asset", "Asset Label", app_name="snipeit") - - result = await list_templates(session=session, app="spoolman", _auth=None) - - assert result == [] diff --git a/backend/tests/unit/models/test_datetime_columns.py b/backend/tests/unit/models/test_datetime_columns.py index a088dd3..062151f 100644 --- a/backend/tests/unit/models/test_datetime_columns.py +++ b/backend/tests/unit/models/test_datetime_columns.py @@ -1,4 +1,8 @@ -"""Phase 7b Cluster 1c — every datetime column must be timezone-aware.""" +"""Phase 7b Cluster 1c — every datetime column must be timezone-aware. + +Phase 1k.1a (Task 25): Template model removed — Template entry dropped from +parametrize list. +""" import pytest from app.models.job import Job @@ -6,14 +10,12 @@ from app.models.printer import Printer from app.models.printer_state import PrinterState from app.models.printer_status_cache import PrinterStatusCache -from app.models.template import Template from sqlalchemy import DateTime @pytest.mark.parametrize( "model,columns", [ - (Template, ["created_at", "updated_at"]), (Printer, ["created_at", "updated_at"]), (Job, ["created_at", "updated_at", "started_at", "finished_at"]), (Preset, ["created_at", "updated_at"]), diff --git a/backend/tests/unit/printer_backends/test_exceptions.py b/backend/tests/unit/printer_backends/test_exceptions.py index 0aa4aca..a9426e4 100644 --- a/backend/tests/unit/printer_backends/test_exceptions.py +++ b/backend/tests/unit/printer_backends/test_exceptions.py @@ -50,3 +50,35 @@ def test_str_mentions_both_values(self) -> None: err = TapeMismatchError(expected_mm=18, loaded_mm=12) s = str(err) assert "18" in s and "12" in s + + +from app.printer_backends.exceptions import ( # noqa: E402 + ContentTypeDataMismatchError, + NoTapeLoadedError, + UnsupportedTapeError, +) + + +class TestUnsupportedTapeError: + def test_carries_tape_mm(self) -> None: + exc = UnsupportedTapeError(tape_mm=36) + assert exc.tape_mm == 36 + assert "36" in str(exc) + assert "supported" in str(exc).lower() + + +class TestNoTapeLoadedError: + def test_message_default(self) -> None: + exc = NoTapeLoadedError() + assert "no tape" in str(exc).lower() + + +class TestContentTypeDataMismatchError: + def test_carries_content_type_and_missing(self) -> None: + exc = ContentTypeDataMismatchError( + content_type="qr_two_lines", + missing_fields=("primary_id", "title"), + ) + assert exc.content_type == "qr_two_lines" + assert exc.missing_fields == ("primary_id", "title") + assert "primary_id" in str(exc) and "title" in str(exc) diff --git a/backend/tests/unit/schemas/test_content_type.py b/backend/tests/unit/schemas/test_content_type.py new file mode 100644 index 0000000..ecc2d1e --- /dev/null +++ b/backend/tests/unit/schemas/test_content_type.py @@ -0,0 +1,22 @@ +"""Unit tests for ContentType enum.""" + +from __future__ import annotations + +from app.schemas.content_type import ContentType + + +class TestContentType: + def test_all_seven_values_defined(self) -> None: + assert {c.value for c in ContentType} == { + "qr_only", + "qr_one_line", + "qr_two_lines", + "qr_three_lines", + "text_one_line", + "text_two_lines", + "qr_with_listing", + } + + def test_string_value_round_trip(self) -> None: + assert ContentType("qr_two_lines") == ContentType.QR_TWO_LINES + assert ContentType.QR_TWO_LINES.value == "qr_two_lines" diff --git a/backend/tests/unit/schemas/test_label_data.py b/backend/tests/unit/schemas/test_label_data.py index c23d407..24f2fad 100644 --- a/backend/tests/unit/schemas/test_label_data.py +++ b/backend/tests/unit/schemas/test_label_data.py @@ -1,9 +1,13 @@ import pytest from app.schemas.label_data import LabelData -from pydantic_core import ValidationError +from app.schemas.label_data_item import ( + LabelDataItem, +) +from pydantic import ValidationError -def test_label_data_minimal() -> None: +def test_label_data_all_fields() -> None: + """Construction with all fields must succeed and fields must be accessible.""" data = LabelData( title="MacBook Pro 16", primary_id="ASSET-12345", @@ -19,9 +23,6 @@ def test_label_data_minimal() -> None: def test_label_data_with_secondary_fields() -> None: data = LabelData( - title="BambuLab PLA", - primary_id="#42", - qr_payload="https://spoolman.example/spool/42", source_app="spoolman", secondary=["Color: Black", "Weight: 850g"], ) @@ -33,12 +34,7 @@ def test_label_data_with_secondary_fields() -> None: def test_label_data_is_frozen() -> None: """LabelData is an immutable value object — mutating fields after construction must fail.""" - data = LabelData( - title="t", - primary_id="p", - qr_payload="q", - source_app="snipeit", - ) + data = LabelData(source_app="snipeit") with pytest.raises(ValidationError, match="frozen_instance"): data.title = "different" # type: ignore[misc] @@ -46,11 +42,39 @@ def test_label_data_is_frozen() -> None: def test_label_data_secondary_is_immutable() -> None: """A tuple field cannot be mutated in-place — append must raise AttributeError.""" data = LabelData( - title="t", - primary_id="p", - qr_payload="q", source_app="snipeit", secondary=["a"], ) with pytest.raises(AttributeError): data.secondary.append("b") # type: ignore[attr-defined] + + +class TestLabelDataOptionalFields: + def test_only_source_app_required(self) -> None: + data = LabelData(source_app="manual") + assert data.title is None + assert data.primary_id is None + assert data.qr_payload is None + assert data.secondary == () + assert data.items == () + + def test_all_fields_set(self) -> None: + data = LabelData( + source_app="hangar", + primary_id="K-02", + title="Werkstatt", + qr_payload="https://example.com/locations/k-02", + secondary=("Notiz 1",), + items=(LabelDataItem(item="A"), LabelDataItem(item="B")), + ) + assert data.items[0].item == "A" + assert len(data.items) == 2 + + def test_frozen(self) -> None: + data = LabelData(source_app="manual") + with pytest.raises(ValidationError, match="frozen_instance"): + data.title = "x" # type: ignore[misc] + + def test_source_app_required(self) -> None: + with pytest.raises(ValidationError, match="source_app"): + LabelData() # type: ignore[call-arg] diff --git a/backend/tests/unit/schemas/test_label_data_item.py b/backend/tests/unit/schemas/test_label_data_item.py new file mode 100644 index 0000000..0aaf9cc --- /dev/null +++ b/backend/tests/unit/schemas/test_label_data_item.py @@ -0,0 +1,27 @@ +"""Unit tests for LabelDataItem (qr_with_listing child).""" + +from __future__ import annotations + +import pytest +from app.schemas.label_data_item import LabelDataItem +from pydantic import ValidationError + + +class TestLabelDataItem: + def test_minimal_item(self) -> None: + item = LabelDataItem(item="A — Schrauben") + assert item.item == "A — Schrauben" + assert item.qr_payload is None + + def test_with_qr_payload(self) -> None: + item = LabelDataItem(item="B", qr_payload="https://example.com/locations/k02/b") + assert item.qr_payload == "https://example.com/locations/k02/b" + + def test_item_required(self) -> None: + with pytest.raises(ValidationError, match="item"): + LabelDataItem() # type: ignore[call-arg] + + def test_frozen(self) -> None: + item = LabelDataItem(item="A") + with pytest.raises(ValidationError, match="frozen_instance"): + item.item = "B" # type: ignore[misc] diff --git a/backend/tests/unit/schemas/test_print_request.py b/backend/tests/unit/schemas/test_print_request.py index 0399e0e..e67ffcd 100644 --- a/backend/tests/unit/schemas/test_print_request.py +++ b/backend/tests/unit/schemas/test_print_request.py @@ -1,94 +1,79 @@ +"""Unit tests for PrintRequest with content_type (no template_id).""" + from __future__ import annotations import pytest +from app.schemas.content_type import ContentType from app.schemas.print_request import ( PrintLookupRequest, - PrintOptions, PrintRequest, RawLabelData, ) from pydantic import ValidationError -def test_print_options_defaults_independent() -> None: - a = PrintRequest(template_id="t", data=RawLabelData(title="x", primary_id="1", qr_payload="u")) - b = PrintRequest(template_id="t", data=RawLabelData(title="x", primary_id="1", qr_payload="u")) - assert a.options is not b.options - - -def test_print_options_immutable() -> None: - opts = PrintOptions() - with pytest.raises(ValidationError): - opts.copies = 5 - - -def test_lookup_xor_data_rejects_both() -> None: - with pytest.raises(ValidationError, match="Exactly one"): - PrintRequest( - template_id="t", - lookup=PrintLookupRequest(app="snipeit", identifier="123"), - data=RawLabelData(title="x", primary_id="1", qr_payload="u"), +class TestRawLabelData: + def test_all_fields_optional(self) -> None: + raw = RawLabelData() + assert raw.title is None + assert raw.primary_id is None + assert raw.qr_payload is None + assert raw.secondary == () + assert raw.items == () + + def test_extra_field_rejected(self) -> None: + with pytest.raises(ValidationError, match="extra"): + RawLabelData(unknown_field="x") # type: ignore[call-arg] + + +class TestPrintRequest: + def test_with_content_type_and_raw_data(self) -> None: + req = PrintRequest( + content_type=ContentType.QR_TWO_LINES, + data=RawLabelData( + primary_id="K-02", + title="Werkstatt", + qr_payload="https://example.com/x", + ), ) - - -def test_lookup_xor_data_rejects_neither() -> None: - with pytest.raises(ValidationError, match="Exactly one"): - PrintRequest(template_id="t") - - -def test_lookup_only_accepted() -> None: - r = PrintRequest(template_id="t", lookup=PrintLookupRequest(app="snipeit", identifier="123")) - assert r.lookup is not None - assert r.data is None - - -def test_data_only_accepted() -> None: - r = PrintRequest( - template_id="t", - data=RawLabelData(title="x", primary_id="1", qr_payload="u", secondary=["a", "b"]), - ) - assert r.data is not None - assert r.lookup is None - assert r.data.secondary == ["a", "b"] - - -def test_raw_label_data_default_secondary_empty() -> None: - d = RawLabelData(title="x", primary_id="1", qr_payload="u") - assert d.secondary == [] - - -def test_raw_label_data_rejects_source_app_field() -> None: - with pytest.raises(ValidationError): - RawLabelData(title="x", primary_id="1", qr_payload="u", source_app="manual") - - -def test_copies_bounds() -> None: - PrintOptions(copies=1) - PrintOptions(copies=10) - with pytest.raises(ValidationError): - PrintOptions(copies=0) - with pytest.raises(ValidationError): - PrintOptions(copies=11) - - -def test_on_tape_mismatch_defaults_to_fail() -> None: - r = PrintRequest(template_id="t", data=RawLabelData(title="x", primary_id="1", qr_payload="u")) - assert r.on_tape_mismatch == "fail" - - -def test_on_tape_mismatch_accepts_queue() -> None: - r = PrintRequest( - template_id="t", - data=RawLabelData(title="x", primary_id="1", qr_payload="u"), - on_tape_mismatch="queue", - ) - assert r.on_tape_mismatch == "queue" - - -def test_on_tape_mismatch_rejects_unknown_value() -> None: - with pytest.raises(ValidationError): - PrintRequest( - template_id="t", - data=RawLabelData(title="x", primary_id="1", qr_payload="u"), - on_tape_mismatch="abort", # not in the Literal set + assert req.content_type == ContentType.QR_TWO_LINES + assert req.data is not None + assert req.data.primary_id == "K-02" + assert req.lookup is None + + def test_with_content_type_and_lookup(self) -> None: + req = PrintRequest( + content_type=ContentType.QR_ONLY, + lookup=PrintLookupRequest(app="snipeit", identifier="ABC-123"), ) + assert req.lookup is not None + assert req.lookup.app == "snipeit" + assert req.data is None + + def test_both_data_and_lookup_rejected(self) -> None: + with pytest.raises(ValidationError, match="Exactly one"): + PrintRequest( + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="x"), + lookup=PrintLookupRequest(app="snipeit", identifier="X"), + ) + + def test_neither_data_nor_lookup_rejected(self) -> None: + with pytest.raises(ValidationError, match="Exactly one"): + PrintRequest(content_type=ContentType.QR_ONLY) + + def test_no_template_id_field(self) -> None: + with pytest.raises(ValidationError, match="extra"): + PrintRequest( + template_id="anything", + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="x"), + ) # type: ignore[call-arg] + + def test_no_on_tape_mismatch_field(self) -> None: + with pytest.raises(ValidationError, match="extra"): + PrintRequest( + on_tape_mismatch="queue", + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="x"), + ) # type: ignore[call-arg] diff --git a/backend/tests/unit/schemas/test_tape_geometry.py b/backend/tests/unit/schemas/test_tape_geometry.py new file mode 100644 index 0000000..ac07df7 --- /dev/null +++ b/backend/tests/unit/schemas/test_tape_geometry.py @@ -0,0 +1,97 @@ +"""Unit tests for TapeGeometry model and TAPE_GEOMETRY constants table.""" + +from __future__ import annotations + +import pytest +from app.schemas.tape_geometry import TAPE_GEOMETRY, TapeGeometry +from pydantic import ValidationError + + +class TestTapeGeometryModel: + def test_valid_values_accepted(self) -> None: + geom = TapeGeometry( + printable_px=70, + qr_max_px=66, + qr_padding_px=2, + text_start_x=72, + line_spacing_px=4, + font_xl=22, + font_l=18, + font_m=14, + font_s=10, + ) + assert geom.printable_px == 70 + assert geom.qr_max_px == 66 + assert geom.font_xl == 22 + + def test_zero_printable_px_rejected(self) -> None: + with pytest.raises(ValueError, match="greater than 0"): + TapeGeometry( + printable_px=0, + qr_max_px=66, + qr_padding_px=2, + text_start_x=72, + line_spacing_px=4, + font_xl=22, + font_l=18, + font_m=14, + font_s=10, + ) + + def test_negative_qr_padding_rejected(self) -> None: + with pytest.raises(ValueError, match="greater than or equal to 0"): + TapeGeometry( + printable_px=70, + qr_max_px=66, + qr_padding_px=-1, + text_start_x=72, + line_spacing_px=4, + font_xl=22, + font_l=18, + font_m=14, + font_s=10, + ) + + def test_frozen_immutable(self) -> None: + geom = TapeGeometry( + printable_px=70, + qr_max_px=66, + qr_padding_px=2, + text_start_x=72, + line_spacing_px=4, + font_xl=22, + font_l=18, + font_m=14, + font_s=10, + ) + with pytest.raises(ValidationError, match="frozen_instance"): + geom.printable_px = 100 # type: ignore[misc] + + +class TestTapeGeometryConstants: + def test_all_seven_sizes_defined(self) -> None: + assert set(TAPE_GEOMETRY.keys()) == {4, 6, 9, 12, 18, 24, 62} + + def test_12mm_v4_winner_values(self) -> None: + geom = TAPE_GEOMETRY[12] + assert geom.printable_px == 70 + assert geom.qr_max_px == 66 + assert geom.text_start_x == 72 + assert geom.font_xl == 22 + assert geom.font_l == 18 + + def test_qr_max_px_follows_formula(self) -> None: + """qr_max_px = printable_px - 2 * qr_padding_px""" + for tape_mm, geom in TAPE_GEOMETRY.items(): + expected = geom.printable_px - 2 * geom.qr_padding_px + assert geom.qr_max_px == expected, ( + f"{tape_mm}mm: qr_max_px={geom.qr_max_px} expected {expected}" + ) + + def test_text_start_x_follows_formula(self) -> None: + """text_start_x = printable_px + qr_padding_px""" + for tape_mm, geom in TAPE_GEOMETRY.items(): + expected = geom.printable_px + geom.qr_padding_px + assert geom.text_start_x == expected, ( + f"{tape_mm}mm: text_start_x={geom.text_start_x} expected {expected}" + ) diff --git a/backend/tests/unit/schemas/test_template_schema.py b/backend/tests/unit/schemas/test_template_schema.py deleted file mode 100644 index 9ae9254..0000000 --- a/backend/tests/unit/schemas/test_template_schema.py +++ /dev/null @@ -1,144 +0,0 @@ -import pytest -from app.schemas.template import LayoutElement, TemplateSchema - - -def test_template_with_qr_and_text() -> None: - template = TemplateSchema( - id="snipeit-asset-24mm", - name="Snipe-IT 24mm", - app="snipeit", - tape_mm=24, - elements=[ - LayoutElement(type="qr", x=0, y=0, size=256, data_field="qr_payload"), - LayoutElement(type="text", x=270, y=10, field="title", font_size=24), - ], - ) - assert len(template.elements) == 2 - assert template.elements[0].type == "qr" - - -def test_template_qr_requires_data_field() -> None: - """QR element without data_field must fail validation.""" - with pytest.raises(ValueError, match="data_field"): - LayoutElement(type="qr", x=0, y=0, size=256) - - -def test_template_qr_requires_size() -> None: - """QR element without size must fail validation.""" - with pytest.raises(ValueError, match="size"): - LayoutElement(type="qr", x=0, y=0, data_field="qr_payload") - - -def test_template_text_requires_field() -> None: - with pytest.raises(ValueError, match="field"): - LayoutElement(type="text", x=0, y=0, font_size=24) - - -def test_template_text_requires_font_size() -> None: - with pytest.raises(ValueError, match="font_size"): - LayoutElement(type="text", x=0, y=0, field="title") - - -def test_template_qr_rejects_zero_size() -> None: - with pytest.raises(ValueError, match="positive size"): - LayoutElement(type="qr", x=0, y=0, size=0, data_field="qr_payload") - - -def test_template_text_rejects_zero_font_size() -> None: - with pytest.raises(ValueError, match="positive font_size"): - LayoutElement(type="text", x=0, y=0, field="title", font_size=0) - - -def test_template_app_accepts_known_string() -> None: - """app is a plain str | None — no Literal gating at schema level.""" - t = TemplateSchema( - id="t", - name="t", - app="snipeit", - tape_mm=24, - elements=[], - ) - assert t.app == "snipeit" - - -def test_template_schema_is_frozen() -> None: - """Templates are immutable after construction.""" - from pydantic_core import ValidationError - - template = TemplateSchema(id="t", name="t", app="snipeit", tape_mm=24, elements=[]) - with pytest.raises(ValidationError, match="frozen_instance"): - template.name = "different" # type: ignore[misc] - - -def test_template_elements_is_immutable() -> None: - """elements is a tuple — appending must raise AttributeError, not silently mutate.""" - template = TemplateSchema( - id="t", - name="t", - app="snipeit", - tape_mm=24, - elements=[LayoutElement(type="text", x=0, y=0, field="title", font_size=12)], - ) - with pytest.raises(AttributeError): - template.elements.append( # type: ignore[attr-defined] - LayoutElement(type="text", x=10, y=10, field="primary_id", font_size=12) - ) - assert isinstance(template.elements, tuple) - - -def test_template_qr_rejects_negative_size() -> None: - with pytest.raises(ValueError, match="positive size"): - LayoutElement(type="qr", x=0, y=0, size=-10, data_field="qr_payload") - - -def test_template_text_rejects_negative_font_size() -> None: - with pytest.raises(ValueError, match="positive font_size"): - LayoutElement(type="text", x=0, y=0, field="title", font_size=-12) - - -def test_template_schema_has_schema_version_field_defaulting_to_1() -> None: - """schema_version is a versioning hook for future YAML migrations.""" - t = TemplateSchema( - id="x", - name="X", - app="snipeit", - tape_mm=24, - elements=(), - ) - assert t.schema_version == 1 - - -def test_template_schema_accepts_explicit_schema_version() -> None: - t = TemplateSchema( - id="x", - name="X", - app="snipeit", - tape_mm=24, - elements=(), - schema_version=1, - ) - assert t.schema_version == 1 - - -def test_template_schema_app_allows_none_for_generic_templates() -> None: - """app=None marks the template as generic — usable with any plugin.""" - t = TemplateSchema( - id="qr-only-24mm", - name="QR-Code only (24mm)", - app=None, - tape_mm=24, - elements=(), - ) - assert t.app is None - - -def test_template_schema_app_accepts_arbitrary_string() -> None: - """Schema does not gate the integration name — the loader validates against the registry.""" - t = TemplateSchema( - id="x", - name="X", - app="future_integration_not_yet_implemented", - tape_mm=24, - elements=(), - ) - assert t.app == "future_integration_not_yet_implemented" diff --git a/backend/tests/unit/seed/test_hangar_templates.py b/backend/tests/unit/seed/test_hangar_templates.py deleted file mode 100644 index 5f23c9b..0000000 --- a/backend/tests/unit/seed/test_hangar_templates.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Verifiziert dass die Hangar-Templates und Samla-Templates valide YAML sind.""" - -from __future__ import annotations - -from pathlib import Path - -import pytest -import yaml -from app.schemas.template import TemplateSchema - -SEED_DIR = Path(__file__).parents[3] / "app" / "seed" / "templates" - - -@pytest.mark.parametrize( - "tape_mm,template_id", - [ - (12, "hangar-furniture-12mm"), - (18, "hangar-furniture-18mm"), - (24, "hangar-furniture-24mm"), - ], -) -def test_hangar_template_parses(tape_mm: int, template_id: str): - path = SEED_DIR / f"{template_id}.yaml" - assert path.exists(), f"missing {path}" - - raw = yaml.safe_load(path.read_text()) - assert raw["schema_version"] == 1 - assert raw["id"] == template_id - assert raw["app"] is None, "app must be null (IntegrationRegistry kennt hangar nicht)" - assert raw["tape_mm"] == tape_mm - - tmpl = TemplateSchema(**raw) - assert tmpl is not None - types = [e["type"] for e in raw["elements"]] - assert types.count("qr") == 1 - assert types.count("text") >= 2 - for elem in raw["elements"]: - assert "bold" not in elem, f"'bold' is not a valid hub element field: {elem}" - - -@pytest.mark.parametrize( - "tape_mm,printer_model,template_id", - [ - (12, "ptouch", "samla-stirntag-12mm"), - (24, "ptouch", "samla-stirntag-24mm"), - (62, "brother_ql", "samla-stirntag-62mm"), - (12, "ptouch", "samla-deckel-12mm"), - (24, "ptouch", "samla-deckel-24mm"), - (62, "brother_ql", "samla-deckel-62mm"), - ], -) -def test_samla_template_parses(tape_mm: int, printer_model: str, template_id: str): - """Phase 1i Task 10: Alle 6 Samla-Templates parsen korrekt. - - Prüft: tape_mm korrekt, printer_model gesetzt, QR + Text vorhanden, - kein 'bold'-Feld (nicht im Schema), preview_sample vorhanden. - """ - path = SEED_DIR / f"{template_id}.yaml" - assert path.exists(), f"missing {path}" - - raw = yaml.safe_load(path.read_text()) - assert raw["schema_version"] == 1 - assert raw["id"] == template_id - assert raw["app"] is None, "app must be null (Samla-Templates sind generisch)" - assert raw["tape_mm"] == tape_mm - assert raw.get("printer_model") == printer_model, ( - f"Expected printer_model={printer_model!r}, got {raw.get('printer_model')!r}" - ) - - tmpl = TemplateSchema(**raw) - assert tmpl is not None - assert tmpl.printer_model == printer_model - - types = [e["type"] for e in raw["elements"]] - assert "qr" in types, "Samla-Template muss QR-Element haben" - assert "text" in types, "Samla-Template muss Text-Element(e) haben" - - for elem in raw["elements"]: - assert "bold" not in elem, f"'bold' ist kein gültiges Hub-Element-Feld: {elem}" - - assert raw.get("preview_sample") is not None, "preview_sample muss vorhanden sein" - assert "qr_payload" in raw["preview_sample"], "preview_sample muss qr_payload enthalten" diff --git a/backend/tests/unit/seed/test_seed_templates.py b/backend/tests/unit/seed/test_seed_templates.py deleted file mode 100644 index fb1f3ab..0000000 --- a/backend/tests/unit/seed/test_seed_templates.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Smoke tests: every shipped seed template parses and renders. - -This is the build-time safety net — if a YAML in app/seed/templates/ -breaks any contract (schema, registry, geometry, renderer), this -suite fails before the PR can merge. -""" - -from collections.abc import Iterator -from pathlib import Path - -import pytest -from app.integrations.registry import IntegrationRegistry -from app.schemas.label_data import LabelData -from app.services.label_renderer import ( - DEFAULT_LABEL_WIDTH_PX, - TAPE_HEIGHT_PX, - LabelRenderer, -) -from app.services.template_loader import TemplateLoader - -SEED_DIR = Path(__file__).parent.parent.parent.parent / "app" / "seed" / "templates" -EXPECTED_IDS = { - "grocy-12mm", - "grocy-18mm", - "grocy-24mm", - "hangar-furniture-12mm", - "hangar-furniture-18mm", - "hangar-furniture-24mm", - "qr-only-12mm", - "qr-only-18mm", - "qr-only-24mm", - "samla-deckel-12mm", - "samla-deckel-24mm", - "samla-deckel-62mm", - "samla-stirntag-12mm", - "samla-stirntag-24mm", - "samla-stirntag-62mm", - "snipeit-12mm", - "snipeit-18mm", - "snipeit-24mm", - "spoolman-12mm", - "spoolman-18mm", - "spoolman-24mm", -} - - -class _StubPlugin: - def __init__(self, name: str) -> None: - self.name = name - self.display_name = name.title() - - async def lookup(self, identifier: str) -> LabelData: - raise NotImplementedError - - -@pytest.fixture(autouse=True) -def _populate_registry() -> Iterator[None]: - IntegrationRegistry._plugins.clear() - TemplateLoader._cache.clear() - for name in ["snipeit", "spoolman", "grocy"]: - IntegrationRegistry.register(_StubPlugin(name)) - TemplateLoader.load_dir(SEED_DIR) - yield - IntegrationRegistry._plugins.clear() - TemplateLoader._cache.clear() - - -@pytest.fixture -def dummy_data() -> LabelData: - return LabelData( - title="Example", - primary_id="HH-AK-BY01", - qr_payload="https://example.test/asset/123", - source_app="snipeit", - secondary=("S/N: 1234", "Loc: Office"), - ) - - -def test_all_expected_templates_are_loaded() -> None: - """The shipped set is exactly the 21 templates the spec calls for. - - 15 original templates + 6 new Samla templates (Phase 1i Task 10): - samla-stirntag-{12,24,62}mm and samla-deckel-{12,24,62}mm. - """ - assert set(TemplateLoader.all()) == EXPECTED_IDS - - -@pytest.mark.parametrize("template_id", sorted(EXPECTED_IDS)) -def test_each_template_renders_with_dummy_label_data( - template_id: str, dummy_data: LabelData -) -> None: - """Every shipped template must produce a 1-bit PIL image without raising.""" - template = TemplateLoader.get(template_id) - image = LabelRenderer().render(template, dummy_data) - assert image.mode == "1" - # Height (tape axis) is pin-locked; width may be trimmed to inked content. - assert image.height == TAPE_HEIGHT_PX[template.tape_mm] - assert 1 <= image.width <= DEFAULT_LABEL_WIDTH_PX diff --git a/backend/tests/unit/services/conftest.py b/backend/tests/unit/services/conftest.py index 10a5958..ff8be27 100644 --- a/backend/tests/unit/services/conftest.py +++ b/backend/tests/unit/services/conftest.py @@ -2,9 +2,13 @@ Provides: async_session_empty — fresh migrated SQLite DB (no rows) - async_session_with_one_template — same but with one seed Template row + async_session_with_one_printer — same but with one Printer row settings_at_head — Settings pointing at the migrated DB runtime_printer_id — stable UUID literal for printer_runtime check + +Phase 1k.1a (Task 25): Template model removed — async_session_with_one_template +replaced by async_session_with_one_printer (readiness checks use Printer rows, +not Template rows, after the template-stack deletion). """ from __future__ import annotations @@ -17,7 +21,7 @@ from alembic import command from alembic.config import Config from app.config import Settings -from app.models.template import Template +from app.models.printer import Printer from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine _ALEMBIC_INI = Path(__file__).parents[3] / "alembic.ini" @@ -51,21 +55,25 @@ async def async_session_empty(tmp_path): @pytest_asyncio.fixture -async def async_session_with_one_template(tmp_path): - """AsyncSession backed by a fresh DB with one seed Template row.""" - url = _make_db_url(tmp_path, "readiness_one_tpl.db") +async def async_session_with_one_printer(tmp_path): + """AsyncSession backed by a fresh DB with one Printer row. + + Replaces the former async_session_with_one_template fixture — the + Template model was removed in Phase 1k.1a. Readiness checks that + previously verified 'at least one template' now verify printer presence. + """ + url = _make_db_url(tmp_path, "readiness_one_printer.db") await asyncio.to_thread(_run_migrations, url) engine = create_async_engine(url, echo=False) async with AsyncSession(engine, expire_on_commit=False) as session: - tpl = Template( - key="test-label", - name="Test Label", - printer_model="pt-series", - tape_width_mm=12, - definition={}, - source="seed", + printer = Printer( + slug="test-pt-p750w", + name="Test PT-P750W", + model="PT-P750W", + backend="ptouch", + connection={"host": "192.0.2.50", "port": 9100}, ) - session.add(tpl) + session.add(printer) await session.commit() yield session await engine.dispose() diff --git a/backend/tests/unit/services/test_label_renderer.py b/backend/tests/unit/services/test_label_renderer.py deleted file mode 100644 index 3a775b2..0000000 --- a/backend/tests/unit/services/test_label_renderer.py +++ /dev/null @@ -1,283 +0,0 @@ -from unittest.mock import patch - -import pytest -from app.schemas.label_data import LabelData -from app.schemas.template import LayoutElement, TemplateSchema -from app.services.label_renderer import ( - DEFAULT_LABEL_WIDTH_PX, - TAPE_HEIGHT_PX, - LabelRenderer, -) -from PIL import Image, ImageFont - - -def test_render_produces_image_with_correct_height_24mm() -> None: - template = TemplateSchema( - id="t1", - name="Test", - app="snipeit", - tape_mm=24, - elements=[ - LayoutElement(type="text", x=10, y=10, field="title", font_size=24), - ], - ) - data = LabelData( - title="Hello", - primary_id="ID-1", - qr_payload="x", - source_app="snipeit", - ) - - img = LabelRenderer().render(template, data) - - assert isinstance(img, Image.Image) - assert img.height == TAPE_HEIGHT_PX[24] - assert img.mode == "1" - - -def test_render_produces_image_with_correct_height_12mm() -> None: - template = TemplateSchema( - id="t1", - name="Test", - app="snipeit", - tape_mm=12, - elements=[LayoutElement(type="text", x=5, y=5, field="title", font_size=16)], - ) - data = LabelData(title="x", primary_id="x", qr_payload="x", source_app="snipeit") - img = LabelRenderer().render(template, data) - assert img.height == TAPE_HEIGHT_PX[12] - - -def test_render_rejects_unsupported_tape_mm() -> None: - template = TemplateSchema( - id="t1", - name="Test", - app="snipeit", - tape_mm=99, - elements=[], - ) - data = LabelData(title="x", primary_id="x", qr_payload="x", source_app="snipeit") - with pytest.raises(ValueError, match="99"): - LabelRenderer().render(template, data) - - -def test_render_with_qr_element_includes_black_pixels() -> None: - """A QR element must produce a non-trivial number of black pixels in its bbox.""" - template = TemplateSchema( - id="t1", - name="Test", - app="snipeit", - tape_mm=24, - elements=[ - LayoutElement(type="qr", x=0, y=0, size=200, data_field="qr_payload"), - ], - ) - data = LabelData( - title="X", - primary_id="X", - qr_payload="https://example.com", - source_app="snipeit", - ) - - img = LabelRenderer().render(template, data) - qr_region = img.crop((0, 0, 200, 200)) - black_count = sum(1 for p in qr_region.get_flattened_data() if p == 0) - assert black_count > 100, f"Expected QR to produce many black pixels, got {black_count}" - - -def test_render_resolves_secondary_tuple_field() -> None: - """secondary is a tuple — renderer must join the entries when used as a text field.""" - template = TemplateSchema( - id="t1", - name="Test", - app="snipeit", - tape_mm=24, - elements=[ - LayoutElement(type="text", x=10, y=100, field="secondary", font_size=16), - ], - ) - data = LabelData( - title="X", - primary_id="X", - qr_payload="x", - source_app="snipeit", - secondary=("Color: Black", "Weight: 850g"), - ) - - img = LabelRenderer().render(template, data) - # The text region should not be entirely white (some pixels must be drawn). - region = img.crop((10, 100, DEFAULT_LABEL_WIDTH_PX, 120)) - black_count = sum(1 for p in region.get_flattened_data() if p == 0) - assert black_count > 0 - - -def test_render_empty_template_produces_blank_image() -> None: - """An empty template (no elements) must render a blank white canvas.""" - template = TemplateSchema(id="t", name="T", app="snipeit", tape_mm=24, elements=[]) - data = LabelData(title="X", primary_id="X", qr_payload="x", source_app="snipeit") - img = LabelRenderer().render(template, data) - # All pixels should be 1 (white background). - assert all(p == 1 for p in img.get_flattened_data()) - - -def test_render_with_missing_data_field_renders_empty_string() -> None: - """If a template references a field LabelData doesn't have, render empty (no crash).""" - template = TemplateSchema( - id="t1", - name="Test", - app="snipeit", - tape_mm=24, - elements=[ - LayoutElement(type="text", x=10, y=10, field="nonexistent_field", font_size=16), - ], - ) - data = LabelData(title="X", primary_id="X", qr_payload="x", source_app="snipeit") - # Must NOT raise — missing fields render as empty strings. - img = LabelRenderer().render(template, data) - assert img is not None - - -def test_font_loader_is_cached() -> None: - """Same font_size returns the same font instance (LRU-cached).""" - from app.services.label_renderer import _load_font_cached - - a = _load_font_cached(24) - b = _load_font_cached(24) - assert a is b - - -def test_load_font_calls_truetype_only_once_per_size() -> None: - """ImageFont.truetype() must be called exactly once per (font, size) pair. - - The lru_cache on _load_font_cached memoises the result — repeated calls - with the same size must not re-read the font file from disk. - """ - from app.services.label_renderer import _load_font_cached - - # Clear the cache so we control how many truetype() calls happen in this test. - _load_font_cached.cache_clear() - call_count = 0 - original_truetype = ImageFont.truetype - - def counting_truetype(font: object, size: object, **kwargs: object) -> ImageFont.FreeTypeFont: - nonlocal call_count - call_count += 1 - return original_truetype(font, size, **kwargs) # type: ignore[arg-type] - - with patch.object(ImageFont, "truetype", counting_truetype): - _load_font_cached(16) - _load_font_cached(16) - _load_font_cached(16) - - assert call_count == 1, ( - f"Expected ImageFont.truetype to be called once for size=16 " - f"(lru_cache should memoize), but it was called {call_count} time(s)" - ) - - -# --------------------------------------------------------------------------- -# Phase 1i smoke-test live-bug: DejaVuSans TTF verfügbar (font_size honored) -# --------------------------------------------------------------------------- - - -def test_label_renderer_uses_truetype_font() -> None: - """font_size-Parameter muss tatsächlich die Textgröße skalieren (kein Bitmap-Fallback). - - Root Cause: Container ohne fonts-dejavu-core → ImageFont.truetype('DejaVuSans.ttf', N) - schlägt mit OSError fehl → _load_font_cached fällt auf load_default() zurück. - Pillow's load_default() ist eine fixe-Größe Bitmap-Font die size IGNORIERT. - Fix: fonts-dejavu-core im Dockerfile installieren — siehe backend/Dockerfile. - - In CI/Container ist die TTF garantiert verfügbar (apt-get install im Dockerfile). - Auf Dev-Rechnern (macOS/Windows ohne system-DejaVuSans) wird der Test übersprungen - statt zu failen — damit lokaler TDD-Workflow nicht durch fehlende Host-Fonts gestört - wird. (Gemini-Review PR #100) - """ - import os - from pathlib import Path - - from app.services.label_renderer import _load_font_cached - from PIL import ImageFont - - # Cache leeren damit der Test sauber von Null startet - _load_font_cached.cache_clear() - - in_ci = os.environ.get("CI") == "true" or Path("/.dockerenv").exists() - try: - probe = ImageFont.truetype("DejaVuSans.ttf", 12) - except OSError: - if in_ci: - raise # CI/Container MUSS die Font haben — apt-get-Fix verifizieren - pytest.skip( - "DejaVuSans.ttf nicht system-installiert — Dev-Maschine ohne fonts-dejavu. " - "In CI/Container wird der Test ausgeführt (Dockerfile installiert die Font)." - ) - del probe - - font = _load_font_cached(22) - assert isinstance(font, ImageFont.FreeTypeFont), ( - f"Erwartet FreeTypeFont (TTF), bekommen {type(font).__name__}. " - "Stelle sicher dass fonts-dejavu-core im Dockerfile installiert ist " - "(/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf muss existieren)." - ) - - # Tatsächliche Skalierung verifizieren: größere font_size → größere Glyphen - font10 = _load_font_cached(10) - font22 = _load_font_cached(22) - bbox10 = font10.getbbox("X") - bbox22 = font22.getbbox("X") - h10 = bbox10[3] - bbox10[1] - h22 = bbox22[3] - bbox22[1] - assert h22 > h10 * 1.5, ( - f"Font-Skalierung defekt: h10={h10}px, h22={h22}px (Faktor {h22 / h10:.1f}x < 1.5x). " - "Bitmap-Fallback liefert immer dieselbe Höhe unabhängig von font_size." - ) - - -class TestWhitespaceTrim: - """Cropping the inked content to save tape material on the length axis.""" - - def test_qr_only_template_is_trimmed_to_content_plus_margin(self) -> None: - template = TemplateSchema( - schema_version=1, - id="qr-only-12mm-test", - name="QR only test", - app=None, - tape_mm=12, - elements=(LayoutElement(type="qr", x=260, y=13, size=80, data_field="qr_payload"),), - ) - data = LabelData( - title="Smoke", - primary_id="X", - qr_payload="https://example.test/smoke", - secondary=(), - source_app="manual", - ) - img = LabelRenderer().render(template, data) - # The QR sits at x=260..340 with size=80; after trim with 6px margin, - # width should be 80 + 2*6 = 92 px (give or take a pixel for QR rendering). - assert img.width < 200, f"Expected compact label, got width={img.width}" - # Height is pin-locked to ptouch Tape12mm print_pins (70px after DPI-Fix). - assert img.height == TAPE_HEIGHT_PX[12], "Tape-axis height must stay fixed" - - def test_entirely_blank_template_returns_unchanged_canvas(self) -> None: - template = TemplateSchema( - schema_version=1, - id="blank-test", - name="Blank", - app=None, - tape_mm=12, - elements=(), - ) - data = LabelData( - title="X", - primary_id="X", - qr_payload="X", - secondary=(), - source_app="manual", - ) - img = LabelRenderer().render(template, data) - # No ink → no trim → full default canvas - assert img.width == 600 - # Height is pin-locked to ptouch Tape12mm print_pins (70px after DPI-Fix). - assert img.height == TAPE_HEIGHT_PX[12] diff --git a/backend/tests/unit/services/test_layout_engine.py b/backend/tests/unit/services/test_layout_engine.py new file mode 100644 index 0000000..623bc95 --- /dev/null +++ b/backend/tests/unit/services/test_layout_engine.py @@ -0,0 +1,100 @@ +"""Unit tests for LayoutEngine — skeleton + validation + dispatch.""" + +from __future__ import annotations + +import pytest +from app.printer_backends.exceptions import ( + ContentTypeDataMismatchError, + UnsupportedTapeError, +) +from app.schemas.content_type import ContentType +from app.schemas.label_data import LabelData +from app.schemas.label_data_item import LabelDataItem +from app.services.layout_engine import LayoutEngine + + +class TestLayoutEngineLookup: + def test_unsupported_tape_raises(self) -> None: + eng = LayoutEngine() + with pytest.raises(UnsupportedTapeError) as exc_info: + eng.render( + tape_mm=36, + content_type=ContentType.QR_ONLY, + data=LabelData(source_app="manual", qr_payload="x"), + ) + assert exc_info.value.tape_mm == 36 + + +class TestLayoutEngineValidation: + def test_qr_only_requires_qr_payload(self) -> None: + eng = LayoutEngine() + with pytest.raises(ContentTypeDataMismatchError) as exc_info: + eng.render( + tape_mm=12, + content_type=ContentType.QR_ONLY, + data=LabelData(source_app="manual"), + ) + assert "qr_payload" in exc_info.value.missing_fields + + def test_qr_two_lines_requires_all_three(self) -> None: + eng = LayoutEngine() + with pytest.raises(ContentTypeDataMismatchError) as exc_info: + eng.render( + tape_mm=12, + content_type=ContentType.QR_TWO_LINES, + data=LabelData(source_app="manual", primary_id="x"), + ) + assert set(exc_info.value.missing_fields) >= {"qr_payload", "title"} + + def test_qr_three_lines_requires_secondary(self) -> None: + eng = LayoutEngine() + with pytest.raises(ContentTypeDataMismatchError) as exc_info: + eng.render( + tape_mm=18, + content_type=ContentType.QR_THREE_LINES, + data=LabelData( + source_app="grocy", + primary_id="X", + title="Y", + qr_payload="Z", + secondary=(), + ), + ) + assert "secondary" in exc_info.value.missing_fields + + def test_text_one_line_only_needs_primary_id(self) -> None: + eng = LayoutEngine() + with pytest.raises(ContentTypeDataMismatchError) as exc_info: + eng.render( + tape_mm=12, + content_type=ContentType.TEXT_ONE_LINE, + data=LabelData(source_app="manual"), + ) + assert exc_info.value.missing_fields == ("primary_id",) + + def test_qr_with_listing_requires_items_and_qr(self) -> None: + eng = LayoutEngine() + with pytest.raises(ContentTypeDataMismatchError) as exc_info: + eng.render( + tape_mm=12, + content_type=ContentType.QR_WITH_LISTING, + data=LabelData(source_app="hangar", primary_id="K02"), + ) + assert "qr_payload" in exc_info.value.missing_fields + assert "items" in exc_info.value.missing_fields + + def test_qr_with_listing_with_items_passes_validation(self) -> None: + """Validation passes with non-empty items; render returns a PIL Image.""" + eng = LayoutEngine() + img = eng.render( + tape_mm=12, + content_type=ContentType.QR_WITH_LISTING, + data=LabelData( + source_app="hangar", + primary_id="K02", + qr_payload="https://example.com/k02", + items=(LabelDataItem(item="A"),), + ), + ) + assert img is not None + assert img.height == 70 # 12mm printable_px diff --git a/backend/tests/unit/services/test_layout_engine_render.py b/backend/tests/unit/services/test_layout_engine_render.py new file mode 100644 index 0000000..d69dc4a --- /dev/null +++ b/backend/tests/unit/services/test_layout_engine_render.py @@ -0,0 +1,278 @@ +"""Render tests per ContentType — output image checks (size, content).""" + +from __future__ import annotations + +from app.schemas.content_type import ContentType +from app.schemas.label_data import LabelData +from app.schemas.tape_geometry import TAPE_GEOMETRY +from app.services.layout_engine import LayoutEngine + + +class TestRenderQROnly: + def test_image_height_matches_printable_px_12mm(self) -> None: + eng = LayoutEngine() + img = eng.render( + tape_mm=12, + content_type=ContentType.QR_ONLY, + data=LabelData(source_app="manual", qr_payload="https://example.com/x"), + ) + assert img.height == TAPE_GEOMETRY[12].printable_px == 70 + + def test_image_mode_is_1bit(self) -> None: + eng = LayoutEngine() + img = eng.render( + tape_mm=12, + content_type=ContentType.QR_ONLY, + data=LabelData(source_app="manual", qr_payload="https://example.com/x"), + ) + assert img.mode == "1" + + def test_qr_pixels_present(self) -> None: + eng = LayoutEngine() + img = eng.render( + tape_mm=12, + content_type=ContentType.QR_ONLY, + data=LabelData(source_app="manual", qr_payload="https://example.com/x"), + ) + # In mode "1", tobytes() returns packed bytes (8 pixels per byte). + # Black pixels are zero bits — count by inspecting the bytes. + pixel_bytes = img.tobytes() + # If any pixel is black (bit=0), at least one byte will be != 0xFF. + non_white_bytes = sum(1 for b in pixel_bytes if b != 0xFF) + assert non_white_bytes > 30, f"Expected QR pixels; got {non_white_bytes} non-white bytes" + + def test_24mm_renders(self) -> None: + eng = LayoutEngine() + img = eng.render( + tape_mm=24, + content_type=ContentType.QR_ONLY, + data=LabelData(source_app="manual", qr_payload="https://example.com/y"), + ) + assert img.height == TAPE_GEOMETRY[24].printable_px == 128 + + +class TestRenderQROneLine: + def test_image_height_matches_printable_px(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.schemas.tape_geometry import TAPE_GEOMETRY + from app.services.layout_engine import LayoutEngine + + eng = LayoutEngine() + img = eng.render( + tape_mm=12, + content_type=ContentType.QR_ONE_LINE, + data=LabelData( + source_app="manual", + qr_payload="https://example.com/x", + primary_id="X-001", + ), + ) + assert img.height == TAPE_GEOMETRY[12].printable_px + + def test_width_includes_text_column(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.schemas.tape_geometry import TAPE_GEOMETRY + from app.services.layout_engine import LayoutEngine + + eng = LayoutEngine() + img = eng.render( + tape_mm=12, + content_type=ContentType.QR_ONE_LINE, + data=LabelData( + source_app="manual", + qr_payload="https://example.com/x", + primary_id="X-001", + ), + ) + assert img.width > TAPE_GEOMETRY[12].text_start_x + + +class TestRenderQRTwoLines: + def test_baseline_12mm_v4_winner(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.schemas.tape_geometry import TAPE_GEOMETRY + from app.services.layout_engine import LayoutEngine + + eng = LayoutEngine() + img = eng.render( + tape_mm=12, + content_type=ContentType.QR_TWO_LINES, + data=LabelData( + source_app="hangar", + primary_id="K-02", + title="Werkstatt", + qr_payload="https://example.com/locations/k-02", + ), + ) + assert img.height == 70 + geom = TAPE_GEOMETRY[12] + assert img.width > geom.text_start_x + + def test_24mm_renders(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.services.layout_engine import LayoutEngine + + eng = LayoutEngine() + img = eng.render( + tape_mm=24, + content_type=ContentType.QR_TWO_LINES, + data=LabelData( + source_app="hangar", + primary_id="K-02", + title="Werkstatt", + qr_payload="https://example.com/x", + ), + ) + assert img.height == 128 + + def test_62mm_renders_at_higher_dpi(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.services.layout_engine import LayoutEngine + + eng = LayoutEngine() + img = eng.render( + tape_mm=62, + content_type=ContentType.QR_TWO_LINES, + data=LabelData( + source_app="samla", + primary_id="HH-AK-SM01", + title="Samla 11L", + qr_payload="https://example.com/x", + ), + ) + assert img.height == 696 + + +class TestRenderQRThreeLines: + def test_18mm_with_secondary(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.services.layout_engine import LayoutEngine + + eng = LayoutEngine() + img = eng.render( + tape_mm=18, + content_type=ContentType.QR_THREE_LINES, + data=LabelData( + source_app="grocy", + primary_id="Erdbeermarmelade", + title="Lager > Vorrat", + qr_payload="https://example.com/x", + secondary=("MHD 2027-04-30",), + ), + ) + assert img.height == 112 + + def test_24mm_renders(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.services.layout_engine import LayoutEngine + + eng = LayoutEngine() + img = eng.render( + tape_mm=24, + content_type=ContentType.QR_THREE_LINES, + data=LabelData( + source_app="grocy", + primary_id="X", + title="Y", + qr_payload="https://example.com/x", + secondary=("Z",), + ), + ) + assert img.height == 128 + + +class TestRenderTextOneLine: + def test_no_qr_present(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.services.layout_engine import LayoutEngine + + eng = LayoutEngine() + img = eng.render( + tape_mm=12, + content_type=ContentType.TEXT_ONE_LINE, + data=LabelData(source_app="manual", primary_id="HELLO"), + ) + assert img.width < 200 + + def test_renders_at_correct_height(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.schemas.tape_geometry import TAPE_GEOMETRY + from app.services.layout_engine import LayoutEngine + + eng = LayoutEngine() + img = eng.render( + tape_mm=24, + content_type=ContentType.TEXT_ONE_LINE, + data=LabelData(source_app="manual", primary_id="X"), + ) + assert img.height == TAPE_GEOMETRY[24].printable_px + + +class TestRenderTextTwoLines: + def test_18mm_renders(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.services.layout_engine import LayoutEngine + + eng = LayoutEngine() + img = eng.render( + tape_mm=18, + content_type=ContentType.TEXT_TWO_LINES, + data=LabelData(source_app="manual", primary_id="LINE1", title="LINE2"), + ) + assert img.height == 112 + + +class TestRenderQRWithListing: + def test_4_items_render(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.schemas.label_data_item import LabelDataItem + from app.services.layout_engine import LayoutEngine + + eng = LayoutEngine() + img = eng.render( + tape_mm=24, + content_type=ContentType.QR_WITH_LISTING, + data=LabelData( + source_app="hangar", + primary_id="Kallax-02", + qr_payload="https://example.com/k02", + items=( + LabelDataItem(item="A — Schrauben"), + LabelDataItem(item="B — Muttern"), + LabelDataItem(item="C — Werkzeug"), + LabelDataItem(item="D — Kabel"), + ), + ), + ) + assert img.height == 128 + + def test_overflow_shows_n_more(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.schemas.label_data_item import LabelDataItem + from app.services.layout_engine import LayoutEngine + + eng = LayoutEngine() + many = tuple(LabelDataItem(item=f"Item {i}") for i in range(10)) + img = eng.render( + tape_mm=12, + content_type=ContentType.QR_WITH_LISTING, + data=LabelData( + source_app="hangar", + primary_id="X", + qr_payload="x", + items=many, + ), + ) + assert img.height == 70 diff --git a/backend/tests/unit/services/test_print_queue_rerender.py b/backend/tests/unit/services/test_print_queue_rerender.py new file mode 100644 index 0000000..2d4a189 --- /dev/null +++ b/backend/tests/unit/services/test_print_queue_rerender.py @@ -0,0 +1,42 @@ +"""Test the _rerender_from_db recovery path uses LayoutEngine.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +from app.services.layout_engine import LayoutEngine +from app.services.print_queue import PrintQueue + + +class TestRerenderFromDb: + def test_rerender_uses_engine_with_stored_content_type(self) -> None: + printer = MagicMock(id=uuid4()) + queue = PrintQueue( + printers=[printer], + engine=LayoutEngine(), + store=MagicMock(), + on_state_change=AsyncMock(), + ) + stored_payload = { + "label_data": { + "source_app": "manual", + "primary_id": "K-02", + "title": "Werkstatt", + "qr_payload": "https://example.com/x", + "secondary": [], + "items": [], + }, + "content_type": "qr_two_lines", + "rendered_tape_mm": 12, + "tape_mm": 12, + "options": { + "copies": 1, + "auto_cut": True, + "high_resolution": False, + "half_cut": False, + "last_page": True, + }, + } + image = queue._rerender_from_db_payload(stored_payload) + assert image.height == 70 diff --git a/backend/tests/unit/services/test_print_service.py b/backend/tests/unit/services/test_print_service.py index 63aaa79..c260a7f 100644 --- a/backend/tests/unit/services/test_print_service.py +++ b/backend/tests/unit/services/test_print_service.py @@ -1,558 +1,313 @@ +"""Unit tests for PrintService with LayoutEngine integration. + +Phase 1k.1a Task 15: Replaces all template_id / TapeMismatchError / PAUSED-path +tests with new LayoutEngine-based tests. +""" + from __future__ import annotations from unittest.mock import AsyncMock, MagicMock -from uuid import UUID, uuid4 +from uuid import UUID import pytest from app.printer_backends.exceptions import ( + NoTapeLoadedError, PrinterCoverOpenError, PrinterOfflineError, TapeEmptyError, - TapeMismatchError, ) from app.printer_backends.snmp_helper import PreflightStatus -from app.schemas.label_data import LabelData +from app.schemas.content_type import ContentType from app.schemas.print_request import ( PrintLookupRequest, PrintOptions, PrintRequest, RawLabelData, ) -from app.services.job_lifecycle import Job, JobState +from app.services.layout_engine import LayoutEngine from app.services.print_service import PrintService -from app.services.template_loader import TemplateNotFoundError -from PIL import Image - - -@pytest.fixture -def template(): - # Build a minimal TemplateSchema-compatible object — the actual schema - # was created in Phase 4. Use the real schema if available, else a - # MagicMock with .tape_mm attribute. - tpl = MagicMock() - tpl.tape_mm = 24 - tpl.id = "qr-only-24mm" - return tpl - - -@pytest.fixture -def image(): - return Image.new("1", (200, 128)) - - -@pytest.fixture -def loader(template): - m = MagicMock() - m.get.return_value = template - return m - - -@pytest.fixture -def renderer(image): - m = MagicMock() - m.render.return_value = image - return m - -_FAKE_JOB_UUID = uuid4() - - -@pytest.fixture -def queue(): - m = AsyncMock() - m.submit.return_value = "job-1" - # Phase 2: submit_with_id und submit_paused_with_id werden jetzt genutzt - m.submit_with_id.return_value = _FAKE_JOB_UUID - m.submit_paused_with_id.return_value = _FAKE_JOB_UUID - return m - - -@pytest.fixture -def lookup_service(): - m = AsyncMock() - m.lookup.return_value = LabelData( - title="X", - primary_id="1", - qr_payload="u", - source_app="snipeit", - secondary=(), - ) - return m +_PRINTER_ID = UUID("bbbbbbbb-0000-0000-0000-000000000001") -@pytest.fixture -def backend(): - """Backend mock that reports 24mm tape loaded and printer idle (happy path).""" - m = AsyncMock() - m.preflight_check.return_value = PreflightStatus( +def _preflight(loaded_tape_mm: int | None = 12) -> PreflightStatus: + return PreflightStatus( hr_printer_status="idle", - loaded_tape_mm=24, + loaded_tape_mm=loaded_tape_mm, error_flags=[], ) - return m - - -_PRINTER_ID = UUID("bbbbbbbb-0000-0000-0000-000000000001") - - -def _service(loader, renderer, queue, lookup_service, backend, store=None): - kwargs = { - "template_loader": loader, - "renderer": renderer, - "print_queue": queue, - "lookup_service": lookup_service, - "printer_id": _PRINTER_ID, - "backend": backend, - } - if store is not None: - kwargs["store"] = store - return PrintService(**kwargs) - - -# --------------------------------------------------------------------------- -# Existing happy-path tests — updated to pass backend fixture -# --------------------------------------------------------------------------- - - -async def test_lookup_path_calls_lookup_and_renders( - loader, - renderer, - queue, - lookup_service, - backend, -) -> None: - svc = _service(loader, renderer, queue, lookup_service, backend) - req = PrintRequest( - template_id="qr-only-24mm", - lookup=PrintLookupRequest(app="snipeit", identifier="42"), - ) - job_id = await svc.submit_print_job(req) - lookup_service.lookup.assert_awaited_once_with("snipeit", "42") - renderer.render.assert_called_once() - # Phase 2: submit_print_job ruft submit_with_id statt submit - queue.submit_with_id.assert_awaited_once() - assert isinstance(job_id, UUID) - - -async def test_data_path_bypasses_lookup_and_marks_source_manual( - loader, - renderer, - queue, - lookup_service, - backend, -) -> None: - svc = _service(loader, renderer, queue, lookup_service, backend) - req = PrintRequest( - template_id="qr-only-24mm", - data=RawLabelData(title="T", primary_id="P", qr_payload="Q", secondary=["a"]), - ) - job_id = await svc.submit_print_job(req) - lookup_service.lookup.assert_not_called() - args, _ = renderer.render.call_args - label_data = args[1] - assert isinstance(label_data, LabelData) - assert label_data.source_app == "manual" - assert label_data.secondary == ("a",) - # Phase 2: submit_print_job gibt UUID zurück - assert isinstance(job_id, UUID) - - -async def test_template_not_found_raises_synchronously( - loader, - renderer, - queue, - lookup_service, - backend, -) -> None: - loader.get.side_effect = TemplateNotFoundError("qr-only-24mm") - svc = _service(loader, renderer, queue, lookup_service, backend) - req = PrintRequest( - template_id="qr-only-24mm", - lookup=PrintLookupRequest(app="snipeit", identifier="x"), - ) - with pytest.raises(TemplateNotFoundError): - await svc.submit_print_job(req) - queue.submit.assert_not_called() - # preflight must NOT be called when template is not found - backend.preflight_check.assert_not_awaited() - - -async def test_options_passed_to_queue(loader, renderer, queue, lookup_service, backend) -> None: - svc = _service(loader, renderer, queue, lookup_service, backend) - req = PrintRequest( - template_id="qr-only-24mm", - data=RawLabelData(title="T", primary_id="P", qr_payload="Q"), - options=PrintOptions(copies=2, auto_cut=False, high_resolution=True), - ) - await svc.submit_print_job(req) - # Phase 2: submit_with_id statt submit - _, kwargs = queue.submit_with_id.call_args - assert kwargs["tape_mm"] == 24 - assert kwargs["auto_cut"] is False - assert kwargs["high_resolution"] is True - # `copies` is deliberately NOT forwarded — see service comment - assert "copies" not in kwargs -# --------------------------------------------------------------------------- -# Preflight: happy path (tape matches) -# --------------------------------------------------------------------------- - - -async def test_preflight_match_proceeds_normally( - loader, renderer, queue, lookup_service, backend -) -> None: - """When loaded tape matches template.tape_mm the job is submitted normally.""" - svc = _service(loader, renderer, queue, lookup_service, backend) - req = PrintRequest( - template_id="qr-only-24mm", - data=RawLabelData(title="T", primary_id="P", qr_payload="Q"), - ) - job_id = await svc.submit_print_job(req) - assert isinstance(job_id, UUID) - backend.preflight_check.assert_awaited_once() - # Phase 2: submit_print_job ruft submit_with_id statt submit - queue.submit_with_id.assert_awaited_once() - - -# --------------------------------------------------------------------------- -# Preflight: tape mismatch + fail (default) -# --------------------------------------------------------------------------- +@pytest.fixture +def make_service(): + """Factory: returns (svc, queue_mock, store_mock, backend_mock).""" + def _make( + loaded_tape_mm: int | None = 12, + ) -> tuple[PrintService, MagicMock, MagicMock, MagicMock]: + backend = AsyncMock() + backend.preflight_check = AsyncMock(return_value=_preflight(loaded_tape_mm)) -async def test_preflight_mismatch_fail_raises_tape_mismatch( - loader, renderer, queue, lookup_service, backend -) -> None: - """on_tape_mismatch=fail (default) → TapeMismatchError raised, no job created.""" - backend.preflight_check.return_value = PreflightStatus( - hr_printer_status="idle", - loaded_tape_mm=12, - error_flags=[], - ) - svc = _service(loader, renderer, queue, lookup_service, backend) - req = PrintRequest( - template_id="qr-only-24mm", # template wants 24mm, printer has 12mm - data=RawLabelData(title="T", primary_id="P", qr_payload="Q"), - on_tape_mismatch="fail", - ) - with pytest.raises(TapeMismatchError) as exc_info: - await svc.submit_print_job(req) - assert exc_info.value.expected_mm == 24 - assert exc_info.value.loaded_mm == 12 - queue.submit.assert_not_called() - - -async def test_preflight_mismatch_default_is_fail( - loader, renderer, queue, lookup_service, backend -) -> None: - """on_tape_mismatch defaults to 'fail' when not specified.""" - backend.preflight_check.return_value = PreflightStatus( - hr_printer_status="idle", - loaded_tape_mm=12, - error_flags=[], - ) - svc = _service(loader, renderer, queue, lookup_service, backend) - req = PrintRequest( - template_id="qr-only-24mm", - data=RawLabelData(title="T", primary_id="P", qr_payload="Q"), - # no on_tape_mismatch — default is "fail" - ) - with pytest.raises(TapeMismatchError): - await svc.submit_print_job(req) - queue.submit.assert_not_called() + queue = MagicMock() + queue.submit_with_id = AsyncMock() + store = MagicMock() + store.save_queued = AsyncMock() -# --------------------------------------------------------------------------- -# Preflight: tape mismatch + queue -# --------------------------------------------------------------------------- + engine = LayoutEngine() + svc = PrintService( + printer_id=_PRINTER_ID, + backend=backend, + queue=queue, + store=store, + engine=engine, + ) + return svc, queue, store, backend -async def test_preflight_mismatch_queue_creates_paused_job( - loader, renderer, queue, lookup_service, backend -) -> None: - """on_tape_mismatch=queue → job created via submit_paused() with PAUSED metadata.""" - backend.preflight_check.return_value = PreflightStatus( - hr_printer_status="idle", - loaded_tape_mm=12, - error_flags=[], - ) - # Phase 2: submit_paused_with_id statt submit_paused. - # queue.get gibt ein in-memory Job-Objekt zurück auf das Metadaten gesetzt werden. - job = Job(id="job-1", printer_id=_PRINTER_ID, image_payload=b"", tape_mm=24, options={}) - from app.services.job_lifecycle import JobStateMachine - - JobStateMachine.transition(job, JobState.PAUSED) - queue.submit_paused_with_id.return_value = _FAKE_JOB_UUID - queue.get.return_value = job - svc = _service(loader, renderer, queue, lookup_service, backend) - req = PrintRequest( - template_id="qr-only-24mm", - data=RawLabelData(title="T", primary_id="P", qr_payload="Q"), - on_tape_mismatch="queue", - ) - job_id = await svc.submit_print_job(req) - assert isinstance(job_id, UUID) - # Phase 2: submit_paused_with_id() wurde aufgerufen (nicht submit/submit_with_id) - queue.submit_paused_with_id.assert_awaited_once() - queue.submit_with_id.assert_not_awaited() - queue.submit.assert_not_awaited() - # tape-mismatch metadata attached after submit_paused_with_id - assert job.state == JobState.PAUSED - assert job.error_code == "tape_mismatch" - assert job.error_message is not None - assert job.error_detail == {"expected_mm": 24, "loaded_mm": 12} - - -async def test_preflight_mismatch_queue_none_tape_loaded( - loader, renderer, queue, lookup_service, backend -) -> None: - """on_tape_mismatch=queue with no tape loaded (loaded_tape_mm=None).""" - backend.preflight_check.return_value = PreflightStatus( - hr_printer_status="idle", - loaded_tape_mm=None, - error_flags=[], - ) - job = Job(id="job-1", printer_id=_PRINTER_ID, image_payload=b"", tape_mm=24, options={}) - from app.services.job_lifecycle import JobStateMachine - - JobStateMachine.transition(job, JobState.PAUSED) - queue.submit_paused_with_id.return_value = _FAKE_JOB_UUID - queue.get.return_value = job - svc = _service(loader, renderer, queue, lookup_service, backend) - req = PrintRequest( - template_id="qr-only-24mm", - data=RawLabelData(title="T", primary_id="P", qr_payload="Q"), - on_tape_mismatch="queue", - ) - job_id = await svc.submit_print_job(req) - assert isinstance(job_id, UUID) - # Phase 2: submit_paused_with_id() aufgerufen - queue.submit_paused_with_id.assert_awaited_once() - assert job.state == JobState.PAUSED - assert job.error_code == "tape_mismatch" - assert job.error_detail == {"expected_mm": 24, "loaded_mm": None} + return _make # --------------------------------------------------------------------------- -# Preflight: other printer errors — always synchronous regardless of on_tape_mismatch +# Happy-path: submit_print_job # --------------------------------------------------------------------------- -async def test_preflight_offline_raises_synchronously( - loader, renderer, queue, lookup_service, backend -) -> None: - """PrinterOfflineError from preflight propagates synchronously.""" - backend.preflight_check.side_effect = PrinterOfflineError("host unreachable") - svc = _service(loader, renderer, queue, lookup_service, backend) - req = PrintRequest( - template_id="qr-only-24mm", - data=RawLabelData(title="T", primary_id="P", qr_payload="Q"), - on_tape_mismatch="queue", # even with queue, offline is always synchronous - ) - with pytest.raises(PrinterOfflineError): - await svc.submit_print_job(req) - queue.submit.assert_not_called() - - -async def test_preflight_tape_empty_raises_synchronously( - loader, renderer, queue, lookup_service, backend -) -> None: - """TapeEmptyError from preflight propagates synchronously.""" - backend.preflight_check.side_effect = TapeEmptyError() - svc = _service(loader, renderer, queue, lookup_service, backend) - req = PrintRequest( - template_id="qr-only-24mm", - data=RawLabelData(title="T", primary_id="P", qr_payload="Q"), - ) - with pytest.raises(TapeEmptyError): - await svc.submit_print_job(req) - queue.submit.assert_not_called() - - -async def test_preflight_cover_open_raises_synchronously( - loader, renderer, queue, lookup_service, backend -) -> None: - """PrinterCoverOpenError from preflight propagates synchronously.""" - backend.preflight_check.side_effect = PrinterCoverOpenError() - svc = _service(loader, renderer, queue, lookup_service, backend) - req = PrintRequest( - template_id="qr-only-24mm", - data=RawLabelData(title="T", primary_id="P", qr_payload="Q"), - ) - with pytest.raises(PrinterCoverOpenError): - await svc.submit_print_job(req) - queue.submit.assert_not_called() +class TestSubmitPrintJob: + @pytest.mark.asyncio + async def test_renders_on_loaded_tape_mm_18(self, make_service) -> None: + """Happy path: 18mm tape, QR_TWO_LINES — returns UUID, queue.submit called.""" + svc, queue, _store, _backend = make_service(loaded_tape_mm=18) + request = PrintRequest( + content_type=ContentType.QR_TWO_LINES, + data=RawLabelData( + primary_id="K02", + title="Workshop", + qr_payload="https://example.com/x", + ), + ) + job_id = await svc.submit_print_job(request) + assert isinstance(job_id, UUID) + queue.submit_with_id.assert_awaited_once() + kwargs = queue.submit_with_id.await_args.kwargs + assert kwargs.get("tape_mm") == 18 + + @pytest.mark.asyncio + async def test_renders_on_loaded_tape_mm_12(self, make_service) -> None: + """Happy path: 12mm tape, QR_ONE_LINE.""" + svc, queue, _store, _backend = make_service(loaded_tape_mm=12) + request = PrintRequest( + content_type=ContentType.QR_ONE_LINE, + data=RawLabelData( + primary_id="A01", + qr_payload="https://example.com/a", + ), + ) + job_id = await svc.submit_print_job(request) + assert isinstance(job_id, UUID) + queue.submit_with_id.assert_awaited_once() + + @pytest.mark.asyncio + async def test_qr_only_renders_minimally(self, make_service) -> None: + """QR_ONLY: only qr_payload required — renders and queues.""" + svc, queue, _store, _backend = make_service(loaded_tape_mm=12) + request = PrintRequest( + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="https://example.com/x"), + ) + await svc.submit_print_job(request) + queue.submit_with_id.assert_awaited_once() + + @pytest.mark.asyncio + async def test_returns_uuid(self, make_service) -> None: + """submit_print_job always returns a fresh UUID.""" + svc, _queue, _store, _backend = make_service(loaded_tape_mm=24) + request = PrintRequest( + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="https://example.com/x"), + ) + job_id = await svc.submit_print_job(request) + assert isinstance(job_id, UUID) + + @pytest.mark.asyncio + async def test_store_save_queued_called(self, make_service) -> None: + """store.save_queued is called once before queue.submit.""" + svc, _queue, store, _backend = make_service(loaded_tape_mm=12) + request = PrintRequest( + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="https://example.com/x"), + ) + await svc.submit_print_job(request) + store.save_queued.assert_called_once() + + @pytest.mark.asyncio + async def test_options_forwarded_to_queue(self, make_service) -> None: + """PrintOptions fields are forwarded to queue.submit as kwargs.""" + svc, queue, _store, _backend = make_service(loaded_tape_mm=12) + request = PrintRequest( + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="https://example.com/x"), + options=PrintOptions(auto_cut=False, high_resolution=True), + ) + await svc.submit_print_job(request) + queue.submit_with_id.assert_awaited_once() + kwargs = queue.submit_with_id.await_args.kwargs + assert kwargs["auto_cut"] is False + assert kwargs["high_resolution"] is True + assert kwargs["tape_mm"] == 12 + assert "copies" not in kwargs # --------------------------------------------------------------------------- -# Race-condition fix: submit_paused() atomic path (Commit A — Issue #67) +# NoTapeLoadedError when preflight returns loaded_tape_mm=None # --------------------------------------------------------------------------- -@pytest.mark.asyncio -async def test_tape_mismatch_queue_job_never_enters_asyncio_queue() -> None: - """Prove the atomic path: submit_paused() MUST NOT place the job in the - asyncio.Queue — the job must be stored only in the paused-jobs registry. - - We verify this by inspecting the real PrintQueue's asyncio.Queue size - immediately after submit_print_job returns. If the fix uses submit_paused() - correctly, the queue is empty (qsize() == 0). If the old race-prone code - path is used (submit() then transition PAUSED), the queue has one item - (qsize() == 1) which the worker could pick up before the PAUSED transition - completes. - """ - - from unittest.mock import AsyncMock, MagicMock - from uuid import UUID as _UUID - - from app.printer_backends.snmp_helper import PreflightStatus - from app.services.print_queue import PrintQueue - from app.services.print_service import PrintService - from PIL import Image as _Image - - _race_printer_id = _UUID("aaaaaaaa-0000-0000-0000-000000000001") - - class _NeverPrint: - """Printer that must never be called in this test.""" - - id = _race_printer_id - - async def print_image(self, image, *, tape_mm, **kw): - raise AssertionError("Worker dequeued the paused job — race is present!") - - real_queue = PrintQueue([_NeverPrint()]) - # Do NOT start the worker — we're testing the submit side only. - # The asyncio.Queue size directly reveals whether the job was enqueued. - - tpl = MagicMock() - tpl.tape_mm = 24 - tpl.id = "race-tpl" - loader = MagicMock() - loader.get.return_value = tpl - - renderer = MagicMock() - renderer.render.return_value = _Image.new("1", (200, 128)) - - backend = AsyncMock() - backend.preflight_check.return_value = PreflightStatus( - hr_printer_status="idle", - loaded_tape_mm=12, # mismatch: template wants 24mm - error_flags=[], - ) - - svc = PrintService( - template_loader=loader, - renderer=renderer, - print_queue=real_queue, - lookup_service=AsyncMock(), - printer_id=_race_printer_id, - backend=backend, - ) - - req = PrintRequest( - template_id="race-tpl", - data=RawLabelData(title="T", primary_id="P", qr_payload="Q"), - on_tape_mismatch="queue", - ) - - job_id = await svc.submit_print_job(req) - - # The asyncio.Queue MUST be empty — job was submitted in PAUSED state, - # not enqueued. If this fails, the race-prone code path is still active. - # Phase 2: Printer-Key ist jetzt UUID, nicht "pt@race". - queue_size = real_queue._queues[_race_printer_id].qsize() - assert queue_size == 0, ( - f"Job was placed in asyncio.Queue (qsize={queue_size}) — " - "race-prone submit+pause path still active, fix not applied!" - ) - - job = await real_queue.get(job_id) - from app.services.job_lifecycle import JobState - - assert job.state == JobState.PAUSED, f"Expected PAUSED, got {job.state}" - assert job.error_code == "tape_mismatch" - assert job.error_detail == {"expected_mm": 24, "loaded_mm": 12} +class TestNoTapeLoaded: + @pytest.mark.asyncio + async def test_no_tape_loaded_raises(self, make_service) -> None: + """loaded_tape_mm=None → NoTapeLoadedError, queue never called.""" + svc, queue, _store, _backend = make_service(loaded_tape_mm=None) + request = PrintRequest( + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="https://example.com/x"), + ) + with pytest.raises(NoTapeLoadedError): + await svc.submit_print_job(request) + queue.submit_with_id.assert_not_awaited() + + @pytest.mark.asyncio + async def test_no_tape_store_not_called(self, make_service) -> None: + """When no tape loaded, store.save_queued must NOT be called.""" + svc, _queue, store, _backend = make_service(loaded_tape_mm=None) + request = PrintRequest( + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="https://example.com/x"), + ) + with pytest.raises(NoTapeLoadedError): + await svc.submit_print_job(request) + store.save_queued.assert_not_called() # --------------------------------------------------------------------------- -# Fix C-1: PAUSED-Pfad ruft save_queued NICHT auf +# Preflight: other printer errors # --------------------------------------------------------------------------- -async def test_tape_mismatch_queue_path_does_not_persist_db_job( - loader, renderer, queue, lookup_service, backend -) -> None: - """C-1-Fix: on_tape_mismatch=queue → save_queued wird NICHT aufgerufen. - - Vorher wurde der Job als QUEUED persistiert, obwohl er in PAUSED-State - versetzt wurde. Nach Hub-Restart würde list_pending() ihn als QUEUED - finden und sofort drucken — Doppel-Druck-Risiko. - Fix: PAUSED-Jobs bleiben in-memory-only, kein DB-Persist. - """ - backend.preflight_check.return_value = PreflightStatus( - hr_printer_status="idle", - loaded_tape_mm=12, # mismatch: template wants 24mm - error_flags=[], - ) - job = Job(id="job-1", printer_id=_PRINTER_ID, image_payload=b"", tape_mm=24, options={}) - from app.services.job_lifecycle import JobStateMachine - - JobStateMachine.transition(job, JobState.PAUSED) - queue.submit_paused_with_id.return_value = _FAKE_JOB_UUID - queue.get.return_value = job - - mock_store = AsyncMock() - - svc = _service(loader, renderer, queue, lookup_service, backend, store=mock_store) - req = PrintRequest( - template_id="qr-only-24mm", - data=RawLabelData(title="T", primary_id="P", qr_payload="Q"), - on_tape_mismatch="queue", - ) - - job_id = await svc.submit_print_job(req) - - assert isinstance(job_id, UUID) - # save_queued darf NICHT aufgerufen worden sein — kein DB-Persist für PAUSED - mock_store.save_queued.assert_not_awaited() - # submit_paused_with_id muss aber aufgerufen worden sein - queue.submit_paused_with_id.assert_awaited_once() +class TestPreflightErrors: + @pytest.mark.asyncio + async def test_preflight_offline_raises(self, make_service) -> None: + """PrinterOfflineError from preflight propagates, queue never called.""" + svc, queue, _store, backend = make_service(loaded_tape_mm=12) + backend.preflight_check.side_effect = PrinterOfflineError("unreachable") + request = PrintRequest( + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="https://example.com/x"), + ) + with pytest.raises(PrinterOfflineError): + await svc.submit_print_job(request) + queue.submit_with_id.assert_not_awaited() + + @pytest.mark.asyncio + async def test_preflight_tape_empty_raises(self, make_service) -> None: + """TapeEmptyError from preflight propagates, queue never called.""" + svc, queue, _store, backend = make_service(loaded_tape_mm=12) + backend.preflight_check.side_effect = TapeEmptyError() + request = PrintRequest( + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="https://example.com/x"), + ) + with pytest.raises(TapeEmptyError): + await svc.submit_print_job(request) + queue.submit_with_id.assert_not_awaited() + + @pytest.mark.asyncio + async def test_preflight_cover_open_raises(self, make_service) -> None: + """PrinterCoverOpenError from preflight propagates, queue never called.""" + svc, queue, _store, backend = make_service(loaded_tape_mm=12) + backend.preflight_check.side_effect = PrinterCoverOpenError() + request = PrintRequest( + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="https://example.com/x"), + ) + with pytest.raises(PrinterCoverOpenError): + await svc.submit_print_job(request) + queue.submit_with_id.assert_not_awaited() # --------------------------------------------------------------------------- -# Fix I-1: Rollback bei queue.submit_with_id Fehler +# Lookup path # --------------------------------------------------------------------------- -async def test_submit_with_failing_queue_marks_db_job_failed( - loader, renderer, queue, lookup_service, backend -) -> None: - """I-1-Fix: Wenn queue.submit_with_id wirft, muss der DB-Job auf FAILED gesetzt werden. - - Ohne Rollback bliebe eine stale QUEUED-Row in der DB ohne Worker-Gegenstück. - Nach Hub-Restart würde list_pending() sie finden und re-enqueuen — aber der - Job hat keinen gültigen Zustand mehr. - Fix: try/except um submit_with_id, bei Exception → mark_failed + re-raise. - """ - submit_error = RuntimeError("asyncio.Queue voll oder andere Fehlerursache") - queue.submit_with_id.side_effect = submit_error - - mock_store = AsyncMock() - - svc = _service(loader, renderer, queue, lookup_service, backend, store=mock_store) - req = PrintRequest( - template_id="qr-only-24mm", - data=RawLabelData(title="T", primary_id="P", qr_payload="Q"), - ) - - with pytest.raises(RuntimeError): - await svc.submit_print_job(req) - - # save_queued muss aufgerufen worden sein (DB-Persist vor submit) - mock_store.save_queued.assert_awaited_once() - # mark_failed muss aufgerufen worden sein mit passendem error-String - mock_store.mark_failed.assert_awaited_once() - call_args = mock_store.mark_failed.call_args - error_msg: str = call_args.args[1] if call_args.args else call_args.kwargs["error"] - assert "submit_failed" in error_msg - assert "RuntimeError" in error_msg +class TestLookupPath: + @pytest.mark.asyncio + async def test_lookup_path_calls_lookup_service(self, make_service) -> None: + """lookup request resolves via lookup_service, then renders + queues.""" + from app.schemas.label_data import LabelData + + lookup_svc = AsyncMock() + lookup_svc.resolve = AsyncMock( + return_value=LabelData( + title="Workshop", + primary_id="K02", + qr_payload="https://example.com/x", + source_app="hangar", + ) + ) + + backend = AsyncMock() + backend.preflight_check = AsyncMock(return_value=_preflight(18)) + queue = MagicMock() + queue.submit_with_id = AsyncMock() + store = MagicMock() + store.save_queued = AsyncMock() + engine = LayoutEngine() + + svc = PrintService( + printer_id=_PRINTER_ID, + backend=backend, + queue=queue, + store=store, + engine=engine, + lookup_service=lookup_svc, + ) + + request = PrintRequest( + content_type=ContentType.QR_TWO_LINES, + lookup=PrintLookupRequest(app="hangar", identifier="K02"), + ) + job_id = await svc.submit_print_job(request) + assert isinstance(job_id, UUID) + lookup_svc.resolve.assert_awaited_once_with("hangar", "K02") + queue.submit_with_id.assert_awaited_once() + + @pytest.mark.asyncio + async def test_data_path_bypasses_lookup(self, make_service) -> None: + """data path: lookup_service.resolve is never called.""" + lookup_svc = AsyncMock() + lookup_svc.resolve = AsyncMock() + + backend = AsyncMock() + backend.preflight_check = AsyncMock(return_value=_preflight(12)) + queue = MagicMock() + queue.submit_with_id = AsyncMock() + store = MagicMock() + store.save_queued = AsyncMock() + engine = LayoutEngine() + + svc = PrintService( + printer_id=_PRINTER_ID, + backend=backend, + queue=queue, + store=store, + engine=engine, + lookup_service=lookup_svc, + ) + + request = PrintRequest( + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="https://example.com/x"), + ) + await svc.submit_print_job(request) + lookup_svc.resolve.assert_not_awaited() diff --git a/backend/tests/unit/services/test_readiness_builder.py b/backend/tests/unit/services/test_readiness_builder.py index ad93f25..0efd1ab 100644 --- a/backend/tests/unit/services/test_readiness_builder.py +++ b/backend/tests/unit/services/test_readiness_builder.py @@ -1,6 +1,10 @@ -"""Phase 7b Cluster 1e — all 8 readiness checks: database, alembic, -template_seed, printer_runtime, printer_db_sync, snmp_discovery, -print_queue, sse_bus.""" +"""Phase 7b Cluster 1e — readiness checks: database, alembic, +printer_runtime, printer_db_sync, snmp_discovery, print_queue, sse_bus. + +Phase 1k.1a (Task 25): template_seed check removed from readiness.py +(templates table deleted). Tests for template_seed removed; fixture +renamed from async_session_with_one_template to async_session_with_one_printer. +""" from __future__ import annotations @@ -39,14 +43,14 @@ def _state_with_queue_and_bus(printer_id=None, subs=0, max_subs=100): async def test_build_readiness_with_all_ok( - async_session_with_one_template, settings_at_head, runtime_printer_id + async_session_with_one_printer, settings_at_head, runtime_printer_id ): from app.services.readiness import build_readiness_response - # Use a state that includes print_queue + event_bus so all 8 checks pass. + # Use a state that includes print_queue + event_bus so all checks pass. state = _state_with_queue_and_bus(printer_id=runtime_printer_id) body = await build_readiness_response( - async_session_with_one_template, + async_session_with_one_printer, state, settings_at_head, version="dev", @@ -55,37 +59,21 @@ async def test_build_readiness_with_all_ok( assert isinstance(body, ReadinessResponse) # printer_db_sync will be fail (runtime_printer_id has no DB row) → degraded # but all critical checks pass. - for name in ("database", "alembic", "template_seed", "printer_runtime"): + for name in ("database", "alembic", "printer_runtime"): assert body.checks[name].status == "ok", f"{name} not ok: {body.checks[name]}" assert body.status in {"ready", "degraded"} - - -async def test_build_readiness_template_seed_fails_when_empty( - async_session_empty, settings_at_head -): - from app.services.readiness import build_readiness_response - - state = _FakeState(printer_id=None) - body = await build_readiness_response( - async_session_empty, - state, - settings_at_head, - version="dev", - revision="abc", - ) - assert body.checks["template_seed"].status == "fail" - # template_seed is critical → aggregate is not-ready - assert body.status == "not-ready" + # template_seed check was removed in Phase 1k.1a + assert "template_seed" not in body.checks async def test_build_readiness_printer_runtime_fails_when_no_id( - async_session_with_one_template, settings_at_head + async_session_with_one_printer, settings_at_head ): from app.services.readiness import build_readiness_response state = _FakeState(printer_id=None) body = await build_readiness_response( - async_session_with_one_template, + async_session_with_one_printer, state, settings_at_head, version="dev", @@ -102,12 +90,12 @@ async def test_build_readiness_printer_runtime_fails_when_no_id( async def test_check_printer_db_sync_skipped_when_no_runtime_id( - async_session_with_one_template, settings_at_head + async_session_with_one_printer, settings_at_head ): from app.services.readiness import build_readiness_response body = await build_readiness_response( - async_session_with_one_template, + async_session_with_one_printer, _state_with_queue_and_bus(printer_id=None), settings_at_head, version="v", @@ -117,12 +105,12 @@ async def test_check_printer_db_sync_skipped_when_no_runtime_id( async def test_check_printer_db_sync_fail_when_id_has_no_row( - async_session_with_one_template, settings_at_head + async_session_with_one_printer, settings_at_head ): from app.services.readiness import build_readiness_response body = await build_readiness_response( - async_session_with_one_template, + async_session_with_one_printer, _state_with_queue_and_bus(printer_id=uuid4()), # any uuid; not in DB settings_at_head, version="v", @@ -132,11 +120,11 @@ async def test_check_printer_db_sync_fail_when_id_has_no_row( async def test_check_printer_db_sync_ok_when_row_exists( - async_session_with_one_template, settings_at_head + async_session_with_one_printer, settings_at_head ): pid = uuid4() # Insert a Printer row matching the runtime id - async_session_with_one_template.add( + async_session_with_one_printer.add( Printer( id=pid, name="x", @@ -146,12 +134,12 @@ async def test_check_printer_db_sync_ok_when_row_exists( enabled=True, ) ) - await async_session_with_one_template.flush() + await async_session_with_one_printer.flush() from app.services.readiness import build_readiness_response body = await build_readiness_response( - async_session_with_one_template, + async_session_with_one_printer, _state_with_queue_and_bus(printer_id=pid), settings_at_head, version="v", @@ -166,10 +154,10 @@ async def test_check_printer_db_sync_ok_when_row_exists( async def test_check_snmp_discovery_fail_when_no_probe_yet( - async_session_with_one_template, settings_at_head + async_session_with_one_printer, settings_at_head ): pid = uuid4() - async_session_with_one_template.add( + async_session_with_one_printer.add( Printer( id=pid, name="x", @@ -179,11 +167,11 @@ async def test_check_snmp_discovery_fail_when_no_probe_yet( enabled=True, ) ) - await async_session_with_one_template.flush() + await async_session_with_one_printer.flush() from app.services.readiness import build_readiness_response body = await build_readiness_response( - async_session_with_one_template, + async_session_with_one_printer, _state_with_queue_and_bus(printer_id=pid), settings_at_head, version="v", @@ -192,11 +180,9 @@ async def test_check_snmp_discovery_fail_when_no_probe_yet( assert body.checks["snmp_discovery"].status == "fail" -async def test_check_snmp_discovery_ok_when_fresh( - async_session_with_one_template, settings_at_head -): +async def test_check_snmp_discovery_ok_when_fresh(async_session_with_one_printer, settings_at_head): pid = uuid4() - async_session_with_one_template.add( + async_session_with_one_printer.add( Printer( id=pid, name="x", @@ -206,7 +192,7 @@ async def test_check_snmp_discovery_ok_when_fresh( enabled=True, ) ) - async_session_with_one_template.add( + async_session_with_one_printer.add( PrinterStatusCache( printer_id=pid, captured_at=datetime.now(UTC), @@ -214,11 +200,11 @@ async def test_check_snmp_discovery_ok_when_fresh( raw_block=None, ) ) - await async_session_with_one_template.flush() + await async_session_with_one_printer.flush() from app.services.readiness import build_readiness_response body = await build_readiness_response( - async_session_with_one_template, + async_session_with_one_printer, _state_with_queue_and_bus(printer_id=pid), settings_at_head, version="v", @@ -229,10 +215,10 @@ async def test_check_snmp_discovery_ok_when_fresh( async def test_check_snmp_discovery_stale_between_90_and_600( - async_session_with_one_template, settings_at_head + async_session_with_one_printer, settings_at_head ): pid = uuid4() - async_session_with_one_template.add( + async_session_with_one_printer.add( Printer( id=pid, name="x", @@ -242,7 +228,7 @@ async def test_check_snmp_discovery_stale_between_90_and_600( enabled=True, ) ) - async_session_with_one_template.add( + async_session_with_one_printer.add( PrinterStatusCache( printer_id=pid, captured_at=datetime.now(UTC) - timedelta(seconds=200), @@ -250,11 +236,11 @@ async def test_check_snmp_discovery_stale_between_90_and_600( raw_block=None, ) ) - await async_session_with_one_template.flush() + await async_session_with_one_printer.flush() from app.services.readiness import build_readiness_response body = await build_readiness_response( - async_session_with_one_template, + async_session_with_one_printer, _state_with_queue_and_bus(printer_id=pid), settings_at_head, version="v", @@ -269,7 +255,7 @@ async def test_check_snmp_discovery_stale_between_90_and_600( async def test_check_print_queue_fail_when_missing( - async_session_with_one_template, settings_at_head + async_session_with_one_printer, settings_at_head ): state = _FakeState(printer_id=uuid4()) # NO print_queue attribute @@ -277,7 +263,7 @@ async def test_check_print_queue_fail_when_missing( from app.services.readiness import build_readiness_response body = await build_readiness_response( - async_session_with_one_template, + async_session_with_one_printer, state, settings_at_head, version="v", @@ -292,12 +278,12 @@ async def test_check_print_queue_fail_when_missing( async def test_check_sse_bus_fail_when_subscribers_at_max( - async_session_with_one_template, settings_at_head + async_session_with_one_printer, settings_at_head ): from app.services.readiness import build_readiness_response body = await build_readiness_response( - async_session_with_one_template, + async_session_with_one_printer, _state_with_queue_and_bus(printer_id=uuid4(), subs=100, max_subs=100), settings_at_head, version="v", diff --git a/backend/tests/unit/services/test_svg_renderer.py b/backend/tests/unit/services/test_svg_renderer.py deleted file mode 100644 index da359e1..0000000 --- a/backend/tests/unit/services/test_svg_renderer.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Unit tests for app.services.svg_renderer. - -Verifies that the SVG renderer produces valid XML with correct structure, -pure-vector ```` elements, a QR ```` element, a viewBox that -matches the tape dimensions, and a title annotation strip above the tape -outline. -""" - -from __future__ import annotations - -import xml.etree.ElementTree as ET - -import pytest -from app.services.label_renderer import TAPE_HEIGHT_PX -from app.services.svg_renderer import ( - _ANNOTATION_HEIGHT_PX, - render_template_svg, -) - -SVG_NS = "http://www.w3.org/2000/svg" - - -def _findall(root: ET.Element, local_tag: str) -> list[ET.Element]: - """Find all descendant elements matching a local tag name (ignoring namespace).""" - return root.findall(f".//{{{SVG_NS}}}{local_tag}") - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _minimal_definition( - tape_mm: int = 12, - *, - include_qr: bool = True, - include_text: bool = True, -) -> dict[str, object]: - elements: list[dict[str, object]] = [] - if include_qr: - elements.append({"type": "qr", "x": 8, "y": 13, "size": 60, "data_field": "qr_payload"}) - if include_text: - elements.append({"type": "text", "x": 80, "y": 18, "font_size": 20, "field": "primary_id"}) - return { - "id": f"test-{tape_mm}mm", - "tape_mm": tape_mm, - "elements": elements, - } - - -def _sample() -> dict[str, object]: - return {"primary_id": "TestValue", "qr_payload": "https://example.com/"} - - -# --------------------------------------------------------------------------- -# Core contract -# --------------------------------------------------------------------------- - - -def test_svg_renderer_produces_valid_xml_with_text_elements() -> None: - definition = _minimal_definition() - svg = render_template_svg(definition, _sample()) - - assert "" in svg - assert "TestValue" in svg # text element rendered as pure - assert "viewBox" in svg - - # Must parse as valid XML without exceptions. - root = ET.fromstring(svg) - assert root.tag == f"{{{SVG_NS}}}svg" - - -def test_svg_contains_no_base64_image_embeds() -> None: - """Text and QR must be pure-vector — no raster embeds allowed.""" - definition = _minimal_definition() - svg = render_template_svg(definition, _sample()) - - assert "data:image/png;base64" not in svg - assert "data:image/jpeg;base64" not in svg - - -def test_svg_viewbox_matches_tape_dimensions() -> None: - """viewBox width=600, height=TAPE_HEIGHT_PX[tape_mm] + annotation strip.""" - for tape_mm in (12, 18, 24): - definition = _minimal_definition(tape_mm) - svg = render_template_svg(definition, _sample()) - root = ET.fromstring(svg) - - expected_h = TAPE_HEIGHT_PX[tape_mm] + _ANNOTATION_HEIGHT_PX - vb = root.attrib.get("viewBox", "") - parts = vb.split() - assert len(parts) == 4, f"Unexpected viewBox for tape_mm={tape_mm}: {vb!r}" - assert int(parts[2]) == 600, f"viewBox width should be 600 for tape_mm={tape_mm}" - assert int(parts[3]) == expected_h, ( - f"viewBox height should be {expected_h} for tape_mm={tape_mm}, got {parts[3]}" - ) - - -def test_svg_contains_tape_outline_rect() -> None: - """A gray must mark the printable tape area.""" - definition = _minimal_definition() - svg = render_template_svg(definition, _sample()) - root = ET.fromstring(svg) - - rects = _findall(root, "rect") - assert rects, "No element found — tape outline is missing" - # The rect should have a gray stroke. - strokes = [el.attrib.get("stroke", "") for el in rects] - assert any(s == "#aaa" for s in strokes), f"Expected gray stroke #aaa on rect, got {strokes}" - - -def test_svg_contains_qr_path_element() -> None: - """QR codes must be rendered as a pure-vector , not .""" - definition = _minimal_definition(include_text=False) - svg = render_template_svg(definition, _sample()) - root = ET.fromstring(svg) - - paths = _findall(root, "path") - assert paths, "No element found — QR code is not rendered as vector" - - images = _findall(root, "image") - assert not images, f"Found unexpected elements: {images}" - - -def test_svg_text_value_is_present() -> None: - """The sample field value must appear verbatim in a element.""" - definition = _minimal_definition(include_qr=False) - svg = render_template_svg(definition, _sample()) - root = ET.fromstring(svg) - - texts = _findall(root, "text") - # Filter out the annotation strip (which has fill="#666") — look for user data. - user_texts = [el for el in texts if el.attrib.get("fill") == "black"] - values = [el.text or "" for el in user_texts] - assert any("TestValue" in v for v in values), ( - f"Expected 'TestValue' in a element, got: {values}" - ) - - -def test_svg_annotation_strip_shows_template_key() -> None: - """The annotation strip above the tape must show the template id.""" - definition = _minimal_definition() - svg = render_template_svg(definition, _sample()) - - assert "test-12mm" in svg # the template id from _minimal_definition - - -def test_svg_list_secondary_field_joined_with_pipe() -> None: - """List values (e.g. 'secondary') must be joined with ' | '.""" - definition: dict[str, object] = { - "id": "test-list", - "tape_mm": 18, - "elements": [{"type": "text", "x": 10, "y": 20, "font_size": 14, "field": "secondary"}], - } - sample: dict[str, object] = {"secondary": ["Alpha", "Beta"], "qr_payload": "x"} - svg = render_template_svg(definition, sample) - assert "Alpha | Beta" in svg - - -def test_svg_xml_special_chars_escaped() -> None: - """Ampersands and angle brackets in sample data must be XML-escaped.""" - definition: dict[str, object] = { - "id": "test-escape", - "tape_mm": 12, - "elements": [{"type": "text", "x": 10, "y": 20, "font_size": 14, "field": "title"}], - } - sample: dict[str, object] = {"title": "A & B < C > D", "qr_payload": "x"} - svg = render_template_svg(definition, sample) - - # The raw ampersand/angle must NOT appear outside CDATA. - # The escaped forms must appear. - assert "A & B < C > D" in svg - # Must still parse as valid XML. - ET.fromstring(svg) - - -def test_svg_unsupported_tape_mm_raises() -> None: - with pytest.raises(ValueError, match="Unsupported tape_mm"): - render_template_svg({"id": "bad", "tape_mm": 99, "elements": []}, {}) - - -def test_svg_all_tape_sizes_produce_different_heights() -> None: - """12mm, 18mm and 24mm tapes must result in different SVG heights.""" - heights = set() - for tape_mm in (12, 18, 24): - definition = _minimal_definition(tape_mm) - svg = render_template_svg(definition, _sample()) - root = ET.fromstring(svg) - heights.add(int(root.attrib.get("height", 0))) - assert len(heights) == 3, f"Expected 3 distinct heights, got: {heights}" diff --git a/backend/tests/unit/services/test_template_loader.py b/backend/tests/unit/services/test_template_loader.py deleted file mode 100644 index ad51efa..0000000 --- a/backend/tests/unit/services/test_template_loader.py +++ /dev/null @@ -1,444 +0,0 @@ -"""Tests for TemplateLoader — YAML parsing + registry validation.""" - -from collections.abc import Iterator -from pathlib import Path -from textwrap import dedent - -import pytest -from app.integrations.registry import IntegrationRegistry -from app.schemas.label_data import LabelData -from app.services.template_loader import ( - TemplateLoader, - TemplateValidationError, -) - - -class _StubPlugin: - def __init__(self, name: str) -> None: - self.name = name - self.display_name = name.title() - - async def lookup(self, identifier: str) -> LabelData: - raise NotImplementedError - - -@pytest.fixture(autouse=True) -def _populate_registry() -> Iterator[None]: - """Each test starts with snipeit/spoolman/grocy registered.""" - IntegrationRegistry._plugins.clear() - TemplateLoader._cache.clear() - IntegrationRegistry.register(_StubPlugin("snipeit")) - IntegrationRegistry.register(_StubPlugin("spoolman")) - IntegrationRegistry.register(_StubPlugin("grocy")) - yield - IntegrationRegistry._plugins.clear() - TemplateLoader._cache.clear() - - -def _write_yaml(tmp_path: Path, name: str, body: str) -> Path: - p = tmp_path / name - p.write_text(dedent(body).lstrip()) - return p - - -def test_load_single_parses_valid_yaml(tmp_path: Path) -> None: - """Happy path — well-formed YAML with a known integration.""" - path = _write_yaml( - tmp_path, - "x.yaml", - """ - schema_version: 1 - id: x - name: X - app: snipeit - tape_mm: 24 - elements: - - { type: qr, x: 0, y: 0, size: 100, data_field: qr_payload } - """, - ) - template = TemplateLoader._load_single(path) - assert template.id == "x" - assert template.app == "snipeit" - assert len(template.elements) == 1 - - -def test_load_single_accepts_app_null_for_generic_template(tmp_path: Path) -> None: - """Generic templates have app: null and skip the registry check.""" - path = _write_yaml( - tmp_path, - "qr-only.yaml", - """ - schema_version: 1 - id: qr-only - name: QR only - app: null - tape_mm: 24 - elements: - - { type: qr, x: 0, y: 0, size: 100, data_field: qr_payload } - """, - ) - template = TemplateLoader._load_single(path) - assert template.app is None - - -def test_load_single_rejects_non_mapping_root(tmp_path: Path) -> None: - """Top-level YAML must be a mapping (dict), not a list or string.""" - path = _write_yaml(tmp_path, "list.yaml", "- not_a_mapping\n") - with pytest.raises(TemplateValidationError, match="must be a mapping"): - TemplateLoader._load_single(path) - - -def test_load_single_rejects_invalid_yaml_syntax(tmp_path: Path) -> None: - """Genuine YAML parse errors propagate as TemplateValidationError.""" - path = _write_yaml(tmp_path, "broken.yaml", "id: x\n bad: indent\nname [\n") - with pytest.raises(TemplateValidationError, match="YAML parse error"): - TemplateLoader._load_single(path) - - -def test_load_single_rejects_missing_required_fields(tmp_path: Path) -> None: - """Missing required field surfaces the Pydantic ValidationError detail.""" - path = _write_yaml( - tmp_path, - "incomplete.yaml", - """ - schema_version: 1 - id: x - name: X - """, - ) - with pytest.raises(TemplateValidationError, match="schema validation failed"): - TemplateLoader._load_single(path) - - -def test_load_single_rejects_unknown_integration(tmp_path: Path) -> None: - """An app value not in IntegrationRegistry fails with a helpful message.""" - path = _write_yaml( - tmp_path, - "future.yaml", - """ - schema_version: 1 - id: future - name: Future - app: not_a_real_integration - tape_mm: 24 - elements: [] - """, - ) - with pytest.raises( - TemplateValidationError, match=r"unknown integration 'not_a_real_integration'" - ): - TemplateLoader._load_single(path) - - -def test_load_dir_caches_all_templates(tmp_path: Path) -> None: - _write_yaml( - tmp_path, - "a.yaml", - """ - schema_version: 1 - id: a - name: A - app: snipeit - tape_mm: 24 - elements: [] - """, - ) - _write_yaml( - tmp_path, - "b.yaml", - """ - schema_version: 1 - id: b - name: B - app: grocy - tape_mm: 18 - elements: [] - """, - ) - - TemplateLoader.load_dir(tmp_path) - assert sorted(TemplateLoader._cache) == ["a", "b"] - - -def test_load_dir_raises_on_first_bad_file(tmp_path: Path) -> None: - """Strict failure — shipping broken seed YAML is a build-time bug.""" - _write_yaml( - tmp_path, - "good.yaml", - """ - schema_version: 1 - id: good - name: Good - app: snipeit - tape_mm: 24 - elements: [] - """, - ) - _write_yaml(tmp_path, "bad.yaml", "this is not yaml: [unclosed\n") - - with pytest.raises(TemplateValidationError, match=r"bad\.yaml"): - TemplateLoader.load_dir(tmp_path) - - -def test_load_dir_ignores_non_yaml_files(tmp_path: Path) -> None: - """README.md or .gitkeep next to YAMLs are not loaded.""" - _write_yaml( - tmp_path, - "a.yaml", - """ - schema_version: 1 - id: a - name: A - app: snipeit - tape_mm: 24 - elements: [] - """, - ) - (tmp_path / "README.md").write_text("not yaml") - (tmp_path / ".gitkeep").write_text("") - - TemplateLoader.load_dir(tmp_path) - assert list(TemplateLoader._cache) == ["a"] - - -def test_get_returns_cached_template(tmp_path: Path) -> None: - _write_yaml( - tmp_path, - "a.yaml", - """ - schema_version: 1 - id: a - name: A - app: snipeit - tape_mm: 24 - elements: [] - """, - ) - TemplateLoader.load_dir(tmp_path) - assert TemplateLoader.get("a").id == "a" - - -def test_get_raises_keyerror_for_unknown_id(tmp_path: Path) -> None: - from app.services.template_loader import TemplateNotFoundError - - TemplateLoader.load_dir(tmp_path) # empty dir - with pytest.raises(TemplateNotFoundError): - TemplateLoader.get("nope") - - -def test_all_returns_shallow_copy(tmp_path: Path) -> None: - _write_yaml( - tmp_path, - "a.yaml", - """ - schema_version: 1 - id: a - name: A - app: snipeit - tape_mm: 24 - elements: [] - """, - ) - TemplateLoader.load_dir(tmp_path) - snapshot = TemplateLoader.all() - snapshot.clear() - assert list(TemplateLoader._cache) == ["a"] - - -def test_by_app_filters_to_matching_templates(tmp_path: Path) -> None: - for spec_id, app in [("a", "snipeit"), ("b", "snipeit"), ("c", "grocy"), ("d", None)]: - app_yaml = "null" if app is None else app - _write_yaml( - tmp_path, - f"{spec_id}.yaml", - f""" - schema_version: 1 - id: {spec_id} - name: {spec_id.upper()} - app: {app_yaml} - tape_mm: 24 - elements: [] - """, - ) - TemplateLoader.load_dir(tmp_path) - - snipeit_templates = TemplateLoader.by_app("snipeit") - assert sorted(t.id for t in snipeit_templates) == ["a", "b"] - - generic_templates = TemplateLoader.by_app(None) - assert [t.id for t in generic_templates] == ["d"] - - -def test_reload_clears_cache_then_loads_fresh(tmp_path: Path) -> None: - """reload(dir) discards old entries and reads the directory anew.""" - initial = _write_yaml( - tmp_path, - "a.yaml", - """ - schema_version: 1 - id: a - name: Original - app: snipeit - tape_mm: 24 - elements: [] - """, - ) - TemplateLoader.load_dir(tmp_path) - assert TemplateLoader.get("a").name == "Original" - - initial.write_text( - dedent(""" - schema_version: 1 - id: a - name: Updated - app: snipeit - tape_mm: 24 - elements: [] - """).lstrip() - ) - _write_yaml( - tmp_path, - "b.yaml", - """ - schema_version: 1 - id: b - name: B - app: grocy - tape_mm: 18 - elements: [] - """, - ) - - TemplateLoader.reload(tmp_path) - - assert TemplateLoader.get("a").name == "Updated" - assert sorted(TemplateLoader._cache) == ["a", "b"] - - -def test_reload_removes_stale_entries(tmp_path: Path) -> None: - """A file that disappears between loads is dropped from the cache.""" - p = _write_yaml( - tmp_path, - "a.yaml", - """ - schema_version: 1 - id: a - name: A - app: snipeit - tape_mm: 24 - elements: [] - """, - ) - TemplateLoader.load_dir(tmp_path) - assert "a" in TemplateLoader._cache - - p.unlink() - TemplateLoader.reload(tmp_path) - assert "a" not in TemplateLoader._cache - - -def test_load_single_wraps_oserror_in_template_validation_error(tmp_path: Path) -> None: - """OSError from path.read_text() must be wrapped, not propagated raw.""" - # File doesn't exist — read_text raises FileNotFoundError (a subclass of OSError) - missing = tmp_path / "does-not-exist.yaml" - with pytest.raises(TemplateValidationError, match="could not read file"): - TemplateLoader._load_single(missing) - - -def test_load_dir_atomic_on_failure_keeps_previous_cache(tmp_path: Path) -> None: - """A failure in any file leaves the cache exactly as it was before the call.""" - # First, populate the cache with a known-good template - _write_yaml( - tmp_path, - "good.yaml", - """ - schema_version: 1 - id: good - name: Good - app: snipeit - tape_mm: 24 - elements: [] - """, - ) - TemplateLoader.load_dir(tmp_path) - snapshot = dict(TemplateLoader._cache) - - # Add a broken sibling and try to reload - _write_yaml(tmp_path, "bad.yaml", "this is not yaml: [unclosed\n") - - with pytest.raises(TemplateValidationError): - TemplateLoader.load_dir(tmp_path) - - # Cache is the pre-call snapshot — `good` is still loaded - assert dict(TemplateLoader._cache) == snapshot - assert "good" in TemplateLoader._cache - - -def test_load_dir_rejects_duplicate_ids(tmp_path: Path) -> None: - """Two YAMLs declaring the same id must fail loudly.""" - _write_yaml( - tmp_path, - "a.yaml", - """ - schema_version: 1 - id: duplicate - name: First - app: snipeit - tape_mm: 24 - elements: [] - """, - ) - _write_yaml( - tmp_path, - "b.yaml", - """ - schema_version: 1 - id: duplicate - name: Second - app: grocy - tape_mm: 18 - elements: [] - """, - ) - - with pytest.raises(TemplateValidationError, match=r"duplicate template id 'duplicate'"): - TemplateLoader.load_dir(tmp_path) - - # Neither got into the cache — the failure happens during the staging loop - assert "duplicate" not in TemplateLoader._cache - - -def test_get_unknown_raises_template_not_found() -> None: - TemplateLoader._cache.clear() - from app.services.template_loader import TemplateNotFoundError - - with pytest.raises(TemplateNotFoundError) as exc: - TemplateLoader.get("does-not-exist") - assert "does-not-exist" in str(exc.value) - - -def test_reload_preserves_cache_on_failure(tmp_path: Path) -> None: - """reload() on a directory with a broken file leaves the previous cache intact.""" - _write_yaml( - tmp_path, - "a.yaml", - """ - schema_version: 1 - id: a - name: A - app: snipeit - tape_mm: 24 - elements: [] - """, - ) - TemplateLoader.load_dir(tmp_path) - assert "a" in TemplateLoader._cache - - # Add a broken YAML; reload must fail without wiping the cache - _write_yaml(tmp_path, "broken.yaml", "this is not yaml: [unclosed\n") - - with pytest.raises(TemplateValidationError): - TemplateLoader.reload(tmp_path) - - # Previous cache is intact - assert "a" in TemplateLoader._cache diff --git a/backend/tests/unit/test_batch_dispatch.py b/backend/tests/unit/test_batch_dispatch.py index d8db1f1..40fb4b1 100644 --- a/backend/tests/unit/test_batch_dispatch.py +++ b/backend/tests/unit/test_batch_dispatch.py @@ -1,285 +1,112 @@ -"""Unit-Tests für den Batch-Dispatcher (best-effort, pro-Item-Validation). +"""Unit-Tests für den Batch-Dispatcher. -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(). +Phase 1k.1a Task 17: MixedTapeSizesError + tape-consistency-Check entfernt. +dispatch_batch nimmt jetzt gemischte ContentTypes entgegen und delegiert +sofort an service.submit_batch_job(items, half_cut=...). """ from __future__ import annotations -from uuid import UUID, uuid4 +from unittest.mock import AsyncMock +from uuid import uuid4 import pytest +from app.schemas.content_type import ContentType 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: - """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] - - 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: - """Minimal PrinterBackend-Stub für half_cut_supported-Tests.""" - - def __init__(self, half_cut_supported: bool = True) -> None: - self.half_cut_supported = half_cut_supported - - -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"), - ) - - -# --------------------------------------------------------------------------- -# 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 - - -async def test_dispatch_partial_failure_keeps_going(): - """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 1k.2: half_cut logic — jetzt als batch-global flag, nicht per-item -# --------------------------------------------------------------------------- - - -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.submit_batch_calls) == 1 - _, half_cut = service.submit_batch_calls[0] - assert half_cut is True - - -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=False) - items = [_item() for _ in range(3)] - - await dispatch_batch(service, items, backend=backend) - - assert len(service.submit_batch_calls) == 1 - _, half_cut = service.submit_batch_calls[0] - assert half_cut is False - - -async def test_dispatch_half_cut_override_false_disables_half_cut(): - """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) - - assert len(service.submit_batch_calls) == 1 - _, half_cut = service.submit_batch_calls[0] - assert half_cut is False - - -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=True) - items = [_item()] - - await dispatch_batch(service, items, half_cut_override=True, backend=backend) - - assert len(service.submit_batch_calls) == 1 - _, half_cut = service.submit_batch_calls[0] - assert half_cut is True - - -# --------------------------------------------------------------------------- -# Phase 1k.2: MixedTapeSizesError -# --------------------------------------------------------------------------- - - -async def test_dispatch_batch_uses_enqueue_batch_path(): - """dispatch_batch mit validen Items ruft submit_batch_job genau einmal auf.""" - service = _FakePrintService() - items = [_item(), _item(), _item()] - - job_ids, errors = await dispatch_batch(service, items) - - 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 - - -# --------------------------------------------------------------------------- -# 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 app.services.batch_dispatch import dispatch_batch + + +class TestBatchDispatch: + @pytest.mark.asyncio + async def test_mixed_content_types_all_accepted(self) -> None: + """Gemischte ContentTypes dürfen in einem Batch kombiniert werden.""" + service = AsyncMock() + job_ids = [uuid4(), uuid4()] + service.submit_batch_job = AsyncMock(return_value=job_ids) + items = [ + PrintRequest( + content_type=ContentType.QR_TWO_LINES, + data=RawLabelData( + primary_id="A", + title="T", + qr_payload="https://example.com/a", + ), + ), + PrintRequest( + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="https://example.com/b"), + ), + ] + await dispatch_batch(service=service, items=items) + service.submit_batch_job.assert_awaited_once() + + @pytest.mark.asyncio + async def test_empty_items_rejected(self) -> None: + """Leere Item-Liste löst ValueError aus.""" + service = AsyncMock() + with pytest.raises(ValueError, match="at least one"): + await dispatch_batch(service=service, items=[]) + + @pytest.mark.asyncio + async def test_single_item_dispatched(self) -> None: + """Ein einzelnes Item wird korrekt an submit_batch_job weitergeleitet.""" + service = AsyncMock() + job_ids = [uuid4()] + service.submit_batch_job = AsyncMock(return_value=job_ids) + items = [ + PrintRequest( + content_type=ContentType.TEXT_ONE_LINE, + data=RawLabelData(primary_id="X"), + ), + ] + await dispatch_batch(service=service, items=items) + service.submit_batch_job.assert_awaited_once() + call_args = service.submit_batch_job.call_args + # Erstes Positional-Argument sind unsere Items + assert call_args.args[0] == items + + @pytest.mark.asyncio + async def test_return_value_forwarded(self) -> None: + """Rückgabe von submit_batch_job wird 1:1 weitergereicht.""" + service = AsyncMock() + expected_job_ids = [uuid4(), uuid4(), uuid4()] + service.submit_batch_job = AsyncMock(return_value=expected_job_ids) + items = [ + PrintRequest( + content_type=ContentType.QR_ONE_LINE, + data=RawLabelData(primary_id="Y", qr_payload="https://example.com"), + ) + for _ in range(3) + ] + result = await dispatch_batch(service=service, items=items) + assert result == expected_job_ids + + @pytest.mark.asyncio + async def test_half_cut_default_is_false(self) -> None: + """half_cut defaults to False when not explicitly passed.""" + service = AsyncMock() + service.submit_batch_job = AsyncMock(return_value=[uuid4()]) + items = [ + PrintRequest( + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="https://example.com"), + ), + ] + await dispatch_batch(service=service, items=items) + _, kwargs = service.submit_batch_job.call_args + assert kwargs["half_cut"] is False + + @pytest.mark.asyncio + async def test_half_cut_forwarded_when_true(self) -> None: + """half_cut=True wird an submit_batch_job weitergeleitet.""" + service = AsyncMock() + service.submit_batch_job = AsyncMock(return_value=[uuid4()]) + items = [ + PrintRequest( + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="https://example.com"), + ), + ] + await dispatch_batch(service=service, items=items, half_cut=True) + _, kwargs = service.submit_batch_job.call_args + assert kwargs["half_cut"] is True diff --git a/backend/tests/unit/test_batch_schema.py b/backend/tests/unit/test_batch_schema.py index 8402a4f..4bef389 100644 --- a/backend/tests/unit/test_batch_schema.py +++ b/backend/tests/unit/test_batch_schema.py @@ -11,8 +11,10 @@ def _sample_item() -> PrintRequest: + from app.schemas.content_type import ContentType + return PrintRequest( - template_id="hangar-furniture-12mm", + content_type=ContentType.QR_TWO_LINES, data=RawLabelData( title="Kallax 10 Fach 2-3", primary_id="HH-AK-KX10-F0203", @@ -38,15 +40,15 @@ def test_batch_request_caps_at_500(): def test_batch_response_with_all_succeeded(): + # R2-3: errors field removed from BatchResponse resp = BatchResponse( batch_id=uuid4(), printer_id=uuid4(), queued_at="2026-05-30T19:42:01Z", job_ids=[str(uuid4()) for _ in range(5)], - errors=[], ) assert len(resp.job_ids) == 5 - assert resp.errors == [] + assert not hasattr(resp, "errors") def test_batch_error_with_required_fields(): diff --git a/docs/superpowers/plans/2026-06-05-phase-1k1a-layout-engine-plan.md b/docs/superpowers/plans/2026-06-05-phase-1k1a-layout-engine-plan.md new file mode 100644 index 0000000..5287ebd --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-phase-1k1a-layout-engine-plan.md @@ -0,0 +1,3338 @@ +# Phase 1k.1a Hub Layout-Engine 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:** Ersetzt die 21 hartcodierten YAML-Templates durch eine semantische tape-unabhaengige LayoutEngine mit TapeGeometry-Tabelle (7 Tape-Groessen) und 7 ContentTypes auf dem Hub-Backend (Python/FastAPI). + +**Architecture:** LayoutEngine.render(tape_mm, content_type, data) ersetzt LabelRenderer komplett. Hard-Cut Migration ohne Legacy-Kompatibilitaet — alle Templates, TemplateLoader, /api/templates Routes und der PAUSED-Job-Pfad werden geloescht. `jobs.template_key` bleibt als nullable Audit-Spalte erhalten, neue Spalten `jobs.content_type` + `jobs.rendered_tape_mm` werden deterministisch backfilled. + +**Tech Stack:** Python 3.12, FastAPI, Pydantic v2, PIL/Pillow, qrcode, SQLAlchemy + Alembic, pytest. + +**Spec Reference:** `docs/superpowers/specs/2026-06-05-phase-1k1-layout-engine-design.md` (commit 6de07cd, approved nach 7 Review-Runden) + +**Issue:** strausmann/Label-Printer-Hub#103 (Phase 1k.1 unter Umbrella #101) + +**Working Branch:** Neuer Branch `feat/phase-1k1a-layout-engine` von `main`. (NICHT auf `spec/phase-1k1-layout-engine` arbeiten — die Spec wird separat gemerged.) + +--- + +## File Structure + +**Created (new files):** + +| Datei | Verantwortung | +|-------|---------------| +| `backend/app/schemas/tape_geometry.py` | TapeGeometry Pydantic-Model + `TAPE_GEOMETRY` dict (7 Eintraege) | +| `backend/app/schemas/content_type.py` | `ContentType` Enum (7 Werte) | +| `backend/app/schemas/label_data_item.py` | `LabelDataItem` (item + optional qr_payload) fuer qr_with_listing | +| `backend/app/services/layout_engine.py` | `LayoutEngine.render()` + 7 `_render_*` Methoden + Validation | +| `backend/app/printer_backends/exceptions.py` (Erweiterung) | `UnsupportedTapeError`, `NoTapeLoadedError`, `ContentTypeDataMismatchError` | + +**Modified:** + +| Datei | Aenderung | +|-------|-----------| +| `backend/app/schemas/label_data.py` | title/primary_id/qr_payload optional; items: tuple[LabelDataItem,...] | +| `backend/app/schemas/print_request.py` | content_type-Feld; template_id + on_tape_mismatch raus | +| `backend/app/services/print_service.py` | LayoutEngine statt LabelRenderer, kein TapeMismatchError-Pfad mehr | +| `backend/app/services/print_queue.py` | _rerender_from_db nutzt LayoutEngine.render(); PAUSED-State raus | +| `backend/app/services/batch_dispatch.py` | MixedTapeSizesError + Tape-Konsistenz-Check raus | +| `backend/app/api/routes/print.py` | APIRouter(prefix="/api"); content_type-Schema; resume-Route raus | +| `backend/app/api/routes/batch.py` | content_type, MixedTapeSizesError-Mapping raus | +| `backend/app/api/routes/jobs.py` | resume-Route raus | +| `backend/app/api/error_handlers.py` | UnsupportedTapeError 409, ContentTypeDataMismatchError 422, NoTapeLoadedError 409; alte raus | +| `backend/app/main.py` | templates-Router-Registrierungen weg; LayoutEngine in app.state | +| `backend/app/lifespan.py` | TemplateLoader-Preload raus | + +**Deleted:** + +| Datei | Begruendung | +|-------|------------| +| `backend/app/services/label_renderer.py` | Durch LayoutEngine ersetzt | +| `backend/app/services/template_loader.py` | Templates obsolet | +| `backend/app/services/svg_renderer.py` | v1 SVG-Renderer obsolet | +| `backend/app/schemas/template.py` | v1 Schema obsolet | +| `backend/app/schemas/template_read.py` | Read-Schema obsolet | +| `backend/app/models/template.py` | SQLAlchemy-Model obsolet | +| `backend/app/repositories/templates.py` | templates Tabelle dropped | +| `backend/app/api/routes/templates.py` | /api/templates/* weg | +| `backend/app/api/routes/templates_preview.py` | /api/templates/{key}/preview-* weg | +| `backend/app/seed/templates/*.yaml` | Alle 21 YAML-Files | +| `backend/tests/**/test_template*` | Alle Template-Tests | +| `backend/tests/**/test_label_renderer*` | Alle alten Renderer-Tests | +| `backend/tests/**/test_svg_renderer*` | SVG-Renderer-Tests | + +--- + +## Task Execution Order (Critical) + +Tasks 1-13 (Foundation + LayoutEngine) muessen VOR den Service-Refactors (Tasks 14-17) abgeschlossen sein. Tasks 22-23 (DB-Migration + File-Cleanup) sind die LETZTEN Schritte vor der Final-Integration. + +Reihenfolge: +1. Foundation (Tasks 1-4) +2. Exceptions (Task 5) +3. LayoutEngine (Tasks 6-13) +4. Request-Schema-Anpassung (Task 14) +5. Service-Refactors (Tasks 15-17) +6. Route-Refactors (Tasks 18-20) +7. Error-Handler (Task 21) +8. DB-Migration (Task 22) +9. File-Cleanup (Task 23) +10. Main/Lifespan-Cleanup (Task 24) +11. Final Integration (Task 25) + +--- + +### Task 1: TapeGeometry Schema + +**Files:** +- Create: `backend/app/schemas/tape_geometry.py` +- Test: `backend/tests/unit/schemas/test_tape_geometry.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/schemas/test_tape_geometry.py +"""Unit tests for TapeGeometry model and TAPE_GEOMETRY constants table.""" + +from __future__ import annotations + +import pytest +from app.schemas.tape_geometry import TAPE_GEOMETRY, TapeGeometry + + +class TestTapeGeometryModel: + def test_valid_values_accepted(self) -> None: + geom = TapeGeometry( + printable_px=70, qr_max_px=66, qr_padding_px=2, + text_start_x=72, line_spacing_px=4, + font_xl=22, font_l=18, font_m=14, font_s=10, + ) + assert geom.printable_px == 70 + + def test_zero_printable_px_rejected(self) -> None: + with pytest.raises(ValueError, match="greater than 0"): + TapeGeometry( + printable_px=0, qr_max_px=66, qr_padding_px=2, + text_start_x=72, line_spacing_px=4, + font_xl=22, font_l=18, font_m=14, font_s=10, + ) + + def test_negative_qr_padding_rejected(self) -> None: + with pytest.raises(ValueError, match="greater than or equal to 0"): + TapeGeometry( + printable_px=70, qr_max_px=66, qr_padding_px=-1, + text_start_x=72, line_spacing_px=4, + font_xl=22, font_l=18, font_m=14, font_s=10, + ) + + def test_frozen_immutable(self) -> None: + geom = TapeGeometry( + printable_px=70, qr_max_px=66, qr_padding_px=2, + text_start_x=72, line_spacing_px=4, + font_xl=22, font_l=18, font_m=14, font_s=10, + ) + with pytest.raises(ValueError): + geom.printable_px = 100 # type: ignore[misc] + + +class TestTapeGeometryConstants: + def test_all_seven_sizes_defined(self) -> None: + assert set(TAPE_GEOMETRY.keys()) == {4, 6, 9, 12, 18, 24, 62} + + def test_12mm_v4_winner_values(self) -> None: + geom = TAPE_GEOMETRY[12] + assert geom.printable_px == 70 + assert geom.qr_max_px == 66 + assert geom.text_start_x == 72 + assert geom.font_xl == 22 + assert geom.font_l == 18 + + def test_qr_max_px_follows_formula(self) -> None: + """qr_max_px = printable_px - 2 * qr_padding_px""" + for tape_mm, geom in TAPE_GEOMETRY.items(): + expected = geom.printable_px - 2 * geom.qr_padding_px + assert geom.qr_max_px == expected, ( + f"{tape_mm}mm: qr_max_px={geom.qr_max_px} expected {expected}" + ) + + def test_text_start_x_follows_formula(self) -> None: + """text_start_x = printable_px + qr_padding_px""" + for tape_mm, geom in TAPE_GEOMETRY.items(): + expected = geom.printable_px + geom.qr_padding_px + assert geom.text_start_x == expected, ( + f"{tape_mm}mm: text_start_x={geom.text_start_x} expected {expected}" + ) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/unit/schemas/test_tape_geometry.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'app.schemas.tape_geometry'` + +- [ ] **Step 3: Write implementation** + +```python +# backend/app/schemas/tape_geometry.py +"""Brother printer tape geometry — pixel dimensions per supported tape width. + +Each TapeGeometry entry describes the printable area and layout parameters for +a single tape width. The renderer (LayoutEngine) consumes these to position +QR codes and text deterministically, independent of which ContentType is used. + +The 12mm values are empirically validated (Phase 1i V4-Winner, scan-verified). +Other tape widths are extrapolated via pixel-ratio from 12mm and require +post-deploy smoke-test validation. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class TapeGeometry(BaseModel): + """Render parameters for one supported tape width (all values in pixels). + + Formulas (enforced by TAPE_GEOMETRY entries): + qr_max_px = printable_px - 2 * qr_padding_px + text_start_x = printable_px + qr_padding_px + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + printable_px: int = Field(gt=0) + """Print-pin count per tape (Brother spec).""" + + qr_max_px: int = Field(gt=0) + """Square QR-code edge length: printable_px - 2 * qr_padding_px.""" + + qr_padding_px: int = Field(ge=0) + """Padding around the QR-code (also separator gap before text column).""" + + text_start_x: int = Field(ge=0) + """Absolute X-position where text rendering starts (after QR + gap).""" + + line_spacing_px: int = Field(ge=0) + """Vertical gap between adjacent text lines.""" + + font_xl: int = Field(gt=0) + """primary_id font size.""" + + font_l: int = Field(gt=0) + """title font size.""" + + font_m: int = Field(gt=0) + """listing item / secondary content font size.""" + + font_s: int = Field(gt=0) + """secondary line font size.""" + + +TAPE_GEOMETRY: dict[int, TapeGeometry] = { + 4: TapeGeometry(printable_px=24, qr_max_px=20, qr_padding_px=2, text_start_x=26, line_spacing_px=1, font_xl=8, font_l=7, font_m=6, font_s=5), + 6: TapeGeometry(printable_px=32, qr_max_px=28, qr_padding_px=2, text_start_x=34, line_spacing_px=2, font_xl=10, font_l=9, font_m=7, font_s=6), + 9: TapeGeometry(printable_px=50, qr_max_px=46, qr_padding_px=2, text_start_x=52, line_spacing_px=3, font_xl=14, font_l=12, font_m=10, font_s=8), + 12: TapeGeometry(printable_px=70, qr_max_px=66, qr_padding_px=2, text_start_x=72, line_spacing_px=4, font_xl=22, font_l=18, font_m=14, font_s=10), + 18: TapeGeometry(printable_px=112, qr_max_px=108, qr_padding_px=2, text_start_x=114, line_spacing_px=6, font_xl=32, font_l=26, font_m=20, font_s=14), + 24: TapeGeometry(printable_px=128, qr_max_px=124, qr_padding_px=2, text_start_x=130, line_spacing_px=8, font_xl=36, font_l=30, font_m=24, font_s=18), + 62: TapeGeometry(printable_px=696, qr_max_px=688, qr_padding_px=4, text_start_x=700, line_spacing_px=20, font_xl=120, font_l=96, font_m=72, font_s=48), +} +"""Map int(tape_mm) -> TapeGeometry. 12mm scan-verified, others extrapolated.""" +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && pytest tests/unit/schemas/test_tape_geometry.py -v` +Expected: PASS (10 passed) + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/schemas/tape_geometry.py backend/tests/unit/schemas/test_tape_geometry.py +git commit -m "feat(schemas): add TapeGeometry + TAPE_GEOMETRY constants table + +Phase 1k.1a Task 1: introduces the central pixel-geometry table for all 7 +supported tape widths (4/6/9/12/18/24/62mm). 12mm scan-verified, others +extrapolated via pixel-ratio. + +Refs #103" +``` + +--- + +### Task 2: ContentType Enum + +**Files:** +- Create: `backend/app/schemas/content_type.py` +- Test: `backend/tests/unit/schemas/test_content_type.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/schemas/test_content_type.py +"""Unit tests for ContentType enum.""" + +from __future__ import annotations + +from app.schemas.content_type import ContentType + + +class TestContentType: + def test_all_seven_values_defined(self) -> None: + assert {c.value for c in ContentType} == { + "qr_only", "qr_one_line", "qr_two_lines", "qr_three_lines", + "text_one_line", "text_two_lines", "qr_with_listing", + } + + def test_string_value_round_trip(self) -> None: + assert ContentType("qr_two_lines") == ContentType.QR_TWO_LINES + assert ContentType.QR_TWO_LINES.value == "qr_two_lines" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/unit/schemas/test_content_type.py -v` +Expected: FAIL with `ModuleNotFoundError` + +- [ ] **Step 3: Write implementation** + +```python +# backend/app/schemas/content_type.py +"""Semantic content types — tape-independent label descriptions. + +Each ContentType describes WHAT is rendered (QR + N text lines, or listing, +or text-only). The renderer (LayoutEngine) consumes (tape_mm, content_type, +data) and produces a PIL Image — pixel positions are computed from the +TapeGeometry table, not from the ContentType. +""" + +from __future__ import annotations + +from enum import StrEnum + + +class ContentType(StrEnum): + """Tape-independent semantic content types for label rendering.""" + + QR_ONLY = "qr_only" + """QR fills the full tape height; no text.""" + + QR_ONE_LINE = "qr_one_line" + """QR left + 1 text line (XL, vertically centered): qr_payload + primary_id.""" + + QR_TWO_LINES = "qr_two_lines" + """QR left + 2 text lines (XL primary_id + L title).""" + + QR_THREE_LINES = "qr_three_lines" + """QR left + 3 text lines (XL primary_id + L title + S secondary[0]).""" + + TEXT_ONE_LINE = "text_one_line" + """Full-width text XL (primary_id); no QR.""" + + TEXT_TWO_LINES = "text_two_lines" + """2 text lines (XL primary_id + L title); no QR.""" + + QR_WITH_LISTING = "qr_with_listing" + """QR + N item lines (M font); overflow shows "+N more".""" +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && pytest tests/unit/schemas/test_content_type.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/schemas/content_type.py backend/tests/unit/schemas/test_content_type.py +git commit -m "feat(schemas): add ContentType enum (7 semantic types) + +Phase 1k.1a Task 2: introduces tape-independent ContentType enum. +qr_only, qr_one_line, qr_two_lines, qr_three_lines, text_one_line, +text_two_lines, qr_with_listing. + +Refs #103" +``` + +--- + +### Task 3: LabelDataItem Schema + +**Files:** +- Create: `backend/app/schemas/label_data_item.py` +- Test: `backend/tests/unit/schemas/test_label_data_item.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/schemas/test_label_data_item.py +"""Unit tests for LabelDataItem (qr_with_listing child).""" + +from __future__ import annotations + +import pytest +from app.schemas.label_data_item import LabelDataItem + + +class TestLabelDataItem: + def test_minimal_item(self) -> None: + item = LabelDataItem(item="A — Schrauben") + assert item.item == "A — Schrauben" + assert item.qr_payload is None + + def test_with_qr_payload(self) -> None: + item = LabelDataItem( + item="B", qr_payload="https://example.com/locations/k02/b" + ) + assert item.qr_payload == "https://example.com/locations/k02/b" + + def test_item_required(self) -> None: + with pytest.raises(ValueError, match="item"): + LabelDataItem() # type: ignore[call-arg] + + def test_frozen(self) -> None: + item = LabelDataItem(item="A") + with pytest.raises(ValueError): + item.item = "B" # type: ignore[misc] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/unit/schemas/test_label_data_item.py -v` +Expected: FAIL with `ModuleNotFoundError` + +- [ ] **Step 3: Write implementation** + +```python +# backend/app/schemas/label_data_item.py +"""Single child entry for qr_with_listing aggregation labels.""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + + +class LabelDataItem(BaseModel): + """One row in a qr_with_listing label (e.g. Kallax-Regal-Uebersicht).""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + item: str + """Display text for this child (e.g. 'A — Schrauben').""" + + qr_payload: str | None = None + """Optional per-child QR payload (reserved; not rendered in 1k.1a).""" +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && pytest tests/unit/schemas/test_label_data_item.py -v` +Expected: PASS (4 passed) + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/schemas/label_data_item.py backend/tests/unit/schemas/test_label_data_item.py +git commit -m "feat(schemas): add LabelDataItem (qr_with_listing child) + +Phase 1k.1a Task 3: child-entry model for qr_with_listing aggregation. +Used in LabelData.items tuple for Kallax-Regal-Uebersicht labels. + +Refs #103" +``` + +--- + +### Task 4: LabelData — Optional Fields + items + +**Files:** +- Modify: `backend/app/schemas/label_data.py` +- Test: `backend/tests/unit/schemas/test_label_data.py` (Adapt existing or create) + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/schemas/test_label_data.py (replace or extend existing tests) +"""Unit tests for LabelData with optional fields and items extension.""" + +from __future__ import annotations + +from app.schemas.label_data import LabelData +from app.schemas.label_data_item import LabelDataItem + + +class TestLabelDataOptionalFields: + def test_only_source_app_required(self) -> None: + data = LabelData(source_app="manual") + assert data.title is None + assert data.primary_id is None + assert data.qr_payload is None + assert data.secondary == () + assert data.items == () + + def test_all_fields_set(self) -> None: + data = LabelData( + source_app="hangar", + primary_id="K-02", + title="Werkstatt", + qr_payload="https://example.com/locations/k-02", + secondary=("Notiz 1",), + items=(LabelDataItem(item="A"), LabelDataItem(item="B")), + ) + assert data.items[0].item == "A" + assert len(data.items) == 2 + + def test_frozen(self) -> None: + data = LabelData(source_app="manual") + import pytest + with pytest.raises(ValueError): + data.title = "x" # type: ignore[misc] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/unit/schemas/test_label_data.py -v` +Expected: FAIL — original `title: str` was required. + +- [ ] **Step 3: Modify implementation** + +Replace the contents of `backend/app/schemas/label_data.py` with: + +```python +"""App-agnostic label data passed from lookup-clients to the LayoutEngine. + +LabelData is what a `*_client.lookup(id)` call produces. It is the +serialisable view of a real-world entity (Snipe-IT asset, Grocy product, +Spoolman spool, Hangar location) condensed into the minimal set of fields +a label may need: an optional title, an optional identifier, an optional +QR payload, optional secondary lines, and a source-app tag. + +**Phase 1k.1a:** All content fields are optional because ContentType +selects which fields are required for a given render call. The +LayoutEngine validates per-ContentType requirements in +`_validate_data()` and raises `ContentTypeDataMismatchError` if the +required fields are missing. + +Only `source_app` remains required — it is used for downstream routing, +logging, and metrics independent of the chosen ContentType. + +Layout, font, geometry, and tape-fit decisions live in TapeGeometry + +LayoutEngine, NOT here. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from app.schemas.label_data_item import LabelDataItem + + +class LabelData(BaseModel): + """Immutable, app-agnostic label payload.""" + + model_config = ConfigDict(frozen=True) + + source_app: str + """Source application tag (e.g. 'snipeit', 'grocy', 'spoolman', 'hangar', 'manual').""" + + title: str | None = None + """Optional title (e.g. asset name); required by qr_two_lines, qr_three_lines, text_two_lines.""" + + primary_id: str | None = None + """Optional primary identifier; required by qr_one_line, *_two_lines, *_three_lines, text_one_line, qr_with_listing (header).""" + + qr_payload: str | None = None + """Optional URL/payload for the QR code; required by qr_only, qr_*_line(s), qr_with_listing.""" + + secondary: tuple[str, ...] = () + """Optional additional text lines; first entry rendered by qr_three_lines.""" + + items: tuple[LabelDataItem, ...] = () + """Child items for qr_with_listing aggregation labels (Kallax-Regal-Uebersicht etc.).""" +``` + +- [ ] **Step 4: Run tests** + +Run: `cd backend && pytest tests/unit/schemas/test_label_data.py -v` +Expected: PASS (3 passed) + +- [ ] **Step 5: Run full test suite to find dependent breakages** + +Run: `cd backend && pytest -x --ignore=tests/integration 2>&1 | tail -30` +Expected: Multiple failures in tests that constructed `LabelData(title="x", primary_id="y", qr_payload="z", source_app="...")` — these still work (constructor accepts kwargs). But tests that expected raises when fields were missing now fail. Note failures for Task 5+ to address. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/schemas/label_data.py backend/tests/unit/schemas/test_label_data.py +git commit -m "feat(schemas): LabelData fields optional + items extension + +Phase 1k.1a Task 4: title/primary_id/qr_payload werden optional +(str | None = None). source_app bleibt einzig zwingend gesetzt. +items: tuple[LabelDataItem, ...] = () fuer qr_with_listing. + +ContentType-spezifische Pflichtfeld-Validation passiert ab jetzt zentral +in LayoutEngine._validate_data() — siehe Task 6. + +Refs #103" +``` + +--- + +### Task 5: New Exceptions + +**Files:** +- Modify: `backend/app/printer_backends/exceptions.py` +- Test: `backend/tests/unit/printer_backends/test_exceptions.py` (extend existing) + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/printer_backends/test_exceptions.py (extend with new tests) +from __future__ import annotations + +import pytest +from app.printer_backends.exceptions import ( + ContentTypeDataMismatchError, + NoTapeLoadedError, + UnsupportedTapeError, +) + + +class TestUnsupportedTapeError: + def test_carries_tape_mm(self) -> None: + exc = UnsupportedTapeError(tape_mm=36) + assert exc.tape_mm == 36 + assert "36" in str(exc) + assert "supported" in str(exc).lower() + + +class TestNoTapeLoadedError: + def test_message_default(self) -> None: + exc = NoTapeLoadedError() + assert "no tape" in str(exc).lower() + + +class TestContentTypeDataMismatchError: + def test_carries_content_type_and_missing(self) -> None: + exc = ContentTypeDataMismatchError( + content_type="qr_two_lines", + missing_fields=("primary_id", "title"), + ) + assert exc.content_type == "qr_two_lines" + assert exc.missing_fields == ("primary_id", "title") + assert "primary_id" in str(exc) and "title" in str(exc) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/unit/printer_backends/test_exceptions.py -v` +Expected: FAIL with `ImportError` for the new classes. + +- [ ] **Step 3: Extend implementation** + +Append to `backend/app/printer_backends/exceptions.py`: + +```python +class UnsupportedTapeError(Exception): + """Raised when the preflight-detected tape_mm is not in TAPE_GEOMETRY. + + HTTP-Status: 409 (Conflict) — same family as TapeEmptyError, CoverOpenError. + The user must switch to a supported tape; retrying with the same loaded + tape will fail again. + + Defensive: with 7 supported sizes (4/6/9/12/18/24/62mm) this should not + occur in typical hardware setups (PT-Serie + QL-820NWB). The bestehende + TapeRegistry kennt zusaetzliche QL-DK-Breiten (29/38/50/54mm), die in + 1k.1 bewusst noch nicht abgedeckt sind — Erweiterung als Folge-Phase + moeglich. + """ + + def __init__(self, *, tape_mm: int) -> None: + self.tape_mm = tape_mm + supported = (4, 6, 9, 12, 18, 24, 62) + super().__init__( + f"Tape width {tape_mm}mm is not supported by the layout engine. " + f"Supported: {supported}" + ) + + +class NoTapeLoadedError(Exception): + """Raised when preflight returns loaded_tape_mm=None (no tape inserted). + + HTTP-Status: 409 (Conflict) — physical hardware state, retry needed + after user inserts tape. + """ + + def __init__(self) -> None: + super().__init__("No tape loaded — insert a Brother TZe or DK cartridge.") + + +class ContentTypeDataMismatchError(Exception): + """Raised when LabelData lacks fields required by the chosen ContentType. + + HTTP-Status: 422 (Unprocessable Entity) — client can correct the + request payload and retry without changing hardware state. + """ + + def __init__( + self, + *, + content_type: str, + missing_fields: tuple[str, ...], + ) -> None: + self.content_type = content_type + self.missing_fields = missing_fields + super().__init__( + f"ContentType '{content_type}' requires fields {list(missing_fields)} " + f"in LabelData — please populate them and retry." + ) +``` + +- [ ] **Step 4: Run tests** + +Run: `cd backend && pytest tests/unit/printer_backends/test_exceptions.py -v` +Expected: PASS (3 new tests + existing tests) + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/printer_backends/exceptions.py backend/tests/unit/printer_backends/test_exceptions.py +git commit -m "feat(exceptions): UnsupportedTapeError + NoTapeLoadedError + ContentTypeDataMismatchError + +Phase 1k.1a Task 5: new exception classes used by LayoutEngine. + +UnsupportedTapeError(409) and NoTapeLoadedError(409) for hardware/preflight +conflicts. ContentTypeDataMismatchError(422) for client-correctable data +validation errors per ContentType. + +Refs #103" +``` + +--- + +### Task 6: LayoutEngine Skeleton + +**Files:** +- Create: `backend/app/services/layout_engine.py` +- Test: `backend/tests/unit/services/test_layout_engine.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/services/test_layout_engine.py +"""Unit tests for LayoutEngine — skeleton + validation + dispatch.""" + +from __future__ import annotations + +import pytest +from app.printer_backends.exceptions import ( + ContentTypeDataMismatchError, + UnsupportedTapeError, +) +from app.schemas.content_type import ContentType +from app.schemas.label_data import LabelData +from app.schemas.label_data_item import LabelDataItem +from app.services.layout_engine import LayoutEngine + + +class TestLayoutEngineLookup: + def test_supported_tape_mm_returns_geometry(self) -> None: + eng = LayoutEngine() + # Internal helper exercised via _lookup_geometry — called by render. + # We don't call _lookup_geometry directly; we trigger via an invalid tape. + with pytest.raises(UnsupportedTapeError) as exc_info: + eng.render( + tape_mm=36, + content_type=ContentType.QR_ONLY, + data=LabelData(source_app="manual", qr_payload="x"), + ) + assert exc_info.value.tape_mm == 36 + + +class TestLayoutEngineValidation: + def test_qr_only_requires_qr_payload(self) -> None: + eng = LayoutEngine() + with pytest.raises(ContentTypeDataMismatchError) as exc_info: + eng.render( + tape_mm=12, + content_type=ContentType.QR_ONLY, + data=LabelData(source_app="manual"), + ) + assert "qr_payload" in exc_info.value.missing_fields + + def test_qr_two_lines_requires_all_three(self) -> None: + eng = LayoutEngine() + with pytest.raises(ContentTypeDataMismatchError) as exc_info: + eng.render( + tape_mm=12, + content_type=ContentType.QR_TWO_LINES, + data=LabelData(source_app="manual", primary_id="x"), + ) + # Missing both qr_payload + title + assert set(exc_info.value.missing_fields) >= {"qr_payload", "title"} + + def test_qr_three_lines_requires_secondary(self) -> None: + eng = LayoutEngine() + with pytest.raises(ContentTypeDataMismatchError) as exc_info: + eng.render( + tape_mm=18, + content_type=ContentType.QR_THREE_LINES, + data=LabelData( + source_app="grocy", + primary_id="X", title="Y", qr_payload="Z", + secondary=(), # empty -> missing + ), + ) + assert "secondary" in exc_info.value.missing_fields + + def test_text_one_line_only_needs_primary_id(self) -> None: + eng = LayoutEngine() + with pytest.raises(ContentTypeDataMismatchError) as exc_info: + eng.render( + tape_mm=12, + content_type=ContentType.TEXT_ONE_LINE, + data=LabelData(source_app="manual"), + ) + assert exc_info.value.missing_fields == ("primary_id",) + + def test_qr_with_listing_requires_items_and_qr(self) -> None: + eng = LayoutEngine() + with pytest.raises(ContentTypeDataMismatchError) as exc_info: + eng.render( + tape_mm=12, + content_type=ContentType.QR_WITH_LISTING, + data=LabelData(source_app="hangar", primary_id="K02"), + ) + assert "qr_payload" in exc_info.value.missing_fields + assert "items" in exc_info.value.missing_fields + + def test_qr_with_listing_with_items_passes_validation(self) -> None: + """Validation passes even though rendering not yet implemented.""" + eng = LayoutEngine() + # Render returns NotImplementedError for now (will be Task 13) + with pytest.raises(NotImplementedError): + eng.render( + tape_mm=12, + content_type=ContentType.QR_WITH_LISTING, + data=LabelData( + source_app="hangar", + primary_id="K02", + qr_payload="https://example.com/k02", + items=(LabelDataItem(item="A"),), + ), + ) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/unit/services/test_layout_engine.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'app.services.layout_engine'` + +- [ ] **Step 3: Write skeleton implementation** + +```python +# backend/app/services/layout_engine.py +"""LayoutEngine — semantic layout rendering driven by ContentType + TapeGeometry. + +Replaces the v1 LabelRenderer. Each render() call resolves: + 1. tape_mm -> TapeGeometry (via TAPE_GEOMETRY dict) + 2. content_type-required fields -> validated against LabelData + 3. Dispatched to a per-ContentType _render_*() method + 4. Returns a PIL Image whose height matches geometry.printable_px + +The _render_*() methods are implemented in subsequent tasks (7-13). +""" + +from __future__ import annotations + +from PIL import Image + +from app.printer_backends.exceptions import ( + ContentTypeDataMismatchError, + UnsupportedTapeError, +) +from app.schemas.content_type import ContentType +from app.schemas.label_data import LabelData +from app.schemas.tape_geometry import TAPE_GEOMETRY, TapeGeometry + + +class LayoutEngine: + """Tape-independent semantic label renderer. + + Stateless — safe to instantiate once and reuse across requests. + """ + + # ContentType -> ordered tuple of LabelData field names that must be set. + # Used by _validate_data to produce ContentTypeDataMismatchError with a + # complete missing-fields list (one 422 instead of multiple round-trips). + _REQUIRED_FIELDS: dict[ContentType, tuple[str, ...]] = { + ContentType.QR_ONLY: ("qr_payload",), + ContentType.QR_ONE_LINE: ("qr_payload", "primary_id"), + ContentType.QR_TWO_LINES: ("qr_payload", "primary_id", "title"), + ContentType.QR_THREE_LINES: ("qr_payload", "primary_id", "title", "secondary"), + ContentType.TEXT_ONE_LINE: ("primary_id",), + ContentType.TEXT_TWO_LINES: ("primary_id", "title"), + ContentType.QR_WITH_LISTING: ("qr_payload", "primary_id", "items"), + } + + def render( + self, + tape_mm: int, + content_type: ContentType, + data: LabelData, + ) -> Image.Image: + """Render a label for the given tape width + content type + data. + + Raises: + UnsupportedTapeError (409): tape_mm not in TAPE_GEOMETRY. + ContentTypeDataMismatchError (422): data missing required fields. + """ + geometry = self._lookup_geometry(tape_mm) + self._validate_data(content_type, data) + + match content_type: + case ContentType.QR_ONLY: + return self._render_qr_only(geometry, data) + case ContentType.QR_ONE_LINE: + return self._render_qr_one_line(geometry, data) + case ContentType.QR_TWO_LINES: + return self._render_qr_two_lines(geometry, data) + case ContentType.QR_THREE_LINES: + return self._render_qr_three_lines(geometry, data) + case ContentType.TEXT_ONE_LINE: + return self._render_text_one_line(geometry, data) + case ContentType.TEXT_TWO_LINES: + return self._render_text_two_lines(geometry, data) + case ContentType.QR_WITH_LISTING: + return self._render_qr_with_listing(geometry, data) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _lookup_geometry(self, tape_mm: int) -> TapeGeometry: + geom = TAPE_GEOMETRY.get(tape_mm) + if geom is None: + raise UnsupportedTapeError(tape_mm=tape_mm) + return geom + + def _validate_data(self, content_type: ContentType, data: LabelData) -> None: + required = self._REQUIRED_FIELDS[content_type] + missing: list[str] = [] + for field_name in required: + value = getattr(data, field_name) + # Empty string, empty tuple, or None counts as missing. + if value is None or (hasattr(value, "__len__") and len(value) == 0): + missing.append(field_name) + if missing: + raise ContentTypeDataMismatchError( + content_type=str(content_type), + missing_fields=tuple(missing), + ) + + # ------------------------------------------------------------------ + # _render_* methods — implemented in Tasks 7-13 + # ------------------------------------------------------------------ + + def _render_qr_only( + self, geometry: TapeGeometry, data: LabelData, + ) -> Image.Image: + raise NotImplementedError("Task 7") + + def _render_qr_one_line( + self, geometry: TapeGeometry, data: LabelData, + ) -> Image.Image: + raise NotImplementedError("Task 8") + + def _render_qr_two_lines( + self, geometry: TapeGeometry, data: LabelData, + ) -> Image.Image: + raise NotImplementedError("Task 9") + + def _render_qr_three_lines( + self, geometry: TapeGeometry, data: LabelData, + ) -> Image.Image: + raise NotImplementedError("Task 10") + + def _render_text_one_line( + self, geometry: TapeGeometry, data: LabelData, + ) -> Image.Image: + raise NotImplementedError("Task 11") + + def _render_text_two_lines( + self, geometry: TapeGeometry, data: LabelData, + ) -> Image.Image: + raise NotImplementedError("Task 12") + + def _render_qr_with_listing( + self, geometry: TapeGeometry, data: LabelData, + ) -> Image.Image: + raise NotImplementedError("Task 13") +``` + +- [ ] **Step 4: Run tests** + +Run: `cd backend && pytest tests/unit/services/test_layout_engine.py -v` +Expected: PASS (6 tests for validation + dispatch; rendering NotImplementedError is expected) + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/layout_engine.py backend/tests/unit/services/test_layout_engine.py +git commit -m "feat(services): LayoutEngine skeleton — dispatch + validation + +Phase 1k.1a Task 6: introduces LayoutEngine with _lookup_geometry, +_validate_data, and match-statement dispatch to per-ContentType render +methods. Rendering methods raise NotImplementedError pending Tasks 7-13. + +Refs #103" +``` + +--- + +### Task 7: _render_qr_only + +**Files:** +- Modify: `backend/app/services/layout_engine.py` +- Test: `backend/tests/unit/services/test_layout_engine_render.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/services/test_layout_engine_render.py +"""Render tests per ContentType — output image checks (size, content).""" + +from __future__ import annotations + +from app.schemas.content_type import ContentType +from app.schemas.label_data import LabelData +from app.schemas.tape_geometry import TAPE_GEOMETRY +from app.services.layout_engine import LayoutEngine + + +class TestRenderQROnly: + def test_image_height_matches_printable_px_12mm(self) -> None: + eng = LayoutEngine() + img = eng.render( + tape_mm=12, + content_type=ContentType.QR_ONLY, + data=LabelData(source_app="manual", qr_payload="https://example.com/x"), + ) + assert img.height == TAPE_GEOMETRY[12].printable_px == 70 + + def test_image_mode_is_1bit(self) -> None: + eng = LayoutEngine() + img = eng.render( + tape_mm=12, + content_type=ContentType.QR_ONLY, + data=LabelData(source_app="manual", qr_payload="https://example.com/x"), + ) + assert img.mode == "1" + + def test_qr_pixels_present(self) -> None: + """At least some black pixels exist (the QR is rendered).""" + eng = LayoutEngine() + img = eng.render( + tape_mm=12, + content_type=ContentType.QR_ONLY, + data=LabelData(source_app="manual", qr_payload="https://example.com/x"), + ) + black = sum(1 for p in img.getdata() if p == 0) + assert black > 200, f"Expected QR pixels; got {black} black pixels" + + def test_24mm_renders(self) -> None: + eng = LayoutEngine() + img = eng.render( + tape_mm=24, + content_type=ContentType.QR_ONLY, + data=LabelData(source_app="manual", qr_payload="https://example.com/y"), + ) + assert img.height == TAPE_GEOMETRY[24].printable_px == 128 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/unit/services/test_layout_engine_render.py::TestRenderQROnly -v` +Expected: FAIL with `NotImplementedError: Task 7` + +- [ ] **Step 3: Implement _render_qr_only** + +Add helper imports + replace the placeholder `_render_qr_only` in `backend/app/services/layout_engine.py`. The imports at the top of the file become: + +```python +from __future__ import annotations + +import io + +import qrcode +from PIL import Image + +from app.printer_backends.exceptions import ( + ContentTypeDataMismatchError, + UnsupportedTapeError, +) +from app.schemas.content_type import ContentType +from app.schemas.label_data import LabelData +from app.schemas.tape_geometry import TAPE_GEOMETRY, TapeGeometry +``` + +Add a private helper at the top of the `LayoutEngine` class (above `render()`): + +```python + @staticmethod + def _build_qr_image(payload: str, size_px: int) -> Image.Image: + """Render a QR code as a square 1-bit PIL Image at the requested size.""" + qr = qrcode.QRCode( + version=None, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=10, + border=0, + ) + qr.add_data(payload) + qr.make(fit=True) + # Render at high resolution, then resize to the target size. + rendered = qr.make_image(fill_color="black", back_color="white") + return rendered.convert("1").resize((size_px, size_px), Image.NEAREST) + + @staticmethod + def _blank_canvas(width: int, height: int) -> Image.Image: + """Return a white 1-bit PIL Image of the given size.""" + return Image.new("1", (width, height), color=1) +``` + +Replace `_render_qr_only` with: + +```python + def _render_qr_only( + self, geometry: TapeGeometry, data: LabelData, + ) -> Image.Image: + """QR fills the full printable height, left-padded by qr_padding_px. + + Width = qr_max_px + 2 * qr_padding_px = printable_px (square label). + """ + qr_img = self._build_qr_image( + payload=data.qr_payload or "", + size_px=geometry.qr_max_px, + ) + canvas_width = geometry.printable_px # tight crop, QR + padding both sides + canvas = self._blank_canvas(canvas_width, geometry.printable_px) + # Center vertically; QR is square so vertical padding = qr_padding_px. + canvas.paste(qr_img, (geometry.qr_padding_px, geometry.qr_padding_px)) + return canvas +``` + +- [ ] **Step 4: Run tests** + +Run: `cd backend && pytest tests/unit/services/test_layout_engine_render.py::TestRenderQROnly -v` +Expected: PASS (4 passed) + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/layout_engine.py backend/tests/unit/services/test_layout_engine_render.py +git commit -m "feat(layout-engine): implement _render_qr_only + +Phase 1k.1a Task 7: QR fills full printable height with qr_padding_px +border. Width = printable_px (square output). Uses qrcode lib with +ERROR_CORRECT_M and resizes to geometry.qr_max_px. + +Refs #103" +``` + +--- + +### Task 8: _render_qr_one_line + +**Files:** +- Modify: `backend/app/services/layout_engine.py` +- Test: `backend/tests/unit/services/test_layout_engine_render.py` + +- [ ] **Step 1: Write the failing test** + +Append to `backend/tests/unit/services/test_layout_engine_render.py`: + +```python +class TestRenderQROneLine: + def test_image_height_matches_printable_px(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.schemas.tape_geometry import TAPE_GEOMETRY + from app.services.layout_engine import LayoutEngine + eng = LayoutEngine() + img = eng.render( + tape_mm=12, + content_type=ContentType.QR_ONE_LINE, + data=LabelData( + source_app="manual", + qr_payload="https://example.com/x", + primary_id="X-001", + ), + ) + assert img.height == TAPE_GEOMETRY[12].printable_px + + def test_width_includes_text_column(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.schemas.tape_geometry import TAPE_GEOMETRY + from app.services.layout_engine import LayoutEngine + eng = LayoutEngine() + img = eng.render( + tape_mm=12, + content_type=ContentType.QR_ONE_LINE, + data=LabelData( + source_app="manual", + qr_payload="https://example.com/x", + primary_id="X-001", + ), + ) + # Width must exceed text_start_x (text rendered after QR). + assert img.width > TAPE_GEOMETRY[12].text_start_x +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/unit/services/test_layout_engine_render.py::TestRenderQROneLine -v` +Expected: FAIL with `NotImplementedError: Task 8` + +- [ ] **Step 3: Implement _render_qr_one_line + text helper** + +Add this helper to the `LayoutEngine` class (next to `_build_qr_image`): + +```python + @staticmethod + def _load_font(size_px: int) -> "Image.ImageFont": # noqa: F821 + """Load DejaVuSans TrueType font at the requested pixel size. + + DejaVuSans.ttf is installed via fonts-dejavu-core in the Dockerfile + (Phase 1i pre-fix). On dev machines without the system font, falls + back to the default bitmap font (tests skip such envs). + """ + from PIL import ImageFont + try: + return ImageFont.truetype("DejaVuSans.ttf", size_px) + except OSError: + return ImageFont.load_default() + + @staticmethod + def _measure_text(text: str, font: "Image.ImageFont") -> tuple[int, int]: # noqa: F821 + """Return (width, height) bounding box of `text` rendered with `font`.""" + from PIL import ImageDraw + bbox = ImageDraw.Draw(Image.new("1", (1, 1), color=1)).textbbox( + (0, 0), text, font=font + ) + return (bbox[2] - bbox[0], bbox[3] - bbox[1]) +``` + +Replace `_render_qr_one_line` with: + +```python + def _render_qr_one_line( + self, geometry: TapeGeometry, data: LabelData, + ) -> Image.Image: + """QR left + 1 text line (primary_id, font_xl, vertically centered).""" + from PIL import ImageDraw + + qr_img = self._build_qr_image( + payload=data.qr_payload or "", + size_px=geometry.qr_max_px, + ) + font = self._load_font(geometry.font_xl) + text = data.primary_id or "" + text_w, text_h = self._measure_text(text, font) + + canvas_width = geometry.text_start_x + text_w + geometry.qr_padding_px + canvas = self._blank_canvas(canvas_width, geometry.printable_px) + # QR top-left at (qr_padding_px, qr_padding_px) + canvas.paste(qr_img, (geometry.qr_padding_px, geometry.qr_padding_px)) + # Text vertically centered on the printable area. + text_y = max(0, (geometry.printable_px - text_h) // 2) + ImageDraw.Draw(canvas).text( + (geometry.text_start_x, text_y), text, font=font, fill=0 + ) + return canvas +``` + +- [ ] **Step 4: Run tests** + +Run: `cd backend && pytest tests/unit/services/test_layout_engine_render.py::TestRenderQROneLine -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/layout_engine.py backend/tests/unit/services/test_layout_engine_render.py +git commit -m "feat(layout-engine): implement _render_qr_one_line + +Phase 1k.1a Task 8: QR left + 1 text line (primary_id, font_xl, +vertically centered). Width sized to fit text content. + +Refs #103" +``` + +--- + +### Task 9: _render_qr_two_lines (V4-Winner Baseline) + +**Files:** +- Modify: `backend/app/services/layout_engine.py` +- Test: `backend/tests/unit/services/test_layout_engine_render.py` + +- [ ] **Step 1: Write the failing test** + +Append to `backend/tests/unit/services/test_layout_engine_render.py`: + +```python +class TestRenderQRTwoLines: + def test_baseline_12mm_v4_winner(self) -> None: + """Phase 1i V4-Winner baseline: primary_id top, title below QR. + + Scan-verified empirical baseline from 12mm PT-P750W hardware. + """ + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.schemas.tape_geometry import TAPE_GEOMETRY + from app.services.layout_engine import LayoutEngine + eng = LayoutEngine() + img = eng.render( + tape_mm=12, + content_type=ContentType.QR_TWO_LINES, + data=LabelData( + source_app="hangar", + primary_id="K-02", + title="Werkstatt", + qr_payload="https://example.com/locations/k-02", + ), + ) + # Height must be 70px (12mm V4-Winner) + assert img.height == 70 + # Width has QR + text — at minimum text_start_x + minimum text width. + geom = TAPE_GEOMETRY[12] + assert img.width > geom.text_start_x + + def test_24mm_renders(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.services.layout_engine import LayoutEngine + eng = LayoutEngine() + img = eng.render( + tape_mm=24, + content_type=ContentType.QR_TWO_LINES, + data=LabelData( + source_app="hangar", primary_id="K-02", title="Werkstatt", + qr_payload="https://example.com/x", + ), + ) + assert img.height == 128 + + def test_62mm_renders_at_higher_dpi(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.services.layout_engine import LayoutEngine + eng = LayoutEngine() + img = eng.render( + tape_mm=62, + content_type=ContentType.QR_TWO_LINES, + data=LabelData( + source_app="samla", primary_id="HH-AK-SM01", title="Samla 11L", + qr_payload="https://example.com/x", + ), + ) + assert img.height == 696 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/unit/services/test_layout_engine_render.py::TestRenderQRTwoLines -v` +Expected: FAIL with `NotImplementedError: Task 9` + +- [ ] **Step 3: Implement _render_qr_two_lines** + +Replace `_render_qr_two_lines` with: + +```python + def _render_qr_two_lines( + self, geometry: TapeGeometry, data: LabelData, + ) -> Image.Image: + """QR left + 2 text lines (primary_id XL on top, title L below). + + Phase 1i V4-Winner baseline for 12mm: + - primary_id at y=2 (font_xl=22) + - title at y=42 (font_l=18) + - text_start_x=72 + Generalises to other tape widths via geometry constants. + """ + from PIL import ImageDraw + + qr_img = self._build_qr_image( + payload=data.qr_payload or "", + size_px=geometry.qr_max_px, + ) + font_primary = self._load_font(geometry.font_xl) + font_title = self._load_font(geometry.font_l) + + primary_text = data.primary_id or "" + title_text = data.title or "" + primary_w, _ = self._measure_text(primary_text, font_primary) + title_w, _ = self._measure_text(title_text, font_title) + max_text_w = max(primary_w, title_w) + + canvas_width = geometry.text_start_x + max_text_w + geometry.qr_padding_px + canvas = self._blank_canvas(canvas_width, geometry.printable_px) + canvas.paste(qr_img, (geometry.qr_padding_px, geometry.qr_padding_px)) + + draw = ImageDraw.Draw(canvas) + # primary_id: top with qr_padding_px gap from canvas top + draw.text( + (geometry.text_start_x, geometry.qr_padding_px), + primary_text, font=font_primary, fill=0, + ) + # title: starts after primary line height + line_spacing_px + title_y = ( + geometry.qr_padding_px + + geometry.font_xl + + geometry.line_spacing_px + ) + draw.text( + (geometry.text_start_x, title_y), + title_text, font=font_title, fill=0, + ) + return canvas +``` + +- [ ] **Step 4: Run tests** + +Run: `cd backend && pytest tests/unit/services/test_layout_engine_render.py::TestRenderQRTwoLines -v` +Expected: PASS (3 passed) + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/layout_engine.py backend/tests/unit/services/test_layout_engine_render.py +git commit -m "feat(layout-engine): implement _render_qr_two_lines (V4 baseline) + +Phase 1k.1a Task 9: QR + 2 text lines (primary_id XL + title L). +Mirrors Phase 1i V4-Winner empirical baseline for 12mm scan-verified +geometry. Generalises across all 7 supported tape widths. + +Refs #103" +``` + +--- + +### Task 10: _render_qr_three_lines + +**Files:** +- Modify: `backend/app/services/layout_engine.py` +- Test: `backend/tests/unit/services/test_layout_engine_render.py` + +- [ ] **Step 1: Write the failing test** + +Append to `backend/tests/unit/services/test_layout_engine_render.py`: + +```python +class TestRenderQRThreeLines: + def test_18mm_with_secondary(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.services.layout_engine import LayoutEngine + eng = LayoutEngine() + img = eng.render( + tape_mm=18, + content_type=ContentType.QR_THREE_LINES, + data=LabelData( + source_app="grocy", + primary_id="Erdbeermarmelade", + title="Lager > Vorrat", + qr_payload="https://example.com/x", + secondary=("MHD 2027-04-30",), + ), + ) + assert img.height == 112 # 18mm printable_px + + def test_24mm_renders(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.services.layout_engine import LayoutEngine + eng = LayoutEngine() + img = eng.render( + tape_mm=24, + content_type=ContentType.QR_THREE_LINES, + data=LabelData( + source_app="grocy", primary_id="X", title="Y", + qr_payload="https://example.com/x", + secondary=("Z",), + ), + ) + assert img.height == 128 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/unit/services/test_layout_engine_render.py::TestRenderQRThreeLines -v` +Expected: FAIL with `NotImplementedError: Task 10` + +- [ ] **Step 3: Implement _render_qr_three_lines** + +Replace `_render_qr_three_lines` with: + +```python + def _render_qr_three_lines( + self, geometry: TapeGeometry, data: LabelData, + ) -> Image.Image: + """QR left + 3 text lines: primary_id XL, title L, secondary[0] S.""" + from PIL import ImageDraw + + qr_img = self._build_qr_image( + payload=data.qr_payload or "", + size_px=geometry.qr_max_px, + ) + font_primary = self._load_font(geometry.font_xl) + font_title = self._load_font(geometry.font_l) + font_secondary = self._load_font(geometry.font_s) + + primary_text = data.primary_id or "" + title_text = data.title or "" + secondary_text = data.secondary[0] if data.secondary else "" + + primary_w, _ = self._measure_text(primary_text, font_primary) + title_w, _ = self._measure_text(title_text, font_title) + sec_w, _ = self._measure_text(secondary_text, font_secondary) + max_text_w = max(primary_w, title_w, sec_w) + + canvas_width = geometry.text_start_x + max_text_w + geometry.qr_padding_px + canvas = self._blank_canvas(canvas_width, geometry.printable_px) + canvas.paste(qr_img, (geometry.qr_padding_px, geometry.qr_padding_px)) + + draw = ImageDraw.Draw(canvas) + y = geometry.qr_padding_px + draw.text( + (geometry.text_start_x, y), + primary_text, font=font_primary, fill=0, + ) + y += geometry.font_xl + geometry.line_spacing_px + draw.text( + (geometry.text_start_x, y), + title_text, font=font_title, fill=0, + ) + y += geometry.font_l + geometry.line_spacing_px + draw.text( + (geometry.text_start_x, y), + secondary_text, font=font_secondary, fill=0, + ) + return canvas +``` + +- [ ] **Step 4: Run tests** + +Run: `cd backend && pytest tests/unit/services/test_layout_engine_render.py::TestRenderQRThreeLines -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/layout_engine.py backend/tests/unit/services/test_layout_engine_render.py +git commit -m "feat(layout-engine): implement _render_qr_three_lines + +Phase 1k.1a Task 10: QR + 3 text lines (primary_id XL + title L + +secondary[0] S). Used by grocy/snipeit/spoolman 18/24mm and hangar- +furniture 18/24mm labels post-migration. + +Refs #103" +``` + +--- + +### Task 11: _render_text_one_line + +**Files:** +- Modify: `backend/app/services/layout_engine.py` +- Test: `backend/tests/unit/services/test_layout_engine_render.py` + +- [ ] **Step 1: Write the failing test** + +Append to `backend/tests/unit/services/test_layout_engine_render.py`: + +```python +class TestRenderTextOneLine: + def test_no_qr_present(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.services.layout_engine import LayoutEngine + eng = LayoutEngine() + img = eng.render( + tape_mm=12, + content_type=ContentType.TEXT_ONE_LINE, + data=LabelData(source_app="manual", primary_id="HELLO"), + ) + # Width should be small — only text, no QR (no qr_max_px area) + assert img.width < 200 # generous upper bound for "HELLO" at font_xl=22 + + def test_renders_at_correct_height(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.schemas.tape_geometry import TAPE_GEOMETRY + from app.services.layout_engine import LayoutEngine + eng = LayoutEngine() + img = eng.render( + tape_mm=24, + content_type=ContentType.TEXT_ONE_LINE, + data=LabelData(source_app="manual", primary_id="X"), + ) + assert img.height == TAPE_GEOMETRY[24].printable_px +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/unit/services/test_layout_engine_render.py::TestRenderTextOneLine -v` +Expected: FAIL with `NotImplementedError: Task 11` + +- [ ] **Step 3: Implement _render_text_one_line** + +Replace `_render_text_one_line` with: + +```python + def _render_text_one_line( + self, geometry: TapeGeometry, data: LabelData, + ) -> Image.Image: + """Full-width text (primary_id, font_xl, vertically centered).""" + from PIL import ImageDraw + + font = self._load_font(geometry.font_xl) + text = data.primary_id or "" + text_w, text_h = self._measure_text(text, font) + + # No QR -> canvas starts with qr_padding_px left margin + canvas_width = geometry.qr_padding_px + text_w + geometry.qr_padding_px + canvas = self._blank_canvas(canvas_width, geometry.printable_px) + text_y = max(0, (geometry.printable_px - text_h) // 2) + ImageDraw.Draw(canvas).text( + (geometry.qr_padding_px, text_y), text, font=font, fill=0, + ) + return canvas +``` + +- [ ] **Step 4: Run tests** + +Run: `cd backend && pytest tests/unit/services/test_layout_engine_render.py::TestRenderTextOneLine -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/layout_engine.py backend/tests/unit/services/test_layout_engine_render.py +git commit -m "feat(layout-engine): implement _render_text_one_line + +Phase 1k.1a Task 11: full-width primary_id text without QR. Vertically +centered, padded by qr_padding_px from both edges. + +Refs #103" +``` + +--- + +### Task 12: _render_text_two_lines + +**Files:** +- Modify: `backend/app/services/layout_engine.py` +- Test: `backend/tests/unit/services/test_layout_engine_render.py` + +- [ ] **Step 1: Write the failing test** + +Append to `backend/tests/unit/services/test_layout_engine_render.py`: + +```python +class TestRenderTextTwoLines: + def test_18mm_renders(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.services.layout_engine import LayoutEngine + eng = LayoutEngine() + img = eng.render( + tape_mm=18, + content_type=ContentType.TEXT_TWO_LINES, + data=LabelData(source_app="manual", primary_id="LINE1", title="LINE2"), + ) + assert img.height == 112 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/unit/services/test_layout_engine_render.py::TestRenderTextTwoLines -v` +Expected: FAIL with `NotImplementedError: Task 12` + +- [ ] **Step 3: Implement _render_text_two_lines** + +Replace `_render_text_two_lines` with: + +```python + def _render_text_two_lines( + self, geometry: TapeGeometry, data: LabelData, + ) -> Image.Image: + """2 text lines (primary_id XL + title L), no QR.""" + from PIL import ImageDraw + + font_primary = self._load_font(geometry.font_xl) + font_title = self._load_font(geometry.font_l) + primary_text = data.primary_id or "" + title_text = data.title or "" + primary_w, _ = self._measure_text(primary_text, font_primary) + title_w, _ = self._measure_text(title_text, font_title) + max_text_w = max(primary_w, title_w) + + canvas_width = geometry.qr_padding_px + max_text_w + geometry.qr_padding_px + canvas = self._blank_canvas(canvas_width, geometry.printable_px) + + draw = ImageDraw.Draw(canvas) + y = geometry.qr_padding_px + draw.text( + (geometry.qr_padding_px, y), + primary_text, font=font_primary, fill=0, + ) + y += geometry.font_xl + geometry.line_spacing_px + draw.text( + (geometry.qr_padding_px, y), + title_text, font=font_title, fill=0, + ) + return canvas +``` + +- [ ] **Step 4: Run tests** + +Run: `cd backend && pytest tests/unit/services/test_layout_engine_render.py::TestRenderTextTwoLines -v` +Expected: PASS (1 passed) + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/layout_engine.py backend/tests/unit/services/test_layout_engine_render.py +git commit -m "feat(layout-engine): implement _render_text_two_lines + +Phase 1k.1a Task 12: 2 text lines (primary_id XL + title L), no QR. + +Refs #103" +``` + +--- + +### Task 13: _render_qr_with_listing + +**Files:** +- Modify: `backend/app/services/layout_engine.py` +- Test: `backend/tests/unit/services/test_layout_engine_render.py` + +- [ ] **Step 1: Write the failing test** + +Append to `backend/tests/unit/services/test_layout_engine_render.py`: + +```python +class TestRenderQRWithListing: + def test_4_items_render(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.schemas.label_data_item import LabelDataItem + from app.services.layout_engine import LayoutEngine + eng = LayoutEngine() + img = eng.render( + tape_mm=24, + content_type=ContentType.QR_WITH_LISTING, + data=LabelData( + source_app="hangar", + primary_id="Kallax-02", + qr_payload="https://example.com/k02", + items=( + LabelDataItem(item="A — Schrauben"), + LabelDataItem(item="B — Muttern"), + LabelDataItem(item="C — Werkzeug"), + LabelDataItem(item="D — Kabel"), + ), + ), + ) + assert img.height == 128 + + def test_overflow_shows_n_more(self) -> None: + from app.schemas.content_type import ContentType + from app.schemas.label_data import LabelData + from app.schemas.label_data_item import LabelDataItem + from app.services.layout_engine import LayoutEngine + eng = LayoutEngine() + # 12mm can only fit ~2 lines at font_m=14 — 10 items overflow + many = tuple(LabelDataItem(item=f"Item {i}") for i in range(10)) + img = eng.render( + tape_mm=12, + content_type=ContentType.QR_WITH_LISTING, + data=LabelData( + source_app="hangar", primary_id="X", qr_payload="x", items=many, + ), + ) + assert img.height == 70 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/unit/services/test_layout_engine_render.py::TestRenderQRWithListing -v` +Expected: FAIL with `NotImplementedError: Task 13` + +- [ ] **Step 3: Implement _render_qr_with_listing** + +Replace `_render_qr_with_listing` with: + +```python + def _render_qr_with_listing( + self, geometry: TapeGeometry, data: LabelData, + ) -> Image.Image: + """QR left + N item lines (font_m). Overflow shows '+N more'.""" + from PIL import ImageDraw + + qr_img = self._build_qr_image( + payload=data.qr_payload or "", + size_px=geometry.qr_max_px, + ) + font_item = self._load_font(geometry.font_m) + items = list(data.items) + + # How many lines fit in the printable area? Reserve qr_padding_px top/bottom. + available_h = geometry.printable_px - 2 * geometry.qr_padding_px + line_h = geometry.font_m + geometry.line_spacing_px + max_lines = max(1, available_h // line_h) + + # Reserve last line for "+N more" if overflowing. + if len(items) > max_lines: + visible_count = max_lines - 1 + overflow_text = f"+{len(items) - visible_count} more" + visible = items[:visible_count] + else: + visible = items + overflow_text = None + + # Compute canvas width based on widest rendered line. + widths = [self._measure_text(it.item, font_item)[0] for it in visible] + if overflow_text: + widths.append(self._measure_text(overflow_text, font_item)[0]) + max_text_w = max(widths) if widths else 0 + + canvas_width = geometry.text_start_x + max_text_w + geometry.qr_padding_px + canvas = self._blank_canvas(canvas_width, geometry.printable_px) + canvas.paste(qr_img, (geometry.qr_padding_px, geometry.qr_padding_px)) + + draw = ImageDraw.Draw(canvas) + y = geometry.qr_padding_px + for it in visible: + draw.text( + (geometry.text_start_x, y), + it.item, font=font_item, fill=0, + ) + y += line_h + if overflow_text: + draw.text( + (geometry.text_start_x, y), + overflow_text, font=font_item, fill=0, + ) + return canvas +``` + +- [ ] **Step 4: Run tests** + +Run: `cd backend && pytest tests/unit/services/test_layout_engine_render.py::TestRenderQRWithListing -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Run all LayoutEngine tests** + +Run: `cd backend && pytest tests/unit/services/test_layout_engine.py tests/unit/services/test_layout_engine_render.py -v` +Expected: All PASS (validation + dispatch + 7 render methods) + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/layout_engine.py backend/tests/unit/services/test_layout_engine_render.py +git commit -m "feat(layout-engine): implement _render_qr_with_listing + +Phase 1k.1a Task 13: QR + N item lines (font_m), overflow shows +'+N more' on the last line. Used for Kallax-Regal-Uebersicht and +similar aggregation labels. + +Completes all 7 ContentType render methods. + +Refs #103" +``` + +--- + +### Task 14: PrintRequest Schema Refactor + +**Files:** +- Modify: `backend/app/schemas/print_request.py` +- Test: `backend/tests/unit/schemas/test_print_request.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/schemas/test_print_request.py +"""Unit tests for PrintRequest with content_type (no template_id).""" + +from __future__ import annotations + +import pytest +from app.schemas.content_type import ContentType +from app.schemas.print_request import PrintRequest, RawLabelData + + +class TestPrintRequest: + def test_with_content_type_and_raw_data(self) -> None: + req = PrintRequest( + content_type=ContentType.QR_TWO_LINES, + data=RawLabelData( + primary_id="K-02", title="Werkstatt", + qr_payload="https://example.com/x", + ), + ) + assert req.content_type == ContentType.QR_TWO_LINES + assert req.data.primary_id == "K-02" + + def test_no_template_id_field(self) -> None: + """template_id is no longer a valid field — extra="forbid" rejects it.""" + with pytest.raises(ValueError, match="template_id"): + PrintRequest( + template_id="anything", + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="x", primary_id="", title=""), + ) # type: ignore[call-arg] + + def test_no_on_tape_mismatch_field(self) -> None: + with pytest.raises(ValueError, match="on_tape_mismatch"): + PrintRequest( + on_tape_mismatch="queue", + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="x", primary_id="", title=""), + ) # type: ignore[call-arg] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/unit/schemas/test_print_request.py -v` +Expected: FAIL — current PrintRequest still has template_id + on_tape_mismatch. + +- [ ] **Step 3: Modify implementation** + +Read current `backend/app/schemas/print_request.py` and rewrite as follows. The PrintRequest model should look like: + +```python +"""Request schemas for POST /api/print + supporting models. + +Phase 1k.1a: template_id and on_tape_mismatch removed; content_type added. +RawLabelData mirrors LabelData (minus source_app which is set server-side +to 'manual' for raw requests). +""" + +from __future__ import annotations + +from typing import Literal, Self + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.schemas.content_type import ContentType +from app.schemas.label_data_item import LabelDataItem + + +class PrintLookupRequest(BaseModel): + """Resolve label data via an integration plugin.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + app: str + identifier: str + + +class PrintOptions(BaseModel): + """Per-print options — copies, cut behaviour, resolution.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + copies: int = Field(default=1, ge=1, le=10) + auto_cut: bool = True + high_resolution: bool = False + half_cut: bool = False + last_page: bool = True + + +class RawLabelData(BaseModel): + """Raw label payload accepted when the client supplies data directly. + + Mirrors LabelData minus `source_app` (always set to "manual" server-side). + All content fields are optional — ContentType-specific validation happens + in LayoutEngine._validate_data. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + title: str | None = None + primary_id: str | None = None + qr_payload: str | None = None + secondary: tuple[str, ...] = () + items: tuple[LabelDataItem, ...] = () + + +class PrintRequest(BaseModel): + """POST /api/print body. + + Either `data` (RawLabelData) or `lookup` (PrintLookupRequest) is provided. + Exactly one of the two must be present. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + content_type: ContentType + """Semantic content type — drives LayoutEngine render dispatch.""" + + options: PrintOptions = PrintOptions() + """Per-print options (copies, cut behaviour, etc.).""" + + data: RawLabelData | None = None + """Raw label data (preferred over lookup).""" + + lookup: PrintLookupRequest | None = None + """Lookup-based label data (resolved via plugin).""" + + @model_validator(mode="after") + def _exactly_one_data_source(self) -> Self: + if (self.data is None) == (self.lookup is None): + raise ValueError( + "Exactly one of 'data' or 'lookup' must be set." + ) + return self +``` + +- [ ] **Step 4: Run tests** + +Run: `cd backend && pytest tests/unit/schemas/test_print_request.py -v` +Expected: PASS (3 passed) + +- [ ] **Step 5: Run full schema suite — expect some failures** + +Run: `cd backend && pytest tests/unit/schemas -v 2>&1 | tail -20` +Note: existing tests that constructed PrintRequest with template_id will fail. These tests get fixed in later tasks (15-20). For now, only `test_print_request.py` must pass. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/schemas/print_request.py backend/tests/unit/schemas/test_print_request.py +git commit -m "feat(schemas): PrintRequest content_type-based (no template_id) + +Phase 1k.1a Task 14: PrintRequest now requires content_type:ContentType. +template_id and on_tape_mismatch fields removed (extra=forbid rejects). +RawLabelData fields are optional — validation in LayoutEngine. + +Refs #103" +``` + +--- + +### Task 15: PrintService Refactor + +**Files:** +- Modify: `backend/app/services/print_service.py` +- Test: `backend/tests/unit/services/test_print_service.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/services/test_print_service.py — new tests; keep useful existing +"""Unit tests for PrintService with LayoutEngine integration.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID, uuid4 + +import pytest +from app.printer_backends.snmp_helper import LiveStatus +from app.schemas.content_type import ContentType +from app.schemas.print_request import PrintOptions, PrintRequest, RawLabelData +from app.services.layout_engine import LayoutEngine +from app.services.print_service import PrintService + + +@pytest.fixture() +def make_service(): + def _make(loaded_tape_mm: int = 12) -> tuple[PrintService, AsyncMock]: + printer_id = uuid4() + backend = AsyncMock() + backend.preflight_check = AsyncMock( + return_value=LiveStatus( + loaded_tape_mm=loaded_tape_mm, + tape_empty=False, cover_open=False, online=True, + ) + ) + queue = MagicMock() + queue.submit = AsyncMock(return_value=uuid4()) + store = MagicMock() + store.save_queued = MagicMock() + engine = LayoutEngine() + svc = PrintService( + printer_id=printer_id, + backend=backend, + queue=queue, + store=store, + engine=engine, + ) + return svc, queue + return _make + + +class TestPrintServiceRender: + @pytest.mark.asyncio + async def test_submits_with_loaded_tape_mm(self, make_service) -> None: + svc, queue = make_service(loaded_tape_mm=18) + request = PrintRequest( + content_type=ContentType.QR_TWO_LINES, + data=RawLabelData( + primary_id="K02", title="Workshop", qr_payload="https://e.com/x", + ), + ) + await svc.submit_print_job(request) + queue.submit.assert_awaited_once() + # First positional arg is the rendered image — height matches 18mm tape + call_args = queue.submit.call_args + # signature: submit(printer_id, image, *, tape_mm, ...) + image = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs.get("image") + tape_mm_arg = call_args.kwargs.get("tape_mm") + assert tape_mm_arg == 18 + assert image is not None and image.height == 112 # 18mm printable_px +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/unit/services/test_print_service.py::TestPrintServiceRender -v` +Expected: FAIL — current PrintService takes `loader+renderer`, not `engine`. + +- [ ] **Step 3: Refactor PrintService** + +Read `backend/app/services/print_service.py` carefully. The refactor: + +1. Constructor: replace `loader: TemplateLoader, renderer: LabelRenderer` parameters with `engine: LayoutEngine`. +2. `submit_print_job`: remove template lookup; remove TapeMismatchError; remove PAUSED-path; render via `engine.render(tape_mm=preflight.loaded_tape_mm, content_type=request.content_type, data=label_data)`. +3. `_resolve_label_data` keeps existing lookup logic but constructs `LabelData(source_app="manual", **request.data.model_dump())` for raw data. +4. Remove `submit_paused_with_id` references / `resume_paused_job` if present. +5. Drop the `on_tape_mismatch` branching entirely. + +The new `submit_print_job` body should be approximately: + +```python +async def submit_print_job(self, request: PrintRequest) -> UUID: + """Orchestrate preflight -> render -> persist -> queue.submit. + + Phase 1k.1a: tape-independent rendering. The engine renders for the + currently loaded tape (preflight.loaded_tape_mm), so TapeMismatchError + is obsolete in this path. + """ + preflight = await self._backend.preflight_check() + if preflight.loaded_tape_mm is None: + raise NoTapeLoadedError() + + label_data = await self._resolve_label_data(request) + image = self._engine.render( + tape_mm=preflight.loaded_tape_mm, + content_type=request.content_type, + data=label_data, + ) + + job_id = uuid4() + job = Job( + id=str(job_id), + printer_id=self._printer_id, + template_key=None, # new jobs no longer reference templates + content_type=str(request.content_type), + rendered_tape_mm=preflight.loaded_tape_mm, + payload={ + "label_data": label_data.model_dump(), + "tape_mm": preflight.loaded_tape_mm, + "options": request.options.model_dump(), + }, + api_key_id=None, + source_ip=None, + ) + self._store.save_queued(job) + await self._queue.submit( + self._printer_id, + image, + tape_mm=preflight.loaded_tape_mm, + auto_cut=request.options.auto_cut, + high_resolution=request.options.high_resolution, + half_cut=request.options.half_cut, + last_page=request.options.last_page, + ) + return job_id +``` + +`_resolve_label_data` becomes: + +```python +async def _resolve_label_data(self, request: PrintRequest) -> LabelData: + if request.data is not None: + raw = request.data + return LabelData( + source_app="manual", + title=raw.title, + primary_id=raw.primary_id, + qr_payload=raw.qr_payload, + secondary=raw.secondary, + items=raw.items, + ) + # lookup path + assert request.lookup is not None + return await self._lookup_service.resolve( + request.lookup.app, request.lookup.identifier, + ) +``` + +(Job model + Job dataclass might need a `content_type` and `rendered_tape_mm` field — these are added in Task 22 along with the DB migration. For now, store them in `payload` and update Job dataclass to accept them as optional kwargs.) + +Actually — the Job dataclass extension is touched in Task 22's migration. For Task 15, just include the fields in `payload` dict for now and reconcile when models change. + +Replace with the simpler form (keep template_key=None for now, content_type lives in payload until Task 22): + +```python +job = Job( + id=str(job_id), + printer_id=self._printer_id, + template_key=None, + payload={ + "label_data": label_data.model_dump(), + "content_type": str(request.content_type), + "rendered_tape_mm": preflight.loaded_tape_mm, + "tape_mm": preflight.loaded_tape_mm, + "options": request.options.model_dump(), + }, + api_key_id=None, + source_ip=None, +) +``` + +Also update the Job model in `backend/app/models/job.py` if `template_key` is currently `str` (NOT NULL) — change to `str | None`: + +```python +# backend/app/models/job.py +template_key: str | None # snapshot string — survives template deletion; None for content_type-based jobs +``` + +(Note: SQLAlchemy column NOT NULL constraint is changed in Task 22's Alembic migration.) + +- [ ] **Step 4: Run tests** + +Run: `cd backend && pytest tests/unit/services/test_print_service.py -v` +Expected: PASS for new tests. Old tests that referenced TapeMismatchError/PAUSED may fail — delete them. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/print_service.py backend/app/models/job.py backend/tests/unit/services/test_print_service.py +git commit -m "refactor(print-service): integrate LayoutEngine + remove PAUSED path + +Phase 1k.1a Task 15: PrintService now renders via LayoutEngine.render() +using preflight.loaded_tape_mm. TapeMismatchError-Pfad und PAUSED-Job- +Pfad entfernt. NoTapeLoadedError fuer fehlendes Tape. + +Job.template_key wird nullable (None fuer neue Jobs ab 1k.1a). +content_type + rendered_tape_mm in payload dict — eigene Spalten via +Alembic in Task 22. + +Refs #103" +``` + +--- + +### Task 16: PrintQueue _rerender_from_db Migration (KRITISCH) + +**Files:** +- Modify: `backend/app/services/print_queue.py` +- Test: `backend/tests/unit/services/test_print_queue_rerender.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/services/test_print_queue_rerender.py +"""Test the _rerender_from_db recovery path uses LayoutEngine.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from app.schemas.content_type import ContentType +from app.services.layout_engine import LayoutEngine +from app.services.print_queue import PrintQueue + + +class TestRerenderFromDb: + @pytest.mark.asyncio + async def test_rerender_uses_engine_with_stored_content_type(self) -> None: + printer = MagicMock(id=uuid4()) + queue = PrintQueue( + printers=[printer], + engine=LayoutEngine(), + store=MagicMock(), + on_state_change=AsyncMock(), + ) + # Simulate a stored job payload + stored_payload = { + "label_data": { + "source_app": "manual", + "primary_id": "K-02", + "title": "Werkstatt", + "qr_payload": "https://example.com/x", + "secondary": [], + "items": [], + }, + "content_type": "qr_two_lines", + "rendered_tape_mm": 12, + "tape_mm": 12, + "options": { + "copies": 1, "auto_cut": True, "high_resolution": False, + "half_cut": False, "last_page": True, + }, + } + image = queue._rerender_from_db_payload(stored_payload) + # 12mm printable_px + assert image.height == 70 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/unit/services/test_print_queue_rerender.py -v` +Expected: FAIL — `_rerender_from_db_payload` does not exist or uses LabelRenderer. + +- [ ] **Step 3: Refactor PrintQueue** + +In `backend/app/services/print_queue.py`: +1. Constructor: replace `renderer: LabelRenderer | None, loader: type[TemplateLoader] | None` with `engine: LayoutEngine`. +2. Remove all PAUSED-state code: `submit_paused_with_id`, `resume_paused_job`, `PrinterWorkerState.PAUSED` (if present), `_worker_resume_events`. Keep ACTIVE (renamed from PAUSED-aware). +3. Rename or add `_rerender_from_db_payload(payload: dict) -> Image.Image`: + +```python +def _rerender_from_db_payload(self, payload: dict) -> "Image.Image": + """Reconstruct a PIL Image from a stored job payload. + + Used during startup recovery for QUEUED jobs persisted before crash. + The payload was produced by PrintService.submit_print_job (Task 15). + """ + from app.schemas.label_data import LabelData + from app.schemas.content_type import ContentType + + label_data = LabelData(**payload["label_data"]) + content_type = ContentType(payload["content_type"]) + tape_mm = int(payload["rendered_tape_mm"]) + return self._engine.render( + tape_mm=tape_mm, + content_type=content_type, + data=label_data, + ) +``` + +4. Replace any internal call sites of the old `_rerender_from_db` / `_renderer.render(...)` to use the new method. +5. Update the JobStateMachine to drop PAUSED transitions if present (`backend/app/services/job_lifecycle.py`). + +- [ ] **Step 4: Run tests** + +Run: `cd backend && pytest tests/unit/services/test_print_queue_rerender.py -v` +Expected: PASS + +- [ ] **Step 5: Run full print_queue test suite** + +Run: `cd backend && pytest tests/unit/services/test_print_queue*.py -v 2>&1 | tail -30` +Note: many existing tests referenced the old PAUSED path or LabelRenderer. Delete the obsolete ones. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/print_queue.py backend/app/services/job_lifecycle.py backend/tests/unit/services/test_print_queue_rerender.py +git commit -m "refactor(print-queue): _rerender_from_db via LayoutEngine; drop PAUSED + +Phase 1k.1a Task 16: KRITISCH — Recovery-Pfad migriert von LabelRenderer ++ TemplateLoader auf LayoutEngine.render() mit gespeichertem +content_type + rendered_tape_mm + label_data Snapshot. PAUSED-State und +zugehoerige resume_paused_job-Logik entfernt. + +Refs #103" +``` + +--- + +### Task 17: BatchDispatch Cleanup + +**Files:** +- Modify: `backend/app/services/batch_dispatch.py` +- Test: `backend/tests/unit/services/test_batch_dispatch.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/unit/services/test_batch_dispatch.py +"""Tests batch dispatch accepting mixed ContentTypes (no MixedTapeSizesError).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from app.schemas.content_type import ContentType +from app.schemas.print_request import PrintOptions, PrintRequest, RawLabelData +from app.services.batch_dispatch import dispatch_batch + + +class TestBatchDispatch: + @pytest.mark.asyncio + async def test_mixed_content_types_all_accepted(self) -> None: + """Different ContentTypes in one batch render on same loaded_tape_mm.""" + service = AsyncMock() + service.submit_batch_job = AsyncMock(return_value=(uuid4(), [uuid4(), uuid4()])) + items = [ + PrintRequest( + content_type=ContentType.QR_TWO_LINES, + data=RawLabelData(primary_id="A", title="T", qr_payload="x"), + ), + PrintRequest( + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="y"), + ), + ] + await dispatch_batch(service=service, items=items) + service.submit_batch_job.assert_awaited_once() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/unit/services/test_batch_dispatch.py::TestBatchDispatch::test_mixed_content_types_all_accepted -v` +Expected: FAIL — current `dispatch_batch` calls `_validate_item_get_tape_mm` and raises MixedTapeSizesError for differing tape_mm. + +- [ ] **Step 3: Refactor batch_dispatch.py** + +The cleanup: +1. Remove `MixedTapeSizesError` class definition. +2. Remove `_validate_item_get_tape_mm` helper. +3. Remove the consistency-check loop in `dispatch_batch`. +4. `dispatch_batch` now just collects request items and calls `service.submit_batch_job(requests=items)`. + +Replace `dispatch_batch` body: + +```python +async def dispatch_batch( + *, + service: "PrintService", + items: list[PrintRequest], +) -> tuple[UUID, list[UUID]]: + """Submit a batch of mixed-ContentType print requests. + + Phase 1k.1a: tape consistency check removed — all items render on the + same loaded_tape_mm (read once via preflight by PrintService). + """ + if not items: + raise ValueError("Batch must contain at least one item.") + return await service.submit_batch_job(requests=items) +``` + +- [ ] **Step 4: Run tests** + +Run: `cd backend && pytest tests/unit/services/test_batch_dispatch.py -v` +Expected: PASS for new test. Old tests that asserted MixedTapeSizesError need deletion. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/batch_dispatch.py backend/tests/unit/services/test_batch_dispatch.py +git commit -m "refactor(batch-dispatch): drop MixedTapeSizesError + tape consistency check + +Phase 1k.1a Task 17: tape-independent ContentTypes erlauben jetzt +gemischte ContentTypes pro Batch — alle Items rendern auf gleicher +loaded_tape_mm. MixedTapeSizesError + _validate_item_get_tape_mm +entfernt. + +Refs #103" +``` + +--- + +### Task 18: routes/print.py Refactor + Prefix + +**Files:** +- Modify: `backend/app/api/routes/print.py` +- Test: `backend/tests/integration/test_route_print.py` (extend or replace) + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/integration/test_route_print.py (new tests) +"""Integration test for POST /api/print with content_type.""" + +from __future__ import annotations + +import pytest +from httpx import AsyncClient +from app.main import app + + +@pytest.mark.asyncio +async def test_post_api_print_with_content_type(authed_client: AsyncClient) -> None: + """POST /api/print accepts content_type + data payload.""" + payload = { + "content_type": "qr_two_lines", + "data": { + "primary_id": "K-02", + "title": "Werkstatt", + "qr_payload": "https://example.com/locations/k-02", + }, + "options": {"copies": 1}, + } + resp = await authed_client.post("/api/print/brother-p750w", json=payload) + assert resp.status_code == 202 + body = resp.json() + assert "job_id" in body + assert body["state"] in {"queued", "printing"} + + +@pytest.mark.asyncio +async def test_post_api_print_rejects_template_id(authed_client: AsyncClient) -> None: + """template_id field is rejected (extra=forbid).""" + payload = { + "template_id": "anything", + "content_type": "qr_only", + "data": {"qr_payload": "x"}, + } + resp = await authed_client.post("/api/print/brother-p750w", json=payload) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_post_print_resume_route_gone(authed_client: AsyncClient) -> None: + """POST /api/jobs/{id}/resume returns 404 (route deleted).""" + resp = await authed_client.post("/api/jobs/00000000-0000-0000-0000-000000000000/resume") + assert resp.status_code == 404 +``` + +(Assumes `authed_client` fixture exists; if not, copy the auth setup from existing integration tests.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/integration/test_route_print.py -v` +Expected: FAIL — route is currently `/print` not `/api/print`. + +- [ ] **Step 3: Refactor routes/print.py** + +The refactor: +1. Change `router = APIRouter()` to `router = APIRouter(prefix="/api")`. +2. Adapt `@router.post("/print", ...)` to `@router.post("/print/{slug}", ...)` — slug is now part of the path (or accept it via query — match existing batch.py style). +3. Remove `TapeMismatchError` import + error map entry. +4. Remove TemplateNotFoundError import + entry. +5. Add `UnsupportedTapeError`, `NoTapeLoadedError`, `ContentTypeDataMismatchError` to the error map. +6. Delete `POST /jobs/{job_id}/resume` route (if defined here). +7. Adapt response schema if needed (now returns `job_id` + `state`, no `error_detail.expected_mm` for tape_mismatch). + +Reference batch.py path style. After the change, the file should: +- Have `prefix="/api"` on the router +- Define `POST /print/{printer_slug}` (the slug parameter routes to the right printer) +- Not import TapeMismatchError or TemplateNotFoundError +- Not have a resume endpoint + +If the printer-slug resolution was previously done via dependency injection, keep that pattern but make sure the path matches `/api/print/{slug}` overall. + +- [ ] **Step 4: Run tests** + +Run: `cd backend && pytest tests/integration/test_route_print.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/routes/print.py backend/tests/integration/test_route_print.py +git commit -m "feat(api): /api/print prefix + content_type schema + remove resume + +Phase 1k.1a Task 18: APIRouter(prefix=/api), POST /api/print/{slug} +mit content_type-Body, template_id und on_tape_mismatch raus, +/api/jobs/{id}/resume Route entfernt. UnsupportedTapeError(409), +NoTapeLoadedError(409), ContentTypeDataMismatchError(422) mapping. + +Refs #103" +``` + +--- + +### Task 19: routes/batch.py Refactor + +**Files:** +- Modify: `backend/app/api/routes/batch.py` +- Test: `backend/tests/integration/test_route_batch.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/integration/test_route_batch.py +"""POST /api/print/{slug}/batch accepts content_type-keyed items.""" + +from __future__ import annotations + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_post_batch_content_type(authed_client: AsyncClient) -> None: + payload = { + "items": [ + { + "content_type": "qr_two_lines", + "data": { + "primary_id": "A", "title": "T1", + "qr_payload": "https://example.com/a", + }, + }, + { + "content_type": "qr_only", + "data": {"qr_payload": "https://example.com/b"}, + }, + ], + } + resp = await authed_client.post("/api/print/brother-p750w/batch", json=payload) + assert resp.status_code == 202 + body = resp.json() + assert "batch_id" in body + assert len(body["job_ids"]) == 2 + + +@pytest.mark.asyncio +async def test_batch_rejects_template_id(authed_client: AsyncClient) -> None: + payload = { + "items": [{"template_id": "anything", "data": {"qr_payload": "x"}}], + } + resp = await authed_client.post("/api/print/brother-p750w/batch", json=payload) + assert resp.status_code == 422 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/integration/test_route_batch.py -v` +Expected: FAIL — batch currently uses template_id-keyed items. + +- [ ] **Step 3: Refactor routes/batch.py** + +The refactor: +1. Update the BatchRequest items schema to use `content_type: ContentType` field (mirrors PrintRequest). +2. Delete the `MixedTapeSizesError` handler in the error map (and the `400` mapping). +3. Keep the existing routing structure; just replace `template_id` references with `content_type` in the Pydantic models. + +If `BatchItem` is defined as a Pydantic model in this file, change: +```python +class BatchItem(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + content_type: ContentType + data: RawLabelData | None = None + lookup: PrintLookupRequest | None = None + options: PrintOptions = PrintOptions() + # validator: exactly one of data/lookup +``` + +- [ ] **Step 4: Run tests** + +Run: `cd backend && pytest tests/integration/test_route_batch.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/routes/batch.py backend/tests/integration/test_route_batch.py +git commit -m "feat(api): batch route content_type + drop MixedTapeSizesError + +Phase 1k.1a Task 19: BatchItem schema uses content_type. Mixed +ContentTypes pro Batch erlaubt — alle rendern auf loaded_tape_mm. +MixedTapeSizesError 400 mapping entfernt. + +Refs #103" +``` + +--- + +### Task 20: routes/jobs.py — Remove resume + +**Files:** +- Modify: `backend/app/api/routes/jobs.py` +- Test: `backend/tests/integration/test_route_jobs.py` (assertion already in Task 18) + +- [ ] **Step 1: Locate the resume route** + +```bash +grep -n "resume" backend/app/api/routes/jobs.py +``` + +- [ ] **Step 2: Delete the route function** + +Open `backend/app/api/routes/jobs.py` and delete the `@router.post(".../resume", ...)` decorator + function. Also remove any related helper imports if unused. + +- [ ] **Step 3: Run integration test** + +The test from Task 18 (`test_post_print_resume_route_gone`) verifies this returns 404: +Run: `cd backend && pytest tests/integration/test_route_print.py::test_post_print_resume_route_gone -v` +Expected: PASS (404) + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/api/routes/jobs.py +git commit -m "feat(api): remove POST /api/jobs/{id}/resume route + +Phase 1k.1a Task 20: PAUSED-State obsolet ab 1k.1, Resume-Route entfernt. + +Refs #103" +``` + +--- + +### Task 21: error_handlers.py Update + +**Files:** +- Modify: `backend/app/api/error_handlers.py` +- Test: `backend/tests/integration/test_error_handlers.py` (extend or new) + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/integration/test_error_handlers.py +"""Integration tests for global exception -> HTTP-Status mapping.""" + +from __future__ import annotations + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_unsupported_tape_returns_409(authed_client: AsyncClient) -> None: + """If preflight returns an unsupported tape (e.g. mock 36mm), expect 409.""" + # This needs a fixture that mocks preflight to return tape_mm=36. + # Skip if the fixture isn't trivially available; otherwise: + pytest.skip("Requires fixture for preflight=36mm mock") + + +@pytest.mark.asyncio +async def test_content_type_data_mismatch_returns_422(authed_client: AsyncClient) -> None: + payload = { + "content_type": "qr_two_lines", + "data": {"primary_id": "X"}, # missing title + qr_payload + } + resp = await authed_client.post("/api/print/brother-p750w", json=payload) + assert resp.status_code == 422 + body = resp.json() + assert body.get("error_code") == "content_type_data_mismatch" + + +@pytest.mark.asyncio +async def test_tape_mismatch_error_class_gone(authed_client: AsyncClient) -> None: + """TapeMismatchError must not be referenced anywhere in routes.""" + from app.api import error_handlers as mod + # Module should not import TapeMismatchError nor MixedTapeSizesError + src = open(mod.__file__).read() + assert "TapeMismatchError" not in src + assert "MixedTapeSizesError" not in src +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && pytest tests/integration/test_error_handlers.py -v` +Expected: FAIL on the content_type test (mapping doesn't exist yet) and import-test if old refs still present. + +- [ ] **Step 3: Modify error_handlers.py** + +Open `backend/app/api/error_handlers.py`. Changes: + +1. Remove imports + mappings for `TapeMismatchError`, `MixedTapeSizesError`. +2. Add imports + handlers for `UnsupportedTapeError`, `NoTapeLoadedError`, `ContentTypeDataMismatchError`. + +Pattern (adjust to match existing handler structure): + +```python +from app.printer_backends.exceptions import ( + ContentTypeDataMismatchError, + NoTapeLoadedError, + UnsupportedTapeError, +) + +def register_error_handlers(app: FastAPI) -> None: + # ... existing handlers ... + + @app.exception_handler(UnsupportedTapeError) + async def _h_unsupported_tape(_, exc: UnsupportedTapeError): + return JSONResponse( + status_code=409, + content={ + "error_code": "unsupported_tape", + "error_message": str(exc), + "error_detail": {"tape_mm": exc.tape_mm}, + }, + ) + + @app.exception_handler(NoTapeLoadedError) + async def _h_no_tape(_, exc: NoTapeLoadedError): + return JSONResponse( + status_code=409, + content={ + "error_code": "no_tape_loaded", + "error_message": str(exc), + }, + ) + + @app.exception_handler(ContentTypeDataMismatchError) + async def _h_content_type_data_mismatch(_, exc: ContentTypeDataMismatchError): + return JSONResponse( + status_code=422, + content={ + "error_code": "content_type_data_mismatch", + "error_message": str(exc), + "error_detail": { + "content_type": exc.content_type, + "missing_fields": list(exc.missing_fields), + }, + }, + ) +``` + +- [ ] **Step 4: Run tests** + +Run: `cd backend && pytest tests/integration/test_error_handlers.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/error_handlers.py backend/tests/integration/test_error_handlers.py +git commit -m "feat(api): error_handlers — new exception mappings + remove obsolete + +Phase 1k.1a Task 21: TapeMismatchError + MixedTapeSizesError handlers +geloescht. UnsupportedTapeError(409), NoTapeLoadedError(409), +ContentTypeDataMismatchError(422) handlers hinzugefuegt. + +Refs #103" +``` + +--- + +### Task 22: Alembic Migration (templates drop + jobs columns + backfill) + +**Files:** +- Create: `backend/alembic/versions/_phase_1k1a_drop_templates_add_content_columns.py` +- Modify: `backend/app/models/job.py` (add new columns to ORM model) +- Test: `backend/tests/integration/test_alembic_migration.py` + +- [ ] **Step 1: Generate the migration revision file** + +```bash +cd backend +alembic revision -m "phase_1k1a_drop_templates_add_content_columns" +``` + +This creates `backend/alembic/versions/_phase_1k1a_drop_templates_add_content_columns.py`. + +- [ ] **Step 2: Write the migration** + +Open the generated revision and replace `upgrade`/`downgrade`: + +```python +"""phase_1k1a_drop_templates_add_content_columns + +Revision ID: +Revises: +Create Date: 2026-06-05 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "" +down_revision = "" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1) Add new columns to jobs; template_key becomes nullable for new jobs. + with op.batch_alter_table("jobs") as batch_op: + batch_op.add_column(sa.Column("content_type", sa.String(32), nullable=True)) + batch_op.add_column(sa.Column("rendered_tape_mm", sa.Integer(), nullable=True)) + batch_op.alter_column("template_key", nullable=True) + + # 2) Deterministic backfill from template_key for historical jobs. + # Webhook-keys (spoolman/, grocy/) won't match -> content_type=NULL. + bind = op.get_bind() + bind.execute(sa.text(""" + UPDATE jobs SET + content_type = CASE + WHEN template_key LIKE 'qr-only-%' THEN 'qr_only' + WHEN template_key LIKE 'samla-%' THEN 'qr_two_lines' + WHEN template_key IN ( + 'hangar-furniture-12mm', 'grocy-12mm', + 'snipeit-12mm', 'spoolman-12mm' + ) THEN 'qr_two_lines' + WHEN template_key IN ( + 'hangar-furniture-18mm', 'hangar-furniture-24mm', + 'grocy-18mm', 'grocy-24mm', + 'snipeit-18mm', 'snipeit-24mm', + 'spoolman-18mm', 'spoolman-24mm' + ) THEN 'qr_three_lines' + ELSE NULL + END, + rendered_tape_mm = CASE + WHEN template_key LIKE '%-12mm' THEN 12 + WHEN template_key LIKE '%-18mm' THEN 18 + WHEN template_key LIKE '%-24mm' THEN 24 + WHEN template_key LIKE '%-62mm' THEN 62 + ELSE NULL + END + """)) + + # 3) Drop the templates table — TemplateLoader is obsolete. + op.drop_table("templates") + + +def downgrade() -> None: + op.create_table( + "templates", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("key", sa.String, unique=True, nullable=False), + sa.Column("definition", sa.JSON, nullable=False), + sa.Column("created_at", sa.DateTime, server_default=sa.func.now()), + ) + with op.batch_alter_table("jobs") as batch_op: + batch_op.alter_column("template_key", nullable=False) + batch_op.drop_column("rendered_tape_mm") + batch_op.drop_column("content_type") +``` + +- [ ] **Step 3: Update the SQLAlchemy Job model** + +Edit `backend/app/models/job.py` to add the new columns: + +```python +# in the Job model class +content_type: Mapped[str | None] = mapped_column(String(32), nullable=True) +rendered_tape_mm: Mapped[int | None] = mapped_column(Integer, nullable=True) +template_key: Mapped[str | None] = mapped_column(String, nullable=True) # now nullable +``` + +(Adapt to the existing model's syntax — `Mapped` style or older `Column` style.) + +Also update `backend/app/repositories/jobs.py` if `save_queued` (or equivalent) sets `template_key` — accept it as optional and add `content_type` + `rendered_tape_mm` kwargs. + +- [ ] **Step 4: Write the migration test** + +```python +# backend/tests/integration/test_alembic_migration.py +"""Verify the 1k.1a migration upgrades + downgrades on a fresh DB.""" + +from __future__ import annotations + +import subprocess + + +def test_alembic_upgrade_head() -> None: + """Migration runs cleanly from base to head.""" + result = subprocess.run( + ["alembic", "upgrade", "head"], + cwd="backend", + capture_output=True, text=True, + ) + assert result.returncode == 0, result.stderr + + +def test_alembic_downgrade_one_then_upgrade() -> None: + """Downgrade + upgrade round-trip works.""" + subprocess.run(["alembic", "downgrade", "-1"], cwd="backend", check=True) + subprocess.run(["alembic", "upgrade", "head"], cwd="backend", check=True) +``` + +- [ ] **Step 5: Run migration tests** + +Run: `cd backend && pytest tests/integration/test_alembic_migration.py -v` +Expected: PASS (both) + +- [ ] **Step 6: Commit** + +```bash +git add backend/alembic/versions/*_phase_1k1a_drop_templates_add_content_columns.py backend/app/models/job.py backend/app/repositories/jobs.py backend/tests/integration/test_alembic_migration.py +git commit -m "feat(db): Alembic migration — drop templates + add jobs.content_type/rendered_tape_mm + +Phase 1k.1a Task 22: drops templates table, adds jobs.content_type + +rendered_tape_mm via op.batch_alter_table (SQLite-kompatibel). +jobs.template_key wird nullable (Audit-Spalte fuer historische Jobs). +Backfill aus template_key deterministisch fuer Seed-Template-Keys; +Webhook-Keys (spoolman/, grocy/) behalten content_type=NULL. + +Refs #103" +``` + +--- + +### Task 23: File-Cleanup (Delete obsolete files) + +**Files:** +- Delete: 13 files/dirs (siehe Plan-Header "Deleted"-Liste) + +- [ ] **Step 1: Delete service files** + +```bash +cd backend +rm app/services/label_renderer.py +rm app/services/template_loader.py +rm app/services/svg_renderer.py +``` + +- [ ] **Step 2: Delete schemas** + +```bash +rm app/schemas/template.py +rm app/schemas/template_read.py +``` + +- [ ] **Step 3: Delete models + repositories** + +```bash +rm app/models/template.py +rm app/repositories/templates.py +``` + +- [ ] **Step 4: Delete routes** + +```bash +rm app/api/routes/templates.py +rm app/api/routes/templates_preview.py +``` + +- [ ] **Step 5: Delete seed YAMLs** + +```bash +rm app/seed/templates/*.yaml +rmdir app/seed/templates 2>/dev/null || true +``` + +- [ ] **Step 6: Delete obsolete tests** + +```bash +find tests -name "test_label_renderer*" -delete +find tests -name "test_template*" -delete +find tests -name "test_svg_renderer*" -delete +``` + +- [ ] **Step 7: Run full test suite — find remaining import errors** + +Run: `cd backend && pytest -x 2>&1 | head -40` +Expected: import errors in files that still reference deleted modules. Note them for Task 24. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "chore: delete obsolete template-related files (Phase 1k.1a) + +Phase 1k.1a Task 23: hard-cut Loeschung von: +- label_renderer.py, template_loader.py, svg_renderer.py (Services) +- template.py, template_read.py (Schemas) +- models/template.py, repositories/templates.py (DB-Aggregat) +- routes/templates.py, routes/templates_preview.py (API) +- seed/templates/*.yaml (21 YAML-Files) +- alle test_label_renderer*, test_template*, test_svg_renderer* + +Refs #103" +``` + +--- + +### Task 24: main.py + lifespan.py Cleanup + +**Files:** +- Modify: `backend/app/main.py` +- Modify: `backend/app/lifespan.py` + +- [ ] **Step 1: Locate the orphaned imports/calls** + +```bash +cd backend +grep -n "templates_routes\|templates_preview_router\|TemplateLoader\|LabelRenderer" app/main.py app/lifespan.py +``` + +- [ ] **Step 2: Remove from main.py** + +Open `backend/app/main.py`. Delete: +- Imports of `templates_routes`, `templates_preview_router`, `LabelRenderer`, `TemplateLoader`. +- `app.include_router(templates_routes.router)` / `.render_router`. +- `app.include_router(templates_preview_router)`. +- Any `app.state.renderer = LabelRenderer(...)` or `app.state.template_loader = ...` lines. + +Add: +- Import `from app.services.layout_engine import LayoutEngine`. +- In the lifespan / app-startup, `app.state.engine = LayoutEngine()`. + +- [ ] **Step 3: Remove from lifespan.py** + +Delete any `TemplateLoader(...).preload()` calls. Add LayoutEngine wiring if not already done in main.py. + +- [ ] **Step 4: Run full test suite** + +Run: `cd backend && pytest -x 2>&1 | tail -30` +Expected: PASS (or only failures in tests still to be addressed in Task 25) + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/main.py backend/app/lifespan.py +git commit -m "chore: main.py + lifespan.py — drop TemplateLoader/LabelRenderer wiring + +Phase 1k.1a Task 24: Router-Registrierungen fuer templates entfernt. +app.state.engine = LayoutEngine() statt LabelRenderer. Lifespan-Preload +fuer Templates raus. + +Refs #103" +``` + +--- + +### Task 25: Final Integration + Smoke Test Setup + +**Files:** +- Create: `backend/tests/integration/test_phase_1k1a_integration.py` +- Create: `backend/scripts/smoke_layout_engine_12mm_v4.py` + +- [ ] **Step 1: Write the full integration test** + +```python +# backend/tests/integration/test_phase_1k1a_integration.py +"""Full integration test: API -> Engine -> Queue for content_type-based flow.""" + +from __future__ import annotations + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_full_print_flow_qr_two_lines(authed_client: AsyncClient) -> None: + """End-to-end happy path on a mocked printer.""" + payload = { + "content_type": "qr_two_lines", + "data": { + "primary_id": "K-02", + "title": "Werkstatt", + "qr_payload": "https://example.com/locations/k-02", + }, + "options": {"copies": 1, "auto_cut": True}, + } + resp = await authed_client.post("/api/print/brother-p750w-mock", json=payload) + assert resp.status_code == 202 + + +@pytest.mark.asyncio +async def test_full_batch_flow_mixed_content_types(authed_client: AsyncClient) -> None: + payload = { + "items": [ + { + "content_type": "qr_two_lines", + "data": {"primary_id": "A", "title": "T", "qr_payload": "x"}, + }, + { + "content_type": "qr_only", + "data": {"qr_payload": "y"}, + }, + { + "content_type": "qr_three_lines", + "data": { + "primary_id": "G1", "title": "Marmelade", + "qr_payload": "z", "secondary": ["MHD 2027-04-30"], + }, + }, + ], + } + resp = await authed_client.post( + "/api/print/brother-p750w-mock/batch", json=payload, + ) + assert resp.status_code == 202 + body = resp.json() + assert len(body["job_ids"]) == 3 + + +@pytest.mark.asyncio +async def test_preview_endpoint_post(authed_client: AsyncClient) -> None: + payload = { + "content_type": "qr_two_lines", + "tape_mm": 12, + "data": { + "primary_id": "X", "title": "Y", + "qr_payload": "https://example.com/x", + }, + "format": "png", + } + resp = await authed_client.post("/api/render/preview", json=payload) + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("image/png") +``` + +- [ ] **Step 2: Write the smoke-test script** + +```python +# backend/scripts/smoke_layout_engine_12mm_v4.py +"""Manual smoke test: render qr_two_lines for 12mm and save PNG for visual diff. + +Run: + cd backend && python scripts/smoke_layout_engine_12mm_v4.py +Output: + /tmp/smoke_v4_12mm.png — visual diff against Phase 1i V4-Winner reference. +""" + +from __future__ import annotations + +from pathlib import Path + +from app.schemas.content_type import ContentType +from app.schemas.label_data import LabelData +from app.services.layout_engine import LayoutEngine + + +def main() -> None: + eng = LayoutEngine() + img = eng.render( + tape_mm=12, + content_type=ContentType.QR_TWO_LINES, + data=LabelData( + source_app="hangar", + primary_id="K-02", + title="Werkstatt", + qr_payload="https://example.com/locations/k-02", + ), + ) + out = Path("/tmp/smoke_v4_12mm.png") + img.save(out) + print(f"Wrote {out} ({img.width}x{img.height})") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 3: Implement POST /api/render/preview endpoint (if not done in Task 18)** + +Add to `backend/app/api/routes/print.py` (router prefix already `/api`): + +```python +from io import BytesIO +from fastapi.responses import Response + +class PreviewRequest(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + content_type: ContentType + tape_mm: int + data: RawLabelData + format: Literal["png"] = "png" # svg later if needed + + +@router.post("/render/preview", tags=["render"]) +async def render_preview(req: PreviewRequest, http: Request) -> Response: + engine: LayoutEngine = http.app.state.engine + label = LabelData( + source_app="preview", + title=req.data.title, primary_id=req.data.primary_id, + qr_payload=req.data.qr_payload, secondary=req.data.secondary, + items=req.data.items, + ) + img = engine.render(req.tape_mm, req.content_type, label) + buf = BytesIO() + img.save(buf, format="PNG") + return Response(content=buf.getvalue(), media_type="image/png") +``` + +- [ ] **Step 4: Run integration test** + +Run: `cd backend && pytest tests/integration/test_phase_1k1a_integration.py -v` +Expected: PASS (3 tests) + +- [ ] **Step 5: Run smoke-test script** + +Run: `cd backend && python scripts/smoke_layout_engine_12mm_v4.py` +Expected: Output `Wrote /tmp/smoke_v4_12mm.png (x70)`. Manually compare against Phase 1i V4-Winner output (visual diff in browser). + +- [ ] **Step 6: Run lint + type-check + full suite** + +```bash +cd backend +ruff check . +ruff format --check . +mypy . +pytest --cov=app --cov-report=term-missing +``` + +Expected: lint clean, mypy clean, all tests pass, coverage ≥90% on new modules. + +- [ ] **Step 7: Commit** + +```bash +git add backend/tests/integration/test_phase_1k1a_integration.py backend/scripts/smoke_layout_engine_12mm_v4.py backend/app/api/routes/print.py +git commit -m "test(integration): full Phase 1k.1a flow + 12mm V4-Winner smoke script + +Phase 1k.1a Task 25: end-to-end tests verifying API -> LayoutEngine -> +Queue. POST /api/render/preview endpoint hinzugefuegt. Smoke-Script fuer +12mm V4-Baseline-Vergleich (Phase 1i Visual-Diff). + +Refs #103" +``` + +--- + +## Final Steps + +- [ ] **Push branch + open PR** + +```bash +git push -u origin feat/phase-1k1a-layout-engine +gh pr create --base main --title "feat(1k.1a): Hub Layout-Engine — replaces 21 templates with semantic engine" --body-file - <<'EOF' +## Summary + +Implements Phase 1k.1a per approved spec. + +Closes #103 partially (Phase 1k.1a only — 1k.1b/c/d folgen separat). +Refs #101. + +Spec: `docs/superpowers/specs/2026-06-05-phase-1k1-layout-engine-design.md` +Plan: `docs/superpowers/plans/2026-06-05-phase-1k1a-layout-engine-plan.md` + +## Test plan + +- [x] Unit: TapeGeometry, ContentType, LayoutEngine (all 7 render methods) +- [x] Unit: PrintService, PrintQueue rerender, batch_dispatch +- [x] Integration: POST /api/print, /api/print/{slug}/batch, /api/render/preview +- [x] Integration: TapeMismatchError + resume routes gone (404/422) +- [x] Integration: Alembic upgrade + downgrade +- [x] Coverage ≥90% on new modules + +Hardware smoke (post-deploy): 12mm V4-Winner visual identical to Phase 1i output. +EOF +``` + +- [ ] **Hardware-Smoke nach Deploy** + +After Watchtower deploys the new image to the test environment: +1. Send a 12mm `qr_two_lines` print via the new API to the PT-P750W. +2. Compare the printed label visually against the Phase 1i V4-Winner reference. +3. Validate scan-ability of the QR code. +4. Document the result in a follow-up issue/comment. + +--- + +## Self-Review Summary + +**Spec coverage:** +- Sektion 1 (Executive Summary): covered by Tasks 1-13 (Engine + Schemas) and Tasks 14-21 (API/Service refactors) +- Sektion 2 (ContentTypes + Validation): Tasks 2 + 6 (skeleton) + 7-13 (per-type) +- Sektion 3 (TapeGeometry): Task 1 +- Sektion 4 (LayoutEngine API): Tasks 5-13 +- Sektion 5 (1k.1a Files): Tasks 14-25 cover every file in the spec's modify/delete lists +- Sektion 9 (Testing): each task includes failing-test-first + final integration tests + smoke +- Sektion 10 DoD 1k.1a: all checkboxes mapped to tasks + +**Placeholder scan:** No "TBD", "TODO" outside intentional `TODO(phase5)` from existing code. All steps contain runnable code. + +**Type consistency:** All tasks reference `tape_mm: int`, `content_type: ContentType`, `LabelData` with optional fields. `ContentType` enum used consistently. + +**Decomposition complete:** 25 tasks, each producing self-contained commits. Tasks 6-13 build the engine incrementally (skeleton + 7 render methods, one per task) to enable per-task subagent review. diff --git a/docs/superpowers/specs/2026-06-05-phase-1k1-layout-engine-design.md b/docs/superpowers/specs/2026-06-05-phase-1k1-layout-engine-design.md new file mode 100644 index 0000000..7f6eaf1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-phase-1k1-layout-engine-design.md @@ -0,0 +1,808 @@ +# Phase 1k.1 — Layout-Engine + TapeGeometry + ContentTypes (Design) + +**Datum:** 2026-06-05 +**Status:** Approved — Ready for writing-plans (Phase 1k.1a) +**Tracking:** strausmann/Label-Printer-Hub#103 (Phase 1k.1 unter Umbrella #101) +**Vorgaenger-Spec:** docs/superpowers/specs/2026-05-17-phase-7e-template-layout-v2-design.md (subsumiert) +**Hardware-Baseline:** Phase 1i V4-Winner — empirisch validiert auf PT-P750W mit 12mm TZe-Tape. Pixel-Werte (QR x=2 y=2 max_size=66, text_start_x=72, font_xl=22, font_l=18) dokumentiert im Issue-Kommentar zu Issue #103. Das originale Smoke-Test-Protokoll liegt im privaten `homelab-management` Repo (kein OS-Pfad in diesem Repo). + +## 1. Executive Summary + +Phase 1k.1 ersetzt die 21 hartcodierten YAML-Templates (hangar/grocy/snipeit/spoolman/qr-only x 12/18/24mm + 6 Samla) durch eine semantische **Layout-Engine** mit zwei Achsen: + +1. **TapeGeometry** — Tabelle mit **initialem Scope** von 7 Tape-Groessen (**4**/6/9/12/18/24/62mm — `int`, kleinste PT-TZe ist **4mm** (24 Print-Pins) — nicht 3.5mm wie in fruehen Drafts) und ihren Render-Parametern (printable_px, qr_max, Font-Groessen). Die bestehende `TapeRegistry` kennt zusaetzliche QL-DK-Breiten (29/38/50/54mm) — Layout-Engine in 1k.1 deckt diese **bewusst noch nicht** ab; entsprechende Print-Requests fuehren zu `UnsupportedTapeError`. Erweiterung der Tabelle ist Constants-Aenderung (kein Code-Refactor) und kann als Folge-Phase ergaenzt werden. +2. **7 ContentTypes** — semantische Beschreibung was gerendert wird, **tape-unabhaengig** (qr_only, qr_one_line, qr_two_lines, **qr_three_lines** fuer 3-Zeilen-Layouts mit secondary, text_one_line, text_two_lines, qr_with_listing) + +**Tape-Unabhaengigkeit** ist der Kern-Wechsel: Hangar sendet `content_type: qr_two_lines` (ohne tape_mm), Hub liest `preflight.loaded_tape_mm` vom Drucker und rendert passend. Der bestehende `TapeMismatchError` wird damit obsolet — User wechselt physisch das Tape, das System rendert automatisch. + +**Hard-Cut Migration:** Keine Legacy-Kompatibilitaet, keine Compat-Layer fuer alte `template_id`-Calls. Das System ist noch in Entwicklung, alle 21 YAMLs werden geloescht, alle Aufrufer auf die neue API umgestellt. + +**Scope-Decomposition in 4 Sub-Phasen** (sequentiell mergebar): +- **1k.1a:** Hub Layout-Engine + neue API (Python) +- **1k.1b:** Hangar API-Migration (Go) +- **1k.1c:** Hangar Categories DB + CRUD-Editor + Live-Preview (Go) +- **1k.1d:** Hangar Navigation-Refactor (Administration-Submenu) + +## 2. ContentTypes (semantische Render-Beschreibung) + +Sieben Types decken alle bisherigen Use Cases ab. Jeder Type definiert WAS gerendert wird, nicht WIE — die TapeGeometry-Tabelle und der Renderer berechnen Pixel-Positionen automatisch. + +| ContentType | Layout-Beschreibung | Genutzte LabelData-Felder | Original Templates (vorher) | +|-------------|---------------------|---------------------------|----------------------------| +| `qr_only` | QR fuellt volle Tape-Hoehe, kein Text | `qr_payload` | qr-only-12mm, qr-only-18mm, qr-only-24mm | +| `qr_one_line` | QR links + 1 Text-Zeile (XL, vertikal zentriert) | `qr_payload`, `primary_id` | (neu, war Sonderfall) | +| `qr_two_lines` | QR links + 2 Text-Zeilen (XL primary_id + L title) | `qr_payload`, `primary_id`, `title` | hangar-furniture-12mm, grocy-12mm, snipeit-12mm, spoolman-12mm, samla-stirntag-12mm, samla-stirntag-24mm, samla-stirntag-62mm, samla-deckel-12mm, samla-deckel-24mm, samla-deckel-62mm | +| `qr_three_lines` | QR links + 3 Text-Zeilen (XL primary_id + L title + S secondary[0]) | `qr_payload`, `primary_id`, `title`, `secondary` | hangar-furniture-18mm, hangar-furniture-24mm, grocy-18mm, grocy-24mm, snipeit-18mm, snipeit-24mm, spoolman-18mm, spoolman-24mm | +| `text_one_line` | Voll-Breite Text XL, kein QR | `primary_id` | (neu, kein altes Template) | +| `text_two_lines` | 2 Text-Zeilen XL + L, kein QR | `primary_id`, `title` | (neu, kein altes Template) | +| `qr_with_listing` | QR links + N Item-Zeilen (M-Groesse), Overflow zeigt "+N more" | `qr_payload`, `primary_id` (Header), `items: tuple[LabelDataItem,...]` | (neu, fuer Kallax-Regal-Uebersicht aus 7e-Spec) | + +**User-Designentscheidung:** Samla-Boxen bekommen unabhaengig von der Anbringungsart (Stirn / Front / Deckel) das gleiche Label-Layout `qr_two_lines`. Die 6 Original-Templates `samla-stirntag-12/24/62mm` + `samla-deckel-12/24/62mm` werden durch eine einzige Hangar-Category "Samla" mit `content_type: qr_two_lines` ersetzt. + +### Validation-Regeln + +**Schema-Anpassung erforderlich:** Im bestehenden Backend sind `LabelData.title`, `LabelData.primary_id` und `LabelData.qr_payload` als required Pydantic-Felder modelliert. Mit 7 ContentTypes die jeweils nur einen Teil der Felder benoetigen (z.B. `qr_only` nur qr_payload, `text_one_line` nur primary_id), wuerde Pydantic-Validation bereits 422 werfen bevor die Engine-Validation greift. + +**Loesung:** In Phase 1k.1a werden die Felder im `LabelData`-Basismodell **optional** gemacht (`str | None = None`). Die ContentType-spezifischen Pflichtfeld-Checks passieren **zentral in `LayoutEngine._validate_data(content_type, data)`** als ContentTypeDataMismatchError (422). `source_app` bleibt das einzige zwingend gesetzte Feld auf Datenebene. + +Pflichtfeld-Matrix (gepueft in `_validate_data`): + +| ContentType | qr_payload | primary_id | title | secondary | items | source_app | +|-------------|-----------|------------|-------|-----------|-------|------------| +| `qr_only` | erforderlich | — | — | — | — | erforderlich | +| `qr_one_line` | erforderlich | erforderlich | — | — | — | erforderlich | +| `qr_two_lines` | erforderlich | erforderlich | erforderlich | — | — | erforderlich | +| `qr_three_lines` | erforderlich | erforderlich | erforderlich | mind. 1 Eintrag | — | erforderlich | +| `text_one_line` | — | erforderlich | — | — | — | erforderlich | +| `text_two_lines` | — | erforderlich | erforderlich | — | — | erforderlich | +| `qr_with_listing` | erforderlich | erforderlich (Header) | — | — | mind. 1 Item | erforderlich | + +Bei Verstoss: `ContentTypeDataMismatchError(content_type, missing_fields)` -> 422. Felder die nicht erforderlich sind, werden beim Rendern ignoriert (nicht abgelehnt). + +## 3. TapeGeometry (alle 7 Tape-Groessen) + +Pixel-Werte aus Brother Pin-Konfiguration (PT-Serie 180 DPI, QL 300 DPI). Die 12mm-Zeile ist empirisch validiert (Phase 1i V4-Winner). Die anderen Zeilen sind via Pixel-Ratio extrapoliert und werden nach Implementation per Smoke-Test validiert. + +**Wichtig:** `tape_mm` ist konsistent mit dem bestehenden Backend (`TapeSpec.width_mm: int`, SNMP-Parsing `int`) als **Integer** modelliert. Die kleinste PT-TZe-Tape-Groesse ist **4mm** (24 Print-Pins), nicht 3.5mm. + +`qr_max_px` folgt der allgemeinen Formel `printable_px - 2 * qr_padding_px` — damit ist die Geometrie pro Eintrag konsistent und nicht abhaengig von einem hardgecodeten Padding. + +`text_start_x` ist die **absolute Pixel-X-Position** ab dem linken Tape-Rand. Sie setzt sich aus drei Komponenten zusammen: + +1. **Linker QR-Inset** = `qr_padding_px` (Abstand vom linken Tape-Rand bis zur linken QR-Kante) +2. **QR-Code** = `qr_max_px` (Breite des QR) +3. **Trenn-Gap zwischen QR und Text** = `2 * qr_padding_px` (rechte QR-Marge `qr_padding_px` + linker Text-Inset `qr_padding_px`) + +Daraus: `text_start_x = qr_padding_px + qr_max_px + 2 * qr_padding_px = printable_px + qr_padding_px`. Bei reinen Text-ContentTypes ohne QR (text_one_line, text_two_lines) wird `text_start_x` ignoriert — Text rendert ab `qr_padding_px` vom linken Tape-Rand. + +```python +# backend/app/schemas/tape_geometry.py + +from pydantic import BaseModel, ConfigDict, Field + + +class TapeGeometry(BaseModel): + """Render-Parameter pro Tape-Groesse (alle Werte in Pixel).""" + model_config = ConfigDict(frozen=True, extra="forbid") + + printable_px: int = Field(gt=0) # Brother Pin-Count fuer die Tape-Groesse + qr_max_px: int = Field(gt=0) # printable_px - 2 * qr_padding_px (Quadrat fuer QR) + qr_padding_px: int = Field(ge=0) # Padding um den QR-Code (auch Gap zur ersten Text-Zeile) + text_start_x: int = Field(ge=0) # Absolute X-Position wo Text nach QR beginnt + line_spacing_px: int = Field(ge=0) # Vertikaler Abstand zwischen Text-Zeilen + font_xl: int = Field(gt=0) # primary_id Groesse + font_l: int = Field(gt=0) # title Groesse + font_m: int = Field(gt=0) # listing item Groesse + font_s: int = Field(gt=0) # secondary Groesse + + +# tape_mm als int — konsistent mit TapeSpec.width_mm und SNMP-Parsing +TAPE_GEOMETRY: dict[int, TapeGeometry] = { + 4: TapeGeometry(printable_px=24, qr_max_px=20, qr_padding_px=2, text_start_x=26, line_spacing_px=1, font_xl=8, font_l=7, font_m=6, font_s=5), + 6: TapeGeometry(printable_px=32, qr_max_px=28, qr_padding_px=2, text_start_x=34, line_spacing_px=2, font_xl=10, font_l=9, font_m=7, font_s=6), + 9: TapeGeometry(printable_px=50, qr_max_px=46, qr_padding_px=2, text_start_x=52, line_spacing_px=3, font_xl=14, font_l=12, font_m=10, font_s=8), + 12: TapeGeometry(printable_px=70, qr_max_px=66, qr_padding_px=2, text_start_x=72, line_spacing_px=4, font_xl=22, font_l=18, font_m=14, font_s=10), # V4-Winner + 18: TapeGeometry(printable_px=112, qr_max_px=108, qr_padding_px=2, text_start_x=114, line_spacing_px=6, font_xl=32, font_l=26, font_m=20, font_s=14), + 24: TapeGeometry(printable_px=128, qr_max_px=124, qr_padding_px=2, text_start_x=130, line_spacing_px=8, font_xl=36, font_l=30, font_m=24, font_s=18), + 62: TapeGeometry(printable_px=696, qr_max_px=688, qr_padding_px=4, text_start_x=700, line_spacing_px=20, font_xl=120, font_l=96, font_m=72, font_s=48), # QL 300 DPI +} +``` + +### Empirische Validierung post-Deploy + +12mm-Werte aus Phase 1i V4-Winner sind scan-verifiziert (siehe Issue #103 Issue-Kommentar fuer Detailwerte). + +**Extrapolations-Methodologie fuer 4/6/9/18/24/62mm:** +1. **Font-Groessen** (`font_xl`, `font_l`, `font_m`, `font_s`): via Pixel-Ratio `new_value = round(12mm_value * new_printable_px / 70)`, dann auf sinnvolle Lesbarkeit-Grenzen geclamped (Minimum 5px fuer 4mm). +2. **`qr_padding_px`**: bewusst konstant bei `2` fuer 4-24mm Tape; auf `4` erhoeht fuer 62mm (hoehere DPI, mehr Platz). Kein lineares Scaling. +3. **`line_spacing_px`**: via Ratio extrapoliert, dann auf Ganzzahl-Werte gerundet. +4. **`text_start_x`**: deterministisch berechnet als `printable_px + qr_padding_px` (folgt der Formel aus dem Header-Block dieser Sektion). +5. **`qr_max_px`**: deterministisch berechnet als `printable_px - 2 * qr_padding_px`. + +Smoke-Test als Follow-up-Issue: jede Tape-Groesse einmal mit `qr_two_lines` drucken, Lesbarkeit pruefen, ggf. Werte korrigieren. User hat 24mm-Tapes und QL-Rollen verfuegbar. + +## 4. Layout-Engine API + +```python +# backend/app/services/layout_engine.py + +from PIL import Image +from app.schemas.content_type import ContentType +from app.schemas.label_data import LabelData +from app.schemas.tape_geometry import TAPE_GEOMETRY + + +class LayoutEngine: + """Rendert Labels semantisch pro Tape-Groesse + ContentType. + + Ersetzt LabelRenderer komplett. Keine Templates mehr — Engine kennt + alle Kombinationen von TapeGeometry x ContentType. + """ + + def render( + self, + tape_mm: int, + content_type: ContentType, + data: LabelData, + ) -> Image.Image: + """Render-Pfad: tape_mm + content_type + data -> PIL Image. + + Raises UnsupportedTapeError wenn tape_mm nicht in TAPE_GEOMETRY. + Raises ContentTypeDataMismatchError wenn data Pflichtfelder fehlen. + """ + geometry = self._lookup_geometry(tape_mm) + self._validate_data(content_type, data) + + match content_type: + case ContentType.QR_ONLY: + return self._render_qr_only(geometry, data) + case ContentType.QR_ONE_LINE: + return self._render_qr_one_line(geometry, data) + case ContentType.QR_TWO_LINES: + return self._render_qr_two_lines(geometry, data) + case ContentType.QR_THREE_LINES: + return self._render_qr_three_lines(geometry, data) + case ContentType.TEXT_ONE_LINE: + return self._render_text_one_line(geometry, data) + case ContentType.TEXT_TWO_LINES: + return self._render_text_two_lines(geometry, data) + case ContentType.QR_WITH_LISTING: + return self._render_qr_with_listing(geometry, data) +``` + +Jede `_render_*`-Methode ist klein (<30 Zeilen), nutzt nur `geometry` + `data`, und gibt ein PIL-Image zurueck dessen Hoehe `geometry.printable_px` entspricht. Die Breite wird durch Inhalt und Whitespace-Trim bestimmt (analog Phase 1i LabelRenderer-Verhalten). + +Fuer `qr_three_lines`: rendert `primary_id` (XL, oben), `title` (L, mittig), und den ersten Eintrag von `secondary` (S, unten). Weitere `secondary`-Eintraege werden ignoriert — falls Use Cases mit 2+ secondary-Zeilen aufkommen, wird ein separater ContentType `qr_with_listing` oder eine zukuenftige `qr_four_lines`-Variante erstellt. + +### Errors + +| Error | HTTP | Zweck | +|-------|------|-------| +| `UnsupportedTapeError(tape_mm)` | **409** | tape_mm nicht in TAPE_GEOMETRY — hardware-/preflight-bezogener Konflikt (konsistent mit existierenden TapeEmpty/CoverOpen/TapeMismatch 409-Mapping in error_handlers.py) | +| `ContentTypeDataMismatchError(content_type, missing_fields)` | 422 | data fehlen Pflichtfelder fuer den ContentType (clientseitig korrigierbar -> Unprocessable Entity) | +| `NoTapeLoadedError()` | 409 | preflight.loaded_tape_mm == None (Tape physisch nicht eingelegt) | + +`TapeMismatchError` wird ersatzlos geloescht (nicht mehr im Render-Pfad geworfen). + +## 5. Phase 1k.1a — Hub Layout-Engine (Backend) + +### Neue Files + +``` +backend/app/schemas/ ++-- tape_geometry.py # TapeGeometry Pydantic-Model + TAPE_GEOMETRY dict ++-- content_type.py # ContentType Enum (7 Werte) +backend/app/services/ ++-- layout_engine.py # LayoutEngine.render() + 7 _render_*-Methoden +backend/tests/unit/services/ ++-- test_layout_engine.py # Unit-Tests pro ContentType x Tape-Groesse +``` + +### Modifizierte Files (vervollstaendigt nach ops-agent + Copilot Review) + +``` +backend/app/schemas/ +*-- label_data.py # + items: tuple[LabelDataItem, ...] = () und ggf. secondary-Validation ++-- label_data_item.py # NEU — LabelDataItem(item: str, qr_payload: str | None = None) +backend/app/services/ +*-- print_service.py # submit_job: render via LayoutEngine (statt LabelRenderer); TapeMismatchError-Pfad raus +*-- print_queue.py # _process_job: nutzt content_type statt template + # KRITISCH: _rerender_from_db Recovery-Pfad migrieren — + # statt TemplateLoader+LabelRenderer jetzt LayoutEngine.render() mit + # gespeicherten content_type + rendered_tape_mm + data Snapshot +*-- batch_dispatch.py # MixedTapeSizesError loeschen (in 1k.2 eingefuehrt, jetzt obsolet); + # Tape-Konsistenz-Check raus, alle Items rendern auf loaded_tape_mm +backend/app/services/ +*-- svg_renderer.py # SVG-Pfad analog LayoutEngine — render(tape_mm, content_type, data) -> SVG; + # falls SVG-Output noch genutzt wird (Preview-Endpoint, Tests) +backend/app/api/routes/ +*-- print.py # Request-Schema: content_type: ContentType, kein template_id mehr; + # on_tape_mismatch-Feld geloescht (Pfad obsolet, siehe unten) +*-- batch.py # items[].content_type statt items[].template_id; + # MixedTapeSizesError 400-Mapping geloescht +*-- jobs.py # POST /jobs/{job_id}/resume Route loeschen (PAUSED-Pfad obsolet); + # Job-Schema content_type + rendered_tape_mm zurueckgeben +backend/app/exceptions/ +*-- error_handlers.py # TapeMismatchError + MixedTapeSizesError Handler entfernen; + # UnsupportedTapeError + NoTapeLoadedError + ContentTypeDataMismatchError registrieren +backend/app/main.py # Router-Registrierungen: /api/templates Router entfernen; + # imports von TemplateLoader entfernen +backend/app/lifespan.py # Template-Seed-Load beim Startup entfernen; + # falls TemplateLoader.preload() aufgerufen wird, weg +``` + +### Geloeschte Files + +``` +backend/app/services/label_renderer.py # ersetzt durch LayoutEngine +backend/app/services/template_loader.py # Templates obsolet +backend/app/schemas/template.py # v1 Schema obsolet +backend/app/schemas/template_read.py # Read-Schema obsolet (kein API mehr) +backend/app/models/template.py # SQLAlchemy-Model obsolet +backend/app/repositories/templates.py # Repository obsolet (templates Tabelle dropped) +backend/app/api/routes/templates.py # /api/templates/* komplett weg +backend/app/api/routes/templates_preview.py # /api/templates/{key}/preview-* weg +backend/app/seed/templates/*.yaml # alle 21 YAML-Files +backend/tests/**/test_template* # alle Template-Tests +backend/tests/**/test_label_renderer* # alle alten Renderer-Tests +backend/tests/**/test_svg_renderer* # SVG-Renderer-Tests gegen v1 Schema +``` + +### Obsolete Konzepte (komplette Pfade entfernen) + +| Konzept | Bisheriger Code-Pfad | Was passiert | +|---------|---------------------|--------------| +| `TapeMismatchError` | `print_service.py:94`, `:235`, `error_handlers.py` | Klasse + Handler geloescht — Engine rendert immer auf `loaded_tape_mm` | +| `on_tape_mismatch=queue\|fail` PrintRequest-Feld | `routes/print.py`, `routes/batch.py` | Feld geloescht — alle Requests verhalten sich wie "auto-scale" | +| PAUSED-Job State | `print_queue.py`, `JobStateMachine` | State + Transitions geloescht — Jobs sind QUEUED/PRINTING/COMPLETED/FAILED/**CANCELLED** (CANCELLED-State BLEIBT erhalten — wird durch Cancel-Operation gesetzt, unabhaengig vom PAUSED-Pfad) | +| `POST /jobs/{job_id}/resume` Route | `routes/jobs.py:230` UND `routes/print.py` (separater Endpoint im on_tape_mismatch-PAUSED-Workflow) | Beide Routes geloescht — Resume war nur fuer PAUSED-Jobs noetig | +| `MixedTapeSizesError` | `batch_dispatch.py`, `routes/batch.py:60+` | Klasse + 400-Mapping geloescht — Batches mit gemischten ContentTypes rendern alle auf gleiche `loaded_tape_mm` | + +### Neue/geanderte Routes + +**Route-Prefix-Normalisierung:** Das aktuelle Backend ist inkonsistent — `print.py` hat `APIRouter()` ohne Prefix (Routes `/print/...`), waehrend `batch.py` (`prefix="/api"`) und `jobs.py` (`prefix="/api/jobs"`) den `/api`-Prefix verwenden. Phase 1k.1a normalisiert ALLE Print-/Job-/Render-Routes auf den durchgehenden `/api/`-Prefix als bewusstes Breaking Change. Bestehende Aufrufer (Hangar in 1k.1b, andere Clients) ziehen die Prefixes mit. + +| Route (neu, mit /api Prefix) | Methode | Aenderung | +|------|---------|-----------| +| `/api/print/{slug}` | POST | Vorher: `/print/{slug}` ohne Prefix. Request hat `content_type: ContentType`, `data: LabelData`, `options: PrintOptions` — `template_id` und `on_tape_mismatch` Felder geloescht | +| `/api/print/{slug}/batch` | POST | Bereits mit `/api`-Prefix. items[] mit `content_type`, kein `template_id`, kein `on_tape_mismatch` | +| `/api/render/preview` | **POST** | **POST mit JSON-Body** `{content_type, tape_mm, data, format: "png"\|"svg"}`. GET ist ungeeignet weil `qr_with_listing` mit `items: tuple[LabelDataItem,...]` URL-Length-Limits sprengt (proxy/browser caching/escaping-Issues) | +| `/api/templates/*` | alle | komplett geloescht (Route-File weg) | +| `/api/templates/{key}/preview-png` | GET | geloescht | +| `/api/templates/{key}/preview-svg` | GET | geloescht | +| `/api/jobs/{job_id}/resume` | POST | Bereits mit `/api/jobs`-Prefix. Geloescht (PAUSED-State obsolet) | + +Andere bestehende Routes wie `/printers/*`, `/lookup/*`, `/webhooks/*`, `/qr/*` werden in dieser Phase NICHT angefasst — Prefix-Normalisierung dort kann eine separate Folge-Phase werden. + +### DB-Migration (Alembic) + +**Korrektur aus ops-agent Review:** Die bestehende Spalte heisst `template_key` (nicht `template_id`). Das `drop_column("template_id")` Beispiel war falsch. + +**Korrektur aus Gemini Review:** SQLite unterstuetzt `drop_column` nicht direkt — `op.batch_alter_table` ist Pflicht. + +**Korrektur aus Copilot Review CP-9:** Statt `content_type=NULL` zu lassen, backfillen wir deterministisch aus dem strukturierten `template_key`. + +**Designentscheidung aus Copilot Review R3-4:** Die Spalte `jobs.template_key` BLEIBT als Audit-/Debug-Information erhalten. Sie ist im bestehenden Backend als "snapshot string — survives template deletion" dokumentiert, wird in `JobRead` API ausgegeben und enthaelt auch Nicht-Seed-Keys wie `spoolman/` oder `grocy/`. Nur die `templates` Tabelle wird gedroppt (keine Templates mehr). `template_key` wird ueber das Schema-Update nullable (neue Jobs ab 1k.1a haben `template_key=NULL`, aber `content_type` + `rendered_tape_mm` gesetzt). + +Neue Migration `XXXX_drop_templates_table_and_add_content_columns.py`: +```python +def upgrade() -> None: + # 1) Neue Spalten in jobs hinzufuegen — content_type + rendered_tape_mm + with op.batch_alter_table("jobs") as batch_op: + batch_op.add_column(sa.Column("content_type", sa.String(32), nullable=True)) + batch_op.add_column(sa.Column("rendered_tape_mm", sa.Integer, nullable=True)) + # 2) template_key: NOT NULL Constraint entfernen (neue Jobs setzen es nicht + # mehr, alte Jobs behalten ihren Wert als Audit-Trail) + batch_op.alter_column("template_key", nullable=True) + + # 3) Deterministisches Backfill basierend auf bekanntem Seed-Template-Schema. + # HINWEIS: template_key kann auch nicht-Seed-Werte enthalten (z.B. Webhook- + # Erzeugte Jobs wie "spoolman/" oder "grocy/"). Diese matchen keinen + # der CASE-Patterns und behalten content_type=NULL. Das Frontend zeigt + # template_key zusaetzlich an, sodass die historische Information sichtbar + # bleibt. + bind = op.get_bind() + bind.execute(sa.text(""" + UPDATE jobs SET + content_type = CASE + WHEN template_key LIKE 'qr-only-%' THEN 'qr_only' + WHEN template_key LIKE 'samla-%' THEN 'qr_two_lines' + WHEN template_key IN ('hangar-furniture-12mm', 'grocy-12mm', + 'snipeit-12mm', 'spoolman-12mm') + THEN 'qr_two_lines' + WHEN template_key IN ('hangar-furniture-18mm', 'hangar-furniture-24mm', + 'grocy-18mm', 'grocy-24mm', + 'snipeit-18mm', 'snipeit-24mm', + 'spoolman-18mm', 'spoolman-24mm') + THEN 'qr_three_lines' + ELSE NULL + END, + rendered_tape_mm = CASE + WHEN template_key LIKE '%-12mm' THEN 12 + WHEN template_key LIKE '%-18mm' THEN 18 + WHEN template_key LIKE '%-24mm' THEN 24 + WHEN template_key LIKE '%-62mm' THEN 62 + ELSE NULL + END + """)) + + # 4) templates Tabelle entfernen — nicht mehr genutzt nach Hard-Cut. + # template_key bleibt als Snapshot-Spalte in jobs erhalten. + op.drop_table("templates") + + +def downgrade() -> None: + op.create_table( + "templates", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("key", sa.String, unique=True), + # ... urspruengliche Spalten + ) + with op.batch_alter_table("jobs") as batch_op: + batch_op.alter_column("template_key", nullable=False) # zurueck zu NOT NULL + batch_op.drop_column("content_type") + batch_op.drop_column("rendered_tape_mm") +``` + +**JobRead API-Schema** (nach 1k.1a): +- `content_type: ContentType | None` — fuer neue Jobs gesetzt; fuer historische Jobs aus Backfill bestimmt; NULL nur fuer non-matching template_keys +- `rendered_tape_mm: int | None` — analog +- `template_key: str | None` — Audit-Snapshot, NULL fuer neue Jobs, gesetzt fuer historische Jobs + +Frontend zeigt fuer historische Jobs zusaetzlich den `template_key` als Hint (z.B. tooltip "Original template: spoolman/abc-123"), damit die Provenance erkennbar bleibt. + +### Pflicht-Smoke-Test nach Implementation + +12mm-Rendering muss visuell identisch zum Phase 1i V4-Winner Output sein. Konkreter Test: +- Vorher: `LabelRenderer().render(template="hangar-furniture-12mm", data={primary_id="K-02", title="Werkstatt", qr_payload="..."})` -> Image A +- Nachher: `LayoutEngine().render(tape_mm=12, content_type=QR_TWO_LINES, data=...)` -> Image B +- A == B per Pixel-Hash-Vergleich, oder A und B visuell nicht unterscheidbar (manuell ueber Browser-Diff) + +## 6. Phase 1k.1b — Hangar API-Migration + +### Aenderungen pro File + +| File | Aenderung | +|------|-----------| +| `internal/hub/layouts.go` | Struct `LayoutMapping`: `TemplateID string` -> `ContentType string`. YAML-Tag `template_id` -> `content_type`. | +| `internal/hub/client.go` | `PrintRequest` struct: `template_id` Feld entfernen, `content_type` hinzufuegen. JSON-Marshaling angepasst. | +| `internal/generator/print_p750w.go` | PrintRequest-Bau: `TemplateID: m.TemplateID` -> `ContentType: m.ContentType` | +| `cmd/hangar/main.go:768` | Preview-Proxy umgebaut: `/admin/print/preview/{template_id}` -> `POST /admin/print/preview` mit JSON-Body `{content_type, tape_mm, data, format}`. Forward auf Hub `POST /api/render/preview` (Body durchreichen). GET-Variante mit Query-String wuerde URL-Length-Limits sprengen bei `qr_with_listing`. | +| `internal/hub/example-layouts.yaml` | Komplett neu geschrieben (siehe unten); wird in Phase 1k.1c durch Go-Defaults abgeloest | +| `internal/templates/print_form.templ` | Template-Picker raus (er war eh nur einer pro Category) | +| `cmd/hangar/main_test.go` + `internal/generator/print_test.go` | Tests auf neue Felder umgestellt | + +### Neue example-layouts.yaml (Uebergangs-Loesung) + +User-Entscheidung: Samla-Boxen bekommen unabhaengig von der Anbringung (Stirn/Front/Deckel) das gleiche Label-Layout. **Eine Category "Samla"** statt drei separate. + +**Wichtig:** Die YAML-Datei ist eine Zwischen-Loesung fuer Phase 1k.1b. Phase 1k.1c migriert die Categories vollstaendig in die DB mit **in Go-Code definierten Defaults** (siehe Sektion 7). Nach Phase 1k.1c gibt es keinen YAML-Pfad mehr — `HUB_LAYOUTS_PATH` Environment-Variable wird deprecated und in einer Folge-Phase entfernt. + +```yaml +# Phase 1k.1b: ContentType statt template_id, eine Zeile pro Moebeltyp +# (vorher: 3 Zeilen wenn 12/18/24mm-Varianten existierten) + +printers: + brother-p750w: "11111111-1111-1111-1111-111111111111" + brother-ql820: "22222222-2222-2222-2222-222222222222" + +categories: + Kallax-Fach: + printer_slug: brother-p750w + content_type: qr_two_lines + quantity_default: 1 + Kallax-Regal: # NEU — Aggregations-Use-Case + printer_slug: brother-p750w + content_type: qr_with_listing + quantity_default: 1 + Alex-Schublade: + printer_slug: brother-p750w + content_type: qr_two_lines + Schreibtisch-Schublade: + printer_slug: brother-p750w + content_type: qr_two_lines + "Billy-Ebene AK": + printer_slug: brother-p750w + content_type: qr_two_lines + "Billy-Ebene VK": + printer_slug: brother-p750w + content_type: qr_two_lines + Samla: # UNIFIED: alle Samla-Varianten (Stirn/Front/Deckel) + printer_slug: brother-ql820 + content_type: qr_two_lines + quantity_default: 1 +``` + +### Smoke-Test + +`Kallax-Fach`-Print ueber neue Hangar-API muss visuell identisch zum pre-Migration-Print sein. Hardware-Validierung mit PT-P750W + 12mm-Tape. + +## 7. Phase 1k.1c — Hangar Categories DB + CRUD-Editor + +### DB-Schema (SQLite, GORM auto-migrate oder SQL-Migration) + +```sql +CREATE TABLE print_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, -- "Kallax-Fach" + content_type TEXT NOT NULL, -- "qr_two_lines" + printer_slug TEXT NOT NULL, -- "brother-p750w" + quantity_default INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); +CREATE INDEX idx_print_categories_sort ON print_categories(sort_order); +``` + +### Neuer Service + +``` +internal/category/ ++-- service.go # CategoryService: List, Get, Create, Update, Delete, Reorder ++-- model.go # Category struct (GORM) ++-- service_test.go # Unit-Tests +``` + +### Neue Routes + +``` +GET /admin/print/categories # Liste mit Live-Preview pro Eintrag +GET /admin/print/categories/new # Add-Form +POST /admin/print/categories # Create +GET /admin/print/categories/{id}/edit # Edit-Form mit Live-Preview +PUT /admin/print/categories/{id} # Update +DELETE /admin/print/categories/{id} # Delete +POST /admin/print/categories/reorder # Drag-and-Drop neu sortieren +POST /admin/print/categories/preview # HTMX-Endpoint mit JSON-Body, forwarded an Hub + # Body: {content_type, tape_mm, data, format} + # Consistent mit Hub-Endpoint (POST wegen URL-Length + # bei qr_with_listing items) +``` + +### Templates + +``` +internal/templates/ ++-- admin_print_categories_list.templ # Liste mit Mini-Preview pro Eintrag ++-- admin_print_categories_form.templ # Add/Edit-Form mit Live-Preview-Pane ++-- components/category_preview_pane.templ # HTMX-Target fuer Live-Preview-Refresh +``` + +### Live-Preview-Verhalten + +User waehlt im Add/Edit-Form: +1. `content_type` Dropdown (qr_only / qr_one_line / qr_two_lines / **qr_three_lines** / text_one_line / text_two_lines / qr_with_listing — alle 7 Werte) +2. Optionale Sample-Daten (primary_id, title, secondary, items) — defaults aus ContentType-Definition + +HTMX-Trigger: `change`-Event auf Form-Felder -> `POST /admin/print/categories/preview` mit JSON-Body -> Hub `POST /api/render/preview` -> SVG zurueck -> in Preview-Pane. + +Preview-Pane zeigt 3 SVGs side-by-side: 12mm, 18mm, 24mm (oder 62mm wenn QL-Drucker). Per Tab oder Stack-Layout je nach Viewport. + +### Initial-Seeding via Go-Defaults (kein YAML mehr) + +**User-Designentscheidung:** Hangar shipped mit eingebauten Default-Categories direkt im Go-Code. Die `HUB_LAYOUTS_PATH` Env-Variable und `hub-layouts.yaml` wird obsolet. + +```go +// internal/category/defaults.go +package category + +// DefaultCategories: Initial-Set fuer frische Installationen. +// Diese werden beim ersten Start in die DB geschrieben (wenn Tabelle leer ist). +// Nach erfolgreicher Initialisierung der Datenbank werden zuerst die Moebel-Typen +// initialisiert, danach diese Categories. +var DefaultCategories = []Category{ + {Name: "Kallax-Fach", PrinterSlug: "brother-p750w", ContentType: "qr_two_lines", QuantityDefault: 1, SortOrder: 10}, + {Name: "Kallax-Regal", PrinterSlug: "brother-p750w", ContentType: "qr_with_listing", QuantityDefault: 1, SortOrder: 20}, + {Name: "Alex-Schublade", PrinterSlug: "brother-p750w", ContentType: "qr_two_lines", QuantityDefault: 1, SortOrder: 30}, + {Name: "Schreibtisch-Schublade", PrinterSlug: "brother-p750w", ContentType: "qr_two_lines", QuantityDefault: 1, SortOrder: 40}, + {Name: "Billy-Ebene AK", PrinterSlug: "brother-p750w", ContentType: "qr_two_lines", QuantityDefault: 1, SortOrder: 50}, + {Name: "Billy-Ebene VK", PrinterSlug: "brother-p750w", ContentType: "qr_two_lines", QuantityDefault: 1, SortOrder: 60}, + {Name: "Samla", PrinterSlug: "brother-ql820", ContentType: "qr_two_lines", QuantityDefault: 1, SortOrder: 70}, +} +``` + +**Seed-Reihenfolge beim ersten Start:** +1. DB-Schema initialisieren (GORM auto-migrate) +2. Moebel-Typen (bestehend, aus `internal/catalog/`) initialisieren falls leer +3. **`DefaultCategories` in `print_categories` schreiben falls Tabelle leer** +4. Log: `"Seeded N default print categories"` + +Nach Initial-Seed: User aendert Categories ueber Admin-UI (1k.1c CRUD-Editor). Default-Set ist nur bei frischer DB relevant; bestehende DBs werden nicht ueberschrieben. + +**YAML-Konfig-Pfad (`HUB_LAYOUTS_PATH` / `example-layouts.yaml`):** +- Wird in Phase 1k.1c **als deprecated markiert** im Code (Log-Warning beim Start: "HUB_LAYOUTS_PATH is deprecated and ignored. Use Admin-UI to manage categories.") +- In einer Folge-Phase (kein Issue noetig, kleiner Cleanup): YAML-Lese-Code und Env-Variable komplett entfernen +- Test-Files unter `internal/hub/` die YAML laden: in 1k.1c geloescht oder auf Go-Defaults umgestellt + +### Tests + +| Test-Layer | Coverage | +|-----------|----------| +| Unit | CategoryService CRUD, Validation, sort_order-Reorder, Default-Seed-Idempotency | +| Integration | HTTP-Routes mit auth-required Middleware, CSRF | +| HTMX-Integration | Preview-Endpoint (POST mit JSON-Body) liefert valide SVG-Response | +| Initial-Seed | Bei leerer DB: nach DB-Init existieren genau N Categories aus DefaultCategories. Bei bestehender DB: keine Aenderung | + +## 8. Phase 1k.1d — Hangar Navigation-Refactor + +### Aktuelle Navigation (flach) + +``` +Katalog | Meine Items | Admin (Catalog) | Drucken | Benutzer | Status (debug) +``` + +### Neue Navigation (hierarchisch) + +``` +Katalog (top) +Meine Items (top, SSO) +Drucken (top, haeufige User-Aktion) +Administration ▾ (top, mit Untermenue) + |-- Katalog-Typen (vorher "Admin") + |-- Print-Categories (NEU aus 1k.1c) + |-- Benutzer + |-- Einstellungen (NEU — Hub-Layouts-Resync, Resolver-Config, SSO-Status) + '-- Debugging ▾ + |-- Status + '-- Stats +``` + +### Permission-Gating + +| Top-Level-Item | Sichtbar wenn | +|----------------|---------------| +| Katalog | immer | +| Meine Items | User.Source == "sso" | +| Drucken | CanAccessCatalogAdmin (unveraendert) | +| Administration | HasAnyAdminAccess(ctx) — neue Helper-Funktion | + +```go +// internal/auth/permissions.go +func HasAnyAdminAccess(ctx context.Context) bool { + return CanAccessCatalogAdmin(ctx) || + CanAccessUsersAdmin(ctx) || + CanAccessDebug(ctx) +} +``` + +Submenu-Items folgen ihren eigenen Permission-Checks (z.B. "Benutzer" nur sichtbar wenn `CanAccessUsersAdmin`). + +### UI-Implementation + +**Desktop (`md:` und groesser):** +- Click-Dropdown unter "Administration" — nutzt vanilla JS oder Alpine.js +- Active-State: aktueller Pfad wird im Submenu hervorgehoben (gelbes Border) + +**Mobile (Hamburger-Drawer):** +- Administration als `
` Element — nativer Browser-Toggle, kein JS noetig +- Submenu eingerueckt, kollabierbar + +### Files + +``` +internal/templates/ +*-- layout.templ # nav-section + mobile-drawer Restructure ++-- components/admin_dropdown.templ # NEU — wiederverwendbar +web/static/js/ ++-- admin-dropdown.js # Click-outside-handler, ESC-close +internal/auth/ +*-- permissions.go # + HasAnyAdminAccess(ctx) +``` + +### Tests + +Snapshot-Test der gerenderten layout.templ: +- mit User ohne Admin-Rolle: "Administration" Top-Level fehlt +- mit User mit nur CatalogAdmin: "Administration" sichtbar, nur "Katalog-Typen" + "Drucken" im Submenu +- mit Voll-Admin: alle Submenu-Items sichtbar + +## 9. Testing-Strategie (alle Phasen) + +| Phase | Test-Layer | Coverage-Ziel | +|-------|-----------|---------------| +| 1k.1a Hub | Unit (TapeGeometry x ContentType x Validation) + Integration (Print-Endpoint Roundtrip) | 90%+ | +| 1k.1b Hangar API | Unit (Layouts-Parsing, Client-Request-Bau) + Integration (Hangar -> Mock-Hub) | 85%+ | +| 1k.1c Hangar Categories | Unit (Service-CRUD) + Integration (HTTP-Routes mit Auth) + HTMX (Preview-Endpoint) | 85%+ | +| 1k.1d Navigation | Snapshot (rendered HTML) + Permission-Tests | n/a (templ-Snapshots) | + +### Hardware-Smoke nach allen 4 Phasen + +| Test | Drucker | Tape | +|------|---------|------| +| 12mm V4-Baseline (Regression) | PT-P750W | 12mm TZe | +| 24mm Smoke (neu validiert) | PT-P750W | 24mm TZe | +| 62mm Endlos | QL-820NWB | 62mm DK | +| `qr_with_listing` mit 4 Items | PT-P750W | 24mm TZe | + +## 10. Definition of Done (pro Sub-Phase) + +### Phase 1k.1a + +- [ ] `TapeGeometry` (Pydantic mit `Field(gt=0)/Field(ge=0)` Constraints) + `TAPE_GEOMETRY: dict[int, TapeGeometry]` fuer 7 Tape-Groessen (4/6/9/12/18/24/62mm) +- [ ] `ContentType` Enum mit 7 Werten (qr_only, qr_one_line, qr_two_lines, qr_three_lines, text_one_line, text_two_lines, qr_with_listing) +- [ ] `LayoutEngine.render(tape_mm: int, content_type, data)` implementiert alle 7 ContentTypes +- [ ] `LabelData` Schema-Anpassung: `title`, `primary_id`, `qr_payload` werden auf optional gesetzt (`str | None = None`); ContentType-spezifische Pflichtfeld-Validation zentral in `LayoutEngine._validate_data()`; nur `source_app` bleibt zwingend gesetzt +- [ ] `LabelData.items` Erweiterung + `LabelDataItem`-Klasse +- [ ] Routes `/api/print/*` umgebaut auf `content_type`; `template_id` und `on_tape_mismatch` Felder entfernt +- [ ] `/api/render/preview` umgebaut auf **POST mit JSON-Body** `{content_type, tape_mm, data, format}` +- [ ] Routes `/api/templates/*` komplett entfernt +- [ ] `POST /api/jobs/{job_id}/resume` entfernt (PAUSED-State obsolet) +- [ ] Alle 21 YAML-Templates geloescht +- [ ] `LabelRenderer`, `TemplateLoader`, `template.py` Schema geloescht +- [ ] **`print_queue.py._rerender_from_db`** Recovery-Pfad migriert: nutzt jetzt `LayoutEngine.render(rendered_tape_mm, content_type, data)` statt TemplateLoader+LabelRenderer (KRITISCH — sonst sind alle bestehenden Recovery-Operationen broken) +- [ ] `batch_dispatch.py` `MixedTapeSizesError` + Tape-Konsistenz-Check entfernt +- [ ] `error_handlers.py` alte Errors (TapeMismatchError, MixedTapeSizesError) entfernt; neue (UnsupportedTapeError, NoTapeLoadedError, ContentTypeDataMismatchError) registriert +- [ ] `main.py` Router-Registrierungen + Imports aufgeraeumt (kein TemplateLoader mehr) +- [ ] `lifespan.py` Template-Preload entfernt +- [ ] `svg_renderer.py` analog migriert oder geloescht (je nach SVG-Use) +- [ ] Alembic-Migration: `templates` Tabelle drop + `jobs.content_type` + `jobs.rendered_tape_mm` add via `op.batch_alter_table` (SQLite-kompatibel) mit deterministischem Backfill aus `template_key`. `jobs.template_key` BLEIBT als nullable Audit-Spalte erhalten (snapshot survives template deletion, sichtbar in JobRead API) +- [ ] Tests gruen, Coverage >=90% auf neuen Modulen +- [ ] Smoke-Test: 12mm V4-Baseline visuell identisch +- [ ] Refs #103, Closes #81 (7e-Spec subsumiert) + +### Phase 1k.1b + +- [ ] `LayoutMapping` struct: `TemplateID` -> `ContentType` +- [ ] `PrintRequest` struct: `template_id` -> `content_type` +- [ ] `example-layouts.yaml` als Uebergangs-Loesung umgeschrieben (7 ContentTypes inkl. qr_three_lines + `Kallax-Regal` + unified "Samla") — wird in 1k.1c durch Go-Defaults abgeloest +- [ ] Preview-Proxy-Route umgebaut: `POST /admin/print/preview` mit JSON-Body, forward auf Hub `POST /api/render/preview` +- [ ] `print_form.templ` Template-Picker entfernt +- [ ] Tests gruen +- [ ] Smoke: Kallax-Fach-Print identisch zu pre-Migration +- [ ] Refs #103 + +### Phase 1k.1c + +- [ ] DB-Schema `print_categories` + GORM-Model +- [ ] `CategoryService` mit CRUD-API +- [ ] Routes `/admin/print/categories/*` mit Auth-Middleware +- [ ] Templates: List, Add, Edit, Preview-Pane (Dropdown enthaelt alle **7 ContentTypes inkl. qr_three_lines**) +- [ ] HTMX Live-Preview-Endpoint **POST mit JSON-Body** mit Hub-Forwarding (statt GET, konsistent mit Hub-Endpoint) +- [ ] **`internal/category/defaults.go`** mit `DefaultCategories` Slice (Go-Code, KEIN YAML) +- [ ] Initial-Seed-Logik beim ersten Start: nach DB-Init und Moebel-Typen-Seed → schreibt `DefaultCategories` in `print_categories` wenn Tabelle leer +- [ ] **`HUB_LAYOUTS_PATH` Env-Variable + YAML-Lese-Code deprecated** mit Warning-Log; YAML-File wird ignoriert +- [ ] `internal/hub/example-layouts.yaml` und zugehoeriger YAML-Loader-Code entweder geloescht oder als deprecated markiert +- [ ] Tests: Service-Unit + HTTP-Integration + HTMX-Integration + Initial-Seed-Idempotency +- [ ] Smoke: neue Category anlegen, Preview sehen, Test-Print starten +- [ ] Refs #103 + +### Phase 1k.1d + +- [ ] `HasAnyAdminAccess` Helper in `internal/auth/permissions.go` +- [ ] `layout.templ` Nav umgebaut auf hierarchische Struktur +- [ ] `admin_dropdown.templ` Komponente +- [ ] `admin-dropdown.js` Click-outside + ESC-close +- [ ] Mobile-Drawer: `
`-basierte Admin-Section +- [ ] Snapshot-Tests fuer Permission-Varianten +- [ ] Smoke: manuelles Navigieren Mobile + Desktop +- [ ] Refs #103 + +## 11. Out-of-Scope + +- **Multi-Printer-Model Layouts** (PT-180-DPI vs QL-300-DPI) — TapeGeometry abstrahiert das ueber tape_mm-Aufloesung; pro-Modell-Varianten sind ein zukuenftiger Constants-Table-Erweiterung +- **Image-Elemente** (Logos, Photos) — Engine rendert Text + QR, Image-Elemente bleiben deferred +- **WYSIWYG-Editor fuer ContentType-Layouts** — Phase 1k.3 (Issue #104) +- **Per-User-Custom-ContentTypes** — Phase 1k.3 +- **36mm-Tape-Support** — Constants-Table-Erweiterung sobald Hardware +- **TapeMismatchError-Detection** im Hangar-Frontend (z.B. Warnung vor Print) — Phase 1k.1c koennte dies optional aufnehmen wenn Hardware-Drucker fehlende Tape-Erkennung melden + +## 12. Self-Review + +**Privacy-Check:** Spec nutzt RFC 5737-Placeholder (example.com), keine echten IPs/Hostnames, keine echten Namen. + +**Hard-Cut-Rationale:** User bestaetigt: System in Entwicklung, keine Legacy-Kompatibilitaet noetig. Saubere Loeschung aller alten Templates und Renderer ist einfacher als Compat-Layer. + +**Scope-Aufteilung:** 4 Sub-Phasen mit klaren Abhaengigkeiten (a -> b -> c, d nach c). Jede Phase einzeln deploy-bar und reversibel. + +**ContentType-Auswahl:** 7 Types decken alle bisherigen 21 Templates ab plus Kallax-Aggregation aus 7e plus 3-Zeilen-Layouts (qr_three_lines fuer grocy/snipeit/spoolman 18/24mm + hangar-furniture 18/24mm). Validation-Regeln sind explizit, source_app als bestehendes Pflichtfeld erwaehnt. + +**Samla-Unifikation:** Auf User-Wunsch werden alle 6 Samla-Templates (Stirntag/Deckel x 12/24/62mm) auf eine einzige Hangar-Category "Samla" mit `content_type: qr_two_lines` reduziert. + +**Tape-Independence:** Eliminiert TapeMismatchError aus Print-Pfad. Edge-Case "unsupported tape_mm" wird durch defensiv eingebaute Errors gefangen (sollte mit 7 Groessen praktisch nicht auftreten). + +**Migration:** DB-Migrationen nutzen `op.batch_alter_table` fuer SQLite-Kompatibilitaet. `jobs.content_type` wird beim Migrations-Run deterministisch aus `template_key` backfilled (kein Daten-Verlust fuer Historie). Hangar-YAML wird zur Seed-Quelle. + +**Konsistenz mit Backend-Typen:** `tape_mm` als `int` durchgehend — kein `float`. Stimmt mit `TapeSpec.width_mm` und SNMP-Parsing ueberein. + +**Out-of-Scope-Liste:** alle bekannten Versuchungen explizit ausgeschlossen (Multi-Printer-Model, WYSIWYG, Custom-Types, etc.). + +--- + +### Review-Round 1 (PR #108 Findings adressiert) + +Diese Spec wurde nach der ersten Review-Runde durch ops-agent, Gemini Code Assist und GitHub Copilot ueberarbeitet. Adressierte Findings: + +**CRITICAL (8/8 adressiert):** +- C1 (ops-agent) — Hard-Cut-Liste in Sektion 5 vervollstaendigt: svg_renderer, templates_preview, batch_dispatch, main, lifespan, error_handlers + KRITISCH print_queue._rerender_from_db Recovery-Pfad explizit benannt +- C2 (ops-agent) — Alembic: `template_id` -> `template_key` korrigiert +- C3 (ops-agent) — Obsolete Konzepte Tabelle in Sektion 5 ergaenzt: TapeMismatchError, on_tape_mismatch, PAUSED-State, /jobs/{id}/resume, MixedTapeSizesError +- C4 (Copilot) — `tape_mm: int` durchgehend, `dict[int, TapeGeometry]`, 4mm statt 3.5mm (PT-TZe-Minimum) +- C5 (Copilot) — `qr_three_lines` als 7. ContentType fuer grocy/snipeit/spoolman 18/24mm + hangar-furniture 18/24mm +- C6 (Copilot) — `samla-stirntag-*` Mapping in Sektion 2 korrigiert zu `qr_two_lines` (haben QR); zusaetzlich User-Wunsch: Samla unified +- C7 (Copilot) — 62mm Werte korrigiert: `qr_max_px=688` (war 672) mit `qr_padding_px=4` +- C8 (Copilot) — Phase-1i-Smoke-Empirie-Pfad bleibt im Issue-Kommentar #103 erreichbar; im Spec wird darauf statt auf einen nicht-OS-Pfad verwiesen + +**MEDIUM (7/7 adressiert):** +- M1 (Gemini) — Pydantic `Field(gt=0)/Field(ge=0)` Constraints in TapeGeometry +- M2 (Gemini) — `op.batch_alter_table` fuer SQLite-Kompatibilitaet +- M3 (Copilot) — `qr_max_px` Formel als `printable_px - 2 * qr_padding_px` im Sektion-3-Header beschrieben +- M4 (Copilot + ops-agent) — Preview-Endpoint von GET auf POST mit JSON-Body +- M5 (Copilot) — Deterministisches Backfill aus `template_key` in Migration +- M6 (ops-agent) — `text_start_x` als absolute X-Position erklaert +- M7 (ops-agent) — `source_app` Pflichtfeld in LabelData explizit in Validation-Regeln erwaehnt + +LOW-Findings (3) und PRAISE (5) sind im PR-Kommentar archiviert. + +### Review-Round 2 (PR #108 Findings adressiert) + User-Designentscheidung + +Nach Round-1-Push hat Copilot eine zweite Review (commit 2545467) durchgefuehrt und 7 weitere Inkonsistenzen gefunden. Zusaetzlich kam eine User-Designentscheidung zum Initial-Seeding hinzu. + +**Round-2 CRITICAL/MEDIUM (7/7 adressiert):** + +- R2-1 (Copilot) — Sektion 5 File-Liste-Kommentare: "ContentType Enum (6 Werte)" + "6 _render_* Methoden" -> "7 Werte" / "7 _render_* Methoden" +- R2-2 (Copilot) — Sektion 7 1k.1c Dropdown-Liste: `qr_three_lines` als 4. Eintrag ergaenzt (war ausgelassen) +- R2-3 (Copilot) — Sektion 3 `text_start_x` Formel korrekt: Gap zur Text-Spalte ist `2 * qr_padding_px` (symmetrisches Padding um QR); Beispiel: 12mm = `qr_padding_px(2) + qr_max_px(66) + 2*qr_padding_px(4) = 72`; 62mm korrigiert auf `printable_px(696) + qr_padding_px(4) = 700` (war 696 — die Formel-Anwendung fehlte) +- R2-4 (Copilot) — Sektion 6 1k.1b Preview-Proxy: `cmd/hangar/main.go:768` jetzt `POST /admin/print/preview` mit JSON-Body statt Query-String, konsistent zur Hub-API +- R2-5 (Copilot) — Sektion 7 1k.1c Categories-Preview-Route: `POST /admin/print/categories/preview` mit JSON-Body +- R2-6 (Copilot) — Sektion 5 Alembic-Migration: `template_key` ist im aktuellen Schema NOT NULL und kann Nicht-Seed-Werte enthalten (z.B. Webhook-Erzeugte Jobs `spoolman/`, `grocy/`). Migration-Hinweis explizit ergaenzt — solche Eintraege bekommen `content_type=NULL` und werden im Frontend als "(legacy)" markiert +- R2-7 (Copilot) — Sektion 5 Obsolete-Konzepte-Tabelle: `POST /jobs/{job_id}/resume` existiert auch separat in `routes/print.py` (on_tape_mismatch=queue / PAUSED-Workflow) — beide Routes geloescht + +**User-Designentscheidung (Round 2):** + +Initial-Seeding der Hangar-Categories erfolgt ueber **Go-Code Defaults** (`DefaultCategories` Slice in `internal/category/defaults.go`), NICHT mehr ueber YAML. Reihenfolge beim ersten Start: DB-Init -> Moebel-Typen-Seed -> `DefaultCategories`-Seed. `HUB_LAYOUTS_PATH` und `hub-layouts.yaml` werden in 1k.1c deprecated und ignoriert. Ziel: YAML-freie Konfiguration, Categories sind ausschliesslich ueber Admin-UI editierbar. + +Sektion 6 (1k.1b) markiert die YAML-Datei explizit als Uebergangs-Loesung. Sektion 7 (1k.1c) definiert `DefaultCategories` als Source-of-Truth fuer den frischen Start. Out-of-Scope-Sektion ergaenzt: vollstaendiges Entfernen des YAML-Lese-Codes ist in einer Folge-Phase nach 1k.1c (kein eigenes Issue noetig). + +### Review-Round 3 (PR #108 Findings adressiert) + +Nach Round-2-Push hat Copilot eine dritte Review (commit 9c877cb) durchgefuehrt und 4 weitere Findings gemeldet — davon 1 Designentscheidung (template_key behalten) und 3 Klarstellungen. + +**Round-3 (4/4 adressiert):** + +- R3-1 (Copilot) — PR-Beschreibung war veraltet (6 ContentTypes / 3.5mm). PR-Body wird parallel aktualisiert; in der Spec selbst hat sich nichts geaendert (Source of Truth war immer Spec, nicht PR-Body) +- R3-2 (Copilot) — Executive Summary explizit "4mm (nicht 3.5mm)" geschrieben um die historische Verwechslung zu adressieren +- R3-3 (Copilot) — Mapping-Tabelle in Sektion 2: `hangar-furniture-*-12mm` Wildcard war irrefuehrend (keine Wildcards im Repo). Aufgeloest in konkrete Eintraege `hangar-furniture-12mm` etc. +- R3-4 (Copilot, DESIGN-ENTSCHEIDUNG) — `jobs.template_key` Spalte wird NICHT gedroppt. Sie bleibt als nullable Audit-/Debug-Snapshot erhalten (Webhook-Keys wie `spoolman/` waeren sonst nicht mehr rekonstruierbar; "survives template deletion" Eigenschaft bleibt erhalten). Migration NOT NULL -> nullable; neue Jobs setzen template_key=NULL aber content_type+rendered_tape_mm. Frontend zeigt template_key zusaetzlich als Provenance-Hint fuer historische Jobs + +### Review-Round 4 (PR #108 Findings adressiert) + +Nach Round-3-Push hat Copilot eine vierte Review (commit b10552a) durchgefuehrt und 4 weitere Klarstellungen gemeldet. + +**Round-4 (4/4 adressiert):** + +- R4-1 (Copilot) — Validation-Regeln Pre-Condition: bestehendes `LabelData` hat `title/primary_id/qr_payload` als required. Spec ergaenzt um expliziten Schema-Anpassungs-Schritt: Felder werden auf `str | None = None` umgestellt, ContentType-Validation passiert zentral in `LayoutEngine._validate_data()`. Pflichtfeld-Matrix als Tabelle in Sektion 2 ergaenzt +- R4-2 (Copilot) — Extrapolations-Methodologie pro Feld erklaert: Pixel-Ratio + Clamping fuer Fonts, konstantes qr_padding_px bei 2 (62mm: 4), deterministische Formeln fuer text_start_x und qr_max_px. Damit ist die Tabelle nachvollziehbar pflegbar +- R4-3 (Copilot) — Executive Summary explizit: "initiale Scope" von 7 Groessen; bestehende `TapeRegistry` kennt weitere QL-DK-Breiten (29/38/50/54mm) die in 1k.1 bewusst noch nicht abgedeckt sind -> `UnsupportedTapeError`; Erweiterung in Folge-Phase moeglich +- R4-4 (Copilot) — Import-Konvention angepasst: `from PIL.Image import Image` -> `from PIL import Image`, Return-Type `Image.Image` (konsistent mit Repo-Konvention) + +### Review-Round 5 (PR #108 Findings adressiert) + +Nach Round-4-Push hat Copilot eine fuenfte Review (commit c37d494) durchgefuehrt und 4 weitere Klarstellungen gemeldet — alle finishing touches. + +**Round-5 (4/4 adressiert):** + +- R5-1 (Copilot) — Geloeschte-Files-Liste vervollstaendigt: `backend/app/models/template.py` (SQLAlchemy-Model), `backend/app/repositories/templates.py` (Repository), `backend/app/schemas/template_read.py` (Read-Schema) wurden ergaenzt +- R5-2 (Copilot) — Obsolete-Konzepte-Tabelle: PAUSED-State-Entfernung impliziert NICHT CANCELLED-State-Entfernung. Klarstellung in der Tabelle: Jobs nach 1k.1a sind QUEUED/PRINTING/COMPLETED/FAILED/**CANCELLED** — der CANCELLED-State bleibt erhalten (durch Cancel-Operation gesetzt, unabhaengig vom PAUSED-Pfad) +- R5-3 (Copilot) — PR-Body weiterhin "6 ContentTypes" / "3.5mm" — Update aus Round 3 ist nicht persistiert (GraphQL-Warnung). Body wird via gh API erneut gesetzt +- R5-4 (Copilot) — Hardware-Baseline-Referenz im Spec-Header zeigte auf nicht existierenden Pfad `docs/site/operations/protokolle/...`. Korrigiert: Verweis auf Issue #103 Kommentar (oeffentlich verfuegbar) + Hinweis dass Original-Protokoll im privaten `homelab-management` Repo liegt + +### Review-Round 6 (PR #108 Findings adressiert) + +Nach Round-5-Push hat Copilot eine sechste Review (commit 6a1b88d) durchgefuehrt und 3 weitere finishing-touch Findings gemeldet. + +**Round-6 (3/3 adressiert):** + +- R6-1 (Copilot) — Spec-Header Status war "Draft (zur User-Review)" obwohl der Review-Verlauf "ship-ready" signalisiert. Status auf "Approved — Ready for writing-plans (Phase 1k.1a)" gesetzt +- R6-2 (Copilot) — `UnsupportedTapeError` von HTTP **422 -> 409** geaendert. 409 (Conflict) passt zum bestehenden Error-Handler-Mapping fuer hardware-/preflight-bezogene Konflikte (TapeEmpty, CoverOpen, TapeMismatch sind ebenfalls 409). 422 bleibt fuer clientseitig korrigierbare Validation-Fehler (`ContentTypeDataMismatchError`) +- R6-3 (Copilot) — Route-Prefix-Inkonsistenz im bestehenden Backend dokumentiert: `print.py` ohne Prefix vs `batch.py`/`jobs.py` mit `/api/`. Spec ergaenzt: Phase 1k.1a normalisiert ALLE Print-/Job-/Render-Routes auf `/api/` als bewusstes Breaking Change. Andere Routes (`/printers`, `/lookup`, `/webhooks`, `/qr`) bleiben in dieser Phase unveraendert. + +### Review-Round 7 (PR #108 — Konvergenz erreicht) + +Round 7 lieferte 3 minimale Findings — der Trend bestaetigt klare Konvergenz (R1: 18 -> R2: 7 -> R3: 4 -> R4: 4 -> R5: 4 -> R6: 3 -> R7: 3 minor). + +**Round-7 (2/3 adressiert, 1 Halluzination):** + +- R7-1 (Copilot, IGNORIERT) — Behauptet doppelte fuehrende Pipes `||` in Markdown-Tabellen die Spalten verschieben. Verifiziert: keine `||` im File, alle Tabellen nutzen einfache `|`. Halluzination des Reviewers, kein Code-Aenderungsbedarf +- R7-2 (Copilot) — `text_start_x` Erklaerung umstrukturiert in 3 klare Komponenten (linker QR-Inset / QR-Code / Trenn-Gap) mit Formel-Ableitung. Beseitigt Mehrdeutigkeit zwischen "symmetrisches Padding" und "Gap von 2*qr_padding_px" +- R7-3 (Copilot) — Typo `ursprueengliche` -> `urspruengliche` in Sektion 5 Migration-downgrade-Beispiel