mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
Closes the longest-standing inventory gap — finding a specific spool
in a closet of 50 partials. Per-spool icon button on every inventory
card and table row, plus a "Print labels..." header action that opens
a multi-select picker pre-loaded with the currently filtered spools.
Four pre-built templates: AMS holder (30 x 15 mm) for the popular
Makerworld AMS Filament Label Holder, single box label (62 x 29 mm)
for Brother PT/QL or Dymo small labels, Avery L7160 (A4, 21 per
sheet), and Avery 5160 (US Letter, 30 per sheet). Each label carries
the colour swatch (with multi-colour gradient stripes for spools
with extra_colors set), brand, material, name, the *spool ID*
(bsaunder's articulated user-need: telling 8 spools of "PLA White"
apart, especially partials), and a QR code that deep-links to
/inventory?spool=<id> for phone-scan round-trips. Box-label adds
storage location; AMS-holder drops the QR — at 30 x 15 mm there is
no room for swatch + text + QR without truncating away the spool ID,
and AMS-bay identification is at arm's length where the swatch and
ID are enough.
Server-side rendering via ReportLab + qrcode (already a dep). Pure
Python, no headless browser, no system libs. Output is byte-identical
across browsers, Avery sheets align to <0.1 mm, and bulk export is
one click for one PDF. Two endpoints — POST /inventory/labels (local
DB) and POST /spoolman/labels (Spoolman-backed) — gated on
INVENTORY_READ, capped at 500 spools per request, returning
application/pdf via StreamingResponse. The renderer is decoupled
from the SQLAlchemy model via a LabelData dataclass so the same code
path serves both modes.
Modal picker scales to large libraries: search (substring match
across name / brand / #ID), material filter chips derived from the
visible spools, additive Select-all-visible / Deselect-visible /
Clear-all actions so selections survive filter changes. Restyled
twice in development — first cut used generic Tailwind which clashed
with the inventory's bambu-dark palette; second cut switched to
bambu-dark-secondary / bambu-green / bambu-gray to match.
Two render bugs found during visual inspection of generated PDFs and
fixed before commit:
1. AMS-30x15 template originally produced labels with only swatch
+ QR and no text at all — the side-by-side layout left <5 mm
for the text column, so the renderer bailed without drawing
anything. Layout split into tight (h<20mm) and roomy (h>=20mm)
regimes; tight regime drops the QR and gives the right column
to brand + material + a 13pt-bold spool ID.
2. Box-62x29 template aggressively truncated text — swatch + QR
each at ~14 mm on a 26mm-tall label squeezed the text column
to ~16 mm, turning "Polymaker Ivory" into "Polymak..." and
"Polymaker . PLA . Matte" into "Polymaker ...". Swatch capped
at 16 mm, QR capped at 18 mm and constrained to ~20% of width,
leaving the text column ~30 mm — full names render without
truncation.
Both bugs pinned by regression tests in test_label_renderer.py that
render with pageCompression=0 so the resulting PDF bytes contain the
text as ASCII and `assert b"Polymaker" in pdf` works.
249 lines
9.2 KiB
Python
249 lines
9.2 KiB
Python
"""Integration tests for the spool-label routes (#809).
|
|
|
|
Covers both ``POST /inventory/labels`` (local DB) and ``POST /spoolman/labels``
|
|
(Spoolman-backed). The renderer itself has its own unit tests; these tests
|
|
focus on auth, request validation, mode gating, and the wiring between route
|
|
and renderer.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.models.spool import Spool
|
|
|
|
|
|
@pytest.fixture
|
|
async def spool_factory(db_session: AsyncSession):
|
|
"""Factory to create test spools."""
|
|
_counter = [0]
|
|
|
|
async def _create_spool(**kwargs):
|
|
_counter[0] += 1
|
|
defaults = {
|
|
"material": "PLA",
|
|
"subtype": "Basic",
|
|
"brand": "Polymaker",
|
|
"color_name": f"Test {_counter[0]}",
|
|
"rgba": "FF8800FF",
|
|
"label_weight": 1000,
|
|
"weight_used": 0,
|
|
}
|
|
defaults.update(kwargs)
|
|
spool = Spool(**defaults)
|
|
db_session.add(spool)
|
|
await db_session.commit()
|
|
await db_session.refresh(spool)
|
|
return spool
|
|
|
|
return _create_spool
|
|
|
|
|
|
# ── /inventory/labels (local DB) ─────────────────────────────────────────────
|
|
|
|
|
|
class TestLocalInventoryLabels:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_renders_pdf_for_local_spools(self, async_client: AsyncClient, spool_factory):
|
|
s1 = await spool_factory()
|
|
s2 = await spool_factory(material="PETG", brand="Sunlu")
|
|
|
|
resp = await async_client.post(
|
|
"/api/v1/inventory/labels",
|
|
json={"spool_ids": [s1.id, s2.id], "template": "box_62x29"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.headers["content-type"] == "application/pdf"
|
|
assert resp.content.startswith(b"%PDF")
|
|
assert int(resp.headers["content-length"]) == len(resp.content)
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_all_four_templates_succeed(self, async_client: AsyncClient, spool_factory):
|
|
s = await spool_factory()
|
|
for template in ("ams_30x15", "box_62x29", "avery_5160", "avery_l7160"):
|
|
resp = await async_client.post(
|
|
"/api/v1/inventory/labels",
|
|
json={"spool_ids": [s.id], "template": template},
|
|
)
|
|
assert resp.status_code == 200, f"{template} returned {resp.status_code}: {resp.text}"
|
|
assert resp.content.startswith(b"%PDF")
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_unknown_template_rejected(self, async_client: AsyncClient, spool_factory):
|
|
s = await spool_factory()
|
|
resp = await async_client.post(
|
|
"/api/v1/inventory/labels",
|
|
json={"spool_ids": [s.id], "template": "totally_made_up"},
|
|
)
|
|
# Pydantic Literal validation → 422
|
|
assert resp.status_code in (400, 422)
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_empty_spool_ids_rejected(self, async_client: AsyncClient):
|
|
resp = await async_client.post(
|
|
"/api/v1/inventory/labels",
|
|
json={"spool_ids": [], "template": "box_62x29"},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_unknown_spool_id_returns_404(self, async_client: AsyncClient, spool_factory):
|
|
s = await spool_factory()
|
|
resp = await async_client.post(
|
|
"/api/v1/inventory/labels",
|
|
json={"spool_ids": [s.id, 99999], "template": "ams_30x15"},
|
|
)
|
|
assert resp.status_code == 404
|
|
assert "99999" in resp.text
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_preserves_request_order(self, async_client: AsyncClient, spool_factory):
|
|
"""Caller's `spool_ids` order should match the on-screen list — important
|
|
for Avery sheet layouts where users curate the layout via filtering."""
|
|
s1 = await spool_factory()
|
|
s2 = await spool_factory()
|
|
s3 = await spool_factory()
|
|
|
|
# Reverse order; assert the route doesn't sort them. We can't peek
|
|
# inside the PDF for assertion, but we can call render_labels directly
|
|
# under the same patches and compare bytes deterministically.
|
|
from backend.app.api.routes import labels as labels_module
|
|
|
|
captured = {}
|
|
|
|
original = labels_module.render_labels
|
|
|
|
def _capture(template, data_list):
|
|
captured["ids"] = [d.spool_id for d in data_list]
|
|
return original(template, data_list)
|
|
|
|
with patch.object(labels_module, "render_labels", side_effect=_capture):
|
|
resp = await async_client.post(
|
|
"/api/v1/inventory/labels",
|
|
json={"spool_ids": [s3.id, s1.id, s2.id], "template": "avery_l7160"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert captured["ids"] == [s3.id, s1.id, s2.id]
|
|
|
|
|
|
# ── /spoolman/labels (Spoolman-backed) ───────────────────────────────────────
|
|
|
|
|
|
class TestSpoolmanLabels:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_returns_400_when_spoolman_disabled(self, async_client: AsyncClient):
|
|
# Default state in tests: spoolman_enabled is unset / "false"
|
|
resp = await async_client.post(
|
|
"/api/v1/spoolman/labels",
|
|
json={"spool_ids": [1], "template": "box_62x29"},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "Spoolman" in resp.text
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_returns_503_when_spoolman_unreachable(self, async_client: AsyncClient, db_session: AsyncSession):
|
|
from backend.app.models.settings import Settings
|
|
|
|
db_session.add(Settings(key="spoolman_enabled", value="true"))
|
|
await db_session.commit()
|
|
|
|
with patch("backend.app.api.routes.labels.get_spoolman_client", AsyncMock(return_value=None)):
|
|
resp = await async_client.post(
|
|
"/api/v1/spoolman/labels",
|
|
json={"spool_ids": [1], "template": "box_62x29"},
|
|
)
|
|
assert resp.status_code == 503
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_renders_pdf_from_spoolman_data(self, async_client: AsyncClient, db_session: AsyncSession):
|
|
from backend.app.models.settings import Settings
|
|
|
|
db_session.add(Settings(key="spoolman_enabled", value="true"))
|
|
await db_session.commit()
|
|
|
|
spoolman_spool = {
|
|
"id": 42,
|
|
"filament": {
|
|
"name": "PolyTerra Sapphire Blue",
|
|
"material": "PLA",
|
|
"color_hex": "0033AA",
|
|
"vendor": {"name": "Polymaker"},
|
|
},
|
|
"location": "Shelf 5, slot C",
|
|
}
|
|
mock_client = MagicMock()
|
|
mock_client.is_connected = True
|
|
mock_client.get_spools = AsyncMock(return_value=[spoolman_spool])
|
|
|
|
with patch(
|
|
"backend.app.api.routes.labels.get_spoolman_client",
|
|
AsyncMock(return_value=mock_client),
|
|
):
|
|
resp = await async_client.post(
|
|
"/api/v1/spoolman/labels",
|
|
json={"spool_ids": [42], "template": "avery_l7160"},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.headers["content-type"] == "application/pdf"
|
|
assert resp.content.startswith(b"%PDF")
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_returns_404_when_spool_missing_from_spoolman(
|
|
self, async_client: AsyncClient, db_session: AsyncSession
|
|
):
|
|
from backend.app.models.settings import Settings
|
|
|
|
db_session.add(Settings(key="spoolman_enabled", value="true"))
|
|
await db_session.commit()
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.is_connected = True
|
|
mock_client.get_spools = AsyncMock(return_value=[{"id": 1, "filament": {"name": "X", "material": "PLA"}}])
|
|
|
|
with patch(
|
|
"backend.app.api.routes.labels.get_spoolman_client",
|
|
AsyncMock(return_value=mock_client),
|
|
):
|
|
resp = await async_client.post(
|
|
"/api/v1/spoolman/labels",
|
|
json={"spool_ids": [99], "template": "box_62x29"},
|
|
)
|
|
assert resp.status_code == 404
|
|
assert "99" in resp.text
|
|
|
|
|
|
# ── Validation cross-cutting ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestValidation:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_request_body_size_capped(self, async_client: AsyncClient):
|
|
"""spool_ids is bounded to MAX_LABELS_PER_REQUEST so a runaway client
|
|
can't flood the renderer."""
|
|
from backend.app.api.routes.labels import MAX_LABELS_PER_REQUEST
|
|
|
|
resp = await async_client.post(
|
|
"/api/v1/inventory/labels",
|
|
json={
|
|
"spool_ids": list(range(1, MAX_LABELS_PER_REQUEST + 2)),
|
|
"template": "box_62x29",
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|