fix(labels): replace incorrect ams_30x15 preset with correct AMS holder sizes (#1426)

Reporter — the same person who originally requested the labels
  feature in #809 — discovered that the ams_30x15 preset's 30x15 mm
  dimension didn't actually fit any variant of the MakerWorld AMS
  Filament Label Holder (model 752566) it advertised. Two new
  presets replace it:

  - ams_holder_74x33 (74 x 33 mm) matches the printable label STL
    bundled in the MakerWorld project
  - ams_holder_75x55 (75 x 55 mm) fits the cardstock-insert variant
    the reporter validated on bench

  Both cross the 20 mm height threshold so they land in the roomy
  layout branch — swatch on the left, QR on the right, multi-line
  text (brand, material, hex code, spool ID) in the middle. The
  old 30x15 mm preset couldn't fit a QR code; the new ones do.

  No DB migration: the preset name was never persisted. Callers
  scripting the old ams_30x15 value get a clean 422 at the route's
  Literal validator with the new valid values listed.

  i18n: replaced inventory.labels.templates.ams.{label,hint} with
  amsHolderSmall and amsHolderLarge across all 8 locales with real
  translations; parity guard cleaned of the stale English-fallback
  cognate entries. Parity holds at 4856 leaves per locale.

  Tests: backend label renderer + integration tests cover both new
  presets; LabelTemplatePickerModal test updated for the 6-button
  grid and the new template value in the API-call assertion.
This commit is contained in:
maziggy
2026-05-19 13:14:03 +02:00
parent 0b33862ae9
commit 1677efb2c6
19 changed files with 175 additions and 82 deletions
+3
View File
@@ -9,6 +9,9 @@ All notable changes to Bambuddy will be documented in this file.
- **Camera: in-app diagnostic for "Connection lost" (#1395 follow-up)** — Second step of the camera architecture overhaul. When the camera viewer hits its error state, a new **Diagnose** button next to **Retry** runs a staged check against the printer and renders the result inline: which stage failed, how long it took, and a translated remediation hint. Cuts off the "user opens a 'camera broken' ticket → wait days → ask for the support bundle → finally figure out it was their reverse proxy / LAN-only toggle / wrong access code" loop at the user's screen. **Backend** ships `backend/app/services/camera_diagnose.py` (orchestrator) and a new `POST /printers/{id}/camera/diagnose` route in `camera.py`. Stages: (1) `tcp_reachable` — opens a TCP socket to the camera port (322 RTSPS / 6000 chamber image) with a 3-second timeout; distinguishes timeout (`tcp_timeout` → "printer not reachable, check IP/network/power") from refused (`tcp_refused` → "camera port closed, check LAN-only and developer mode") from host-unreachable (`tcp_unreachable` → "printer not reachable"). (2) `first_frame` — captures one JPEG end-to-end via the existing `capture_camera_frame_bytes` pipeline (15-second timeout, same code that powers `/camera/snapshot`); auth, RTSP handshake, and first keyframe collapse into one stage because the user-facing answer is the same regardless of which sub-layer failed. **Live-stream shortcut**: when a viewer is currently watching the printer's camera AND the buffered last-frame timestamp is fresher than 10 seconds, the diagnostic skips the real test and returns `live_stream_active_healthy` — opening a fresh socket would kick the live viewer off on single-camera-connection firmwares (the #1348 reconnect-storm trigger), so we trust the real-world evidence instead. Response includes structured metadata for support triage: `protocol` (rtsp / chamber_image), `port`, `profile` (`default` or the model name with an override — currently only `P2S`), per-stage duration in ms, and the machine-readable summary code. **Frontend** adds `CameraDiagnoseModal.tsx` that fires the API call on mount, renders one row per stage with green-check / red-X / grey-skipped icons, and shows the summary remediation message in a bordered banner styled by overall status. The metadata line at the bottom (protocol / port / profile) lets support triage ask "what does your modal say?" instead of "send the support bundle". A **Run again** button re-runs the diagnostic without dismissing the modal. **EmbeddedCameraViewer** error state grows the Diagnose button (kept "Retry" as the primary action; Diagnose is the escape hatch for users who can't see what's wrong). A small stethoscope icon also lives in the viewer's always-visible control bar between **Refresh** and **Fullscreen**, so pre-flight testing ("did my firmware update break the camera?", "is the camera up before I send a print?") doesn't require waiting for the stream to fail first. Also lifted the previously-hard-coded "Camera unavailable" / "Retry" strings into `camera.unavailable` / `camera.retry` so the error UI is properly translated alongside the new keys. **i18n**: 16 new keys (`unavailable`, `retry`, plus `diagnose.{button,modalTitle,running,runFailed,retry,stage.*,summary.*,meta.*}`) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). German "Diagnose" is a real cognate — added to `IDENTICAL_TO_EN_ALLOWED.de` rather than translated to a synthetic. Parity check holds at 4849 leaves per locale. **Tests**: 11 backend unit tests in `test_camera_diagnose.py` cover the live-stream shortcut (skip when fresh, run when stale), the three TCP failure modes (timeout / refused / OSError) → distinct summary codes, the first-frame stage (no-frame and capture-exception cases), the all-OK path, and the result metadata (P2S → P2S profile / rtsp / 322; A1 → default / chamber_image / 6000; X1C → default / rtsp / 322). 1 backend integration test pins the route's response shape end-to-end. 3 frontend tests in `CameraDiagnoseModal.test.tsx` (mounted → API call, failure → translated remediation, Run again → re-call). 5021 backend tests + 1905 frontend tests green; ruff clean; build clean; i18n parity clean.
### Changed
- **Inventory: AMS Filament Label Holder presets fixed and split into "small" and "large" variants (#1426, reported by @bsaunder)** — Reporter (the same person who originally requested the labels feature in #809) discovered that the `ams_30x15` preset's 30×15 mm dimension didn't fit any documented variant of MakerWorld model 752566, despite the preset advertising itself as designed for it. Two new presets replace it: `ams_holder_74x33` (matches the printable label STL bundled in the project) and `ams_holder_75x55` (fits the cardstock-insert variant the reporter validated as "fits perfectly"). Both land in the roomy-layout branch (swatch + QR + multi-line text with brand / material / hex / spool ID) because the height crosses the 20 mm threshold — so the larger AMS holder labels carry the QR code back to `/inventory?spool=<id>` that the old 30×15 preset couldn't fit. The 30×15 preset is removed entirely; no DB migration needed because the preset name was never persisted (label printing is a one-shot action and the picker defaults to nothing). The legacy tight-layout code path in `_draw_label_tight` is kept as the safety branch for any future ultra-small preset (no shipped template uses it now). **API change**: `POST /inventory/labels` and `POST /spoolman/labels` accept `template` values `ams_holder_74x33` and `ams_holder_75x55` instead of `ams_30x15`. The Literal types in `backend/app/api/routes/labels.py` and `frontend/src/api/client.ts::SpoolLabelTemplate` reject the old value at validation time, so any caller still scripting `ams_30x15` gets a 422 with a clear "valid values" message. i18n: replaced `inventory.labels.templates.ams.{label,hint}` with `inventory.labels.templates.amsHolderSmall.{label,hint}` and `inventory.labels.templates.amsHolderLarge.{label,hint}` — real translations across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW), the English-fallback strings on the old key are gone (`feedback_translate_dont_fallback` rule). Parity check passes at 4856 leaves per locale. Tests: `test_label_renderer.py::ALL_TEMPLATES` updated and the AMS-specific render test pins the small variant; `test_labels.py` integration test covers both new presets end-to-end; `LabelTemplatePickerModal.test.tsx` updated to expect 6 template buttons in the grid (was 5), pin the new `ams_holder_75x55` template value in the API call assertion, and verify both AMS variants are disabled when no spools are selected. 35 affected backend tests + 17 frontend modal tests green; full suite 5035 green; ruff clean; frontend build clean.
### Fixed
- **Archives: "Scan for timelapse" no longer permanently disabled on VP-queue-dispatched prints (#1403 follow-up, reported by @pwostran and @enjoylifenow)** — Reporters dispatched a print from a slicer via the VP print queue, the printer recorded the timelapse to its SD card (visible and downloadable via Bambuddy's file browser), but the archive UI's "Scan for timelapse" and "View Timelapse" actions stayed greyed out forever. Frontend gates both on `archive.printer_id` (`ArchivesPage.tsx:459`); backend `/archives/{id}/timelapse/scan` also 400s when the archive has no `printer_id`. Root cause was in `main.py::on_print_start`'s expected-archive branch: VP-queue archives are created with `printer_id=None` at queue-add time (we don't yet know which printer will run the job — the scheduler decides later for "Any P1S"-style queue items, and even for explicit-printer queue items the archive is created before dispatch). When the print actually starts and `on_print_start` looks up the expected archive via `_expected_prints`, the branch updated `status`, `started_at`, and `subtask_id` but never assigned `printer_id`. So the archive stayed `printer_id=None` for the entire print and forever after — and every downstream UI / API path gated on it (timelapse scan + view, the printer filter on Archives, per-printer stats, success-rate cohort attribution) treated the archive as "unassigned." One-line fix in the expected-archive branch sets `archive.printer_id = printer_id` when they differ, guarded against clobbering an already-correct value so library-file-based queue items (which create their archive with the printer pre-assigned at dispatch time) are unaffected. Two regressions in a new `test_print_start_assigns_printer_id_to_vp_archive.py`: `test_expected_archive_path_assigns_printer_id_when_unset` (VP-queue archive with `printer_id=None` → promoted to the running printer) and `test_expected_archive_path_preserves_existing_printer_id` (library-file archive with `printer_id=7` stays at 7 when the same printer runs it — the branch is idempotent on correct data). The 2 existing `test_layer_timelapse_expected_archive.py` tests + 19 `test_print_start_expected_promotion.py` regressions still green — the expected-archive branch's other side-effects (timelapse session, AMS mapping, status/started_at promotion) are unchanged. 5033 backend tests + ruff clean. **Note about the queue-mode slicer-inheritance path itself**: pwostran's specific complaint that the original #1403 fix "didn't work" was a misdiagnosis on his end — his support bundle proves Bambuddy correctly sent `timelapse: true` to the printer and the printer recorded the video; his actual gap was this archive-attachment bug. The slicer-inheritance branch added in #1403 is a no-op for OrcaSlicer's "Send to print queue" flow because Orca only does the FTP upload and never sends a `project_file` MQTT command at upload time — so the path falls back to `default_*` settings, which is the operative path for that workflow. Users who want a different per-print value still edit the queue item before starting (as pwostran did, which is why his dispatch carried `timelapse: true`).
+10 -2
View File
@@ -37,7 +37,8 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["labels"])
_VALID_TEMPLATES: tuple[TemplateName, ...] = (
"ams_30x15",
"ams_holder_74x33",
"ams_holder_75x55",
"box_40x30",
"box_62x29",
"avery_5160",
@@ -51,7 +52,14 @@ MAX_LABELS_PER_REQUEST = 500
class LabelRequest(BaseModel):
spool_ids: list[int] = Field(..., min_length=1, max_length=MAX_LABELS_PER_REQUEST)
template: Literal["ams_30x15", "box_40x30", "box_62x29", "avery_5160", "avery_l7160"]
template: Literal[
"ams_holder_74x33",
"ams_holder_75x55",
"box_40x30",
"box_62x29",
"avery_5160",
"avery_l7160",
]
def _split_extra_colors(raw: str | None) -> list[str] | None:
+31 -16
View File
@@ -1,9 +1,12 @@
"""PDF spool label rendering.
Five fixed templates:
Six fixed templates:
- ``ams_30x15`` — 30×15 mm single label, fits the popular Makerworld AMS
Filament Label Holder (model 752566). One label per page.
- ``ams_holder_74x33`` — 74×33 mm single label, matches the printable label
STL bundled with the Makerworld AMS Filament Label Holder (model 752566).
Smaller variant — the visible window in the holder. One label per page.
- ``ams_holder_75x55`` — 75×55 mm single label, fits the cardstock-insert
variant of the same holder. Roomier — swatch + QR + full text column.
- ``box_40x30`` — 40×30 mm single label, common DK/Brother roll size and a
good fit for filament-bag/storage-bin labels (#809 follow-up). Roomy
layout — swatch, QR, full text column with hex code.
@@ -12,6 +15,10 @@ Five fixed templates:
- ``avery_5160`` — US Letter sheet, 25.4×66.7 mm × 30 per sheet.
- ``avery_l7160`` — A4 sheet, 38.1×63.5 mm × 21 per sheet.
The legacy ``ams_30x15`` preset (#809) was incorrect — the original 30×15 mm
dimension didn't fit any documented variant of model 752566. Replaced by the
two ``ams_holder_*`` presets above (#1426).
The renderer is decoupled from the Spool model: callers build a ``LabelData``
list from whatever source (local DB, Spoolman, future) so the same code path
works in both modes.
@@ -34,7 +41,14 @@ from reportlab.lib.pagesizes import A4, letter
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas as rl_canvas
TemplateName = Literal["ams_30x15", "box_40x30", "box_62x29", "avery_5160", "avery_l7160"]
TemplateName = Literal[
"ams_holder_74x33",
"ams_holder_75x55",
"box_40x30",
"box_62x29",
"avery_5160",
"avery_l7160",
]
@dataclass
@@ -180,17 +194,17 @@ def _draw_label(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, dat
Two layouts, picked by available height:
- **Tight** (h < 20 mm — AMS holder): swatch on the left, three lines of
text on the right (brand, material+subtype, big spool ID). No QR — at
30×15 mm there is not enough horizontal room for swatch + text + QR
without truncating away the user-need fields, and the AMS holder is an
at-a-glance identifier where the spool ID is the killer field. The
box-label and Avery templates carry the QR for the other use cases.
- **Tight** (h < 20 mm): swatch on the left, three lines of text on the
right (brand, material+subtype, big spool ID). No QR — at very small
heights there is not enough horizontal room for swatch + text + QR
without truncating away the user-need fields. Kept as the safety
branch for any future ultra-small preset; the shipped templates all
land in the roomy layout below.
- **Roomy** (h >= 20 mm — box label, Avery sheets): swatch on the left,
QR on the right, multi-line text in the middle column. Large spool ID
anchored at bottom-left under the swatch so it stays readable when the
label is on a box on a shelf at arm's length.
- **Roomy** (h >= 20 mm — AMS holder, box label, Avery sheets): swatch
on the left, QR on the right, multi-line text in the middle column.
Large spool ID anchored at bottom-left under the swatch so it stays
readable at arm's length.
"""
pad = 1.2 * mm
inner_x, inner_y = x + pad, y + pad
@@ -223,7 +237,7 @@ def _draw_label_tight(
pad: float,
data: LabelData,
) -> None:
"""AMS-holder layout (e.g. 30×15 mm). Swatch + brand/material/hex/ID, no QR."""
"""Tight layout (h < 20 mm). Swatch + brand/material/hex/ID, no QR."""
swatch_w = min(inner_h, inner_w * 0.35)
swatch_y = inner_y + (inner_h - swatch_w) / 2
_draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
@@ -365,7 +379,8 @@ def _draw_label_roomy(
# (label_w_mm, label_h_mm) for single-label-per-page templates.
_SINGLE_LABEL_SIZES_MM: dict[str, tuple[float, float]] = {
"ams_30x15": (30.0, 15.0),
"ams_holder_74x33": (74.0, 33.0),
"ams_holder_75x55": (75.0, 55.0),
"box_40x30": (40.0, 30.0),
"box_62x29": (62.0, 29.0),
}
+8 -2
View File
@@ -66,7 +66,13 @@ class TestLocalInventoryLabels:
@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"):
for template in (
"ams_holder_74x33",
"ams_holder_75x55",
"box_62x29",
"avery_5160",
"avery_l7160",
):
resp = await async_client.post(
"/api/v1/inventory/labels",
json={"spool_ids": [s.id], "template": template},
@@ -100,7 +106,7 @@ class TestLocalInventoryLabels:
s = await spool_factory()
resp = await async_client.post(
"/api/v1/inventory/labels",
json={"spool_ids": [s.id, 99999], "template": "ams_30x15"},
json={"spool_ids": [s.id, 99999], "template": "ams_holder_74x33"},
)
assert resp.status_code == 404
assert "99999" in resp.text
@@ -6,7 +6,14 @@ import pytest
from backend.app.services.label_renderer import LabelData, render_labels
ALL_TEMPLATES = ("ams_30x15", "box_40x30", "box_62x29", "avery_5160", "avery_l7160")
ALL_TEMPLATES = (
"ams_holder_74x33",
"ams_holder_75x55",
"box_40x30",
"box_62x29",
"avery_5160",
"avery_l7160",
)
def _sample(spool_id: int = 1, **overrides) -> LabelData:
@@ -58,7 +65,7 @@ def test_missing_optional_fields_does_not_crash():
deeplink_url="https://example.test/inventory?spool=42",
)
]
pdf = render_labels("ams_30x15", data)
pdf = render_labels("ams_holder_74x33", data)
assert pdf.startswith(b"%PDF")
@@ -74,7 +81,7 @@ def test_long_strings_are_truncated_not_overflowed():
long_brand = "A" * 200
long_name = "B" * 300
data = [_sample(brand=long_brand, name=long_name)]
pdf = render_labels("ams_30x15", data)
pdf = render_labels("ams_holder_74x33", data)
assert pdf.startswith(b"%PDF")
@@ -121,9 +128,10 @@ def _render_uncompressed(template, data):
from backend.app.services.label_renderer import _draw_label # noqa: PLC0415
# Mirror the page-size choice from render_labels but force pageCompression=0.
if template in ("ams_30x15", "box_40x30", "box_62x29"):
if template in ("ams_holder_74x33", "ams_holder_75x55", "box_40x30", "box_62x29"):
sizes = {
"ams_30x15": (30.0, 15.0),
"ams_holder_74x33": (74.0, 33.0),
"ams_holder_75x55": (75.0, 55.0),
"box_40x30": (40.0, 30.0),
"box_62x29": (62.0, 29.0),
}
@@ -167,9 +175,9 @@ def _render_uncompressed(template, data):
def test_ams_template_actually_renders_text():
"""Regression: the first cut of the AMS-holder layout produced labels with
only swatch + QR and no text at all because the side-by-side layout left
<5 mm for the text column. The redesign drops the QR on this template and
gives the right side to brand + material + spool ID. This pins that the
rendered PDF contains all three fields.
<5 mm for the text column. The current AMS templates use the roomy layout
(swatch + QR + multi-line text); this pins that the rendered PDF contains
brand + material + spool ID for the smaller AMS preset.
"""
data = [
LabelData(
@@ -182,7 +190,7 @@ def test_ams_template_actually_renders_text():
deeplink_url="https://example.test/inventory?spool=42",
)
]
pdf = _render_uncompressed("ams_30x15", data)
pdf = _render_uncompressed("ams_holder_74x33", data)
assert b"Polymaker" in pdf, "AMS template must render the brand"
assert b"PLA" in pdf, "AMS template must render the material"
# The bracketed-hash style is what the renderer uses for the spool ID;
+7 -7
View File
@@ -160,7 +160,7 @@ const DE_COGNATES = [
'Hex', 'Warm', 'Neutral', 'Navigation', 'Screenshot', 'Architecture',
'Backend & Auth', 'Stream Overlay', 'Bambuddy Backend URL',
'Material (optional)', 'Custom Headers (JSON)', '({{count}}/8)',
'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
'Box label (62 × 29 mm)',
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
'China', 'Proxy', 'Start',
@@ -192,7 +192,7 @@ const FR_COGNATES = [
'{{count}} filament', '{{count}} filaments', '{{count}} permissions',
'{{count}} downloads', '{{count}} item', '{{count}} selected',
'({{count}} item)', 'Provisioning...', 'Pressure Advance',
'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
'Box label (62 × 29 mm)',
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
'({{count}}/8)', 'Custom Headers (JSON)', 'Permissions',
@@ -223,7 +223,7 @@ const IT_COGNATES = [
'{{name}} - Timelapse', 'Box label (62 × 29 mm)',
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
'AMS holder (30 × 15 mm)', 'Hex: #{{hex}}',
'Hex: #{{hex}}',
'EC984C,#6CD4BC,A66EB9,D87694',
'Proxy', 'Designer',
];
@@ -234,7 +234,7 @@ const JA_COGNATES = [
'OK', 'Bambu', 'Code',
'EU (DD/MM/YYYY)', 'US (MM/DD/YYYY)', 'ON, true, 1',
'({{count}}/8)', 'Custom Headers (JSON)',
'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
'Box label (62 × 29 mm)',
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
'EC984C,#6CD4BC,A66EB9,D87694',
@@ -257,7 +257,7 @@ const PT_BR_COGNATES = [
'Base: {{name}}', 'ETA {{minutes}} min', '{{count}} item',
'{{count}} downloads', '({{count}} item)', '(25%, 50%, 75%)',
'({{count}}/8)', 'Custom Headers (JSON)', '{{name}} - Timelapse',
'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
'Box label (62 × 29 mm)',
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
'Cancelling upload...', 'EC984C,#6CD4BC,A66EB9,D87694',
@@ -269,7 +269,7 @@ const PT_BR_COGNATES = [
// Chinese (Simplified): very few cognates beyond brand names.
const ZH_CN_COGNATES = [
'OK', 'Bambu',
'({{count}}/8)', 'Custom Headers (JSON)', 'AMS holder (30 × 15 mm)',
'({{count}}/8)', 'Custom Headers (JSON)',
'Box label (62 × 29 mm)',
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
@@ -278,7 +278,7 @@ const ZH_CN_COGNATES = [
const ZH_TW_COGNATES = [
'OK', 'Bambu',
'({{count}}/8)', 'Custom Headers (JSON)', 'AMS holder (30 × 15 mm)',
'({{count}}/8)', 'Custom Headers (JSON)',
'Box label (62 × 29 mm)',
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
@@ -176,7 +176,13 @@ describe('LabelTemplatePickerModal', () => {
spoolmanMode={false}
/>,
);
expect(screen.getByText(/AMS holder/i).closest('button')).toBeDisabled();
// Two AMS holder variants exist (#1426). Both must be disabled when no
// spools are selected — the empty-selection guard is global, not per-template.
const amsButtons = screen.getAllByText(/AMS holder/i).map((el) => el.closest('button'));
expect(amsButtons).toHaveLength(2);
for (const btn of amsButtons) {
expect(btn).toBeDisabled();
}
});
it('sends only the currently checked IDs to the local endpoint', async () => {
@@ -218,12 +224,14 @@ describe('LabelTemplatePickerModal', () => {
/>,
);
fireEvent.click(screen.getByText(/AMS holder/i));
// Pick the larger AMS holder variant explicitly (#1426: two AMS templates
// exist now — pin which one the test sends so the assertion stays meaningful).
fireEvent.click(screen.getByText(/AMS holder — large \(75 × 55 mm\)/i));
await waitFor(() => {
expect(api.printSpoolmanSpoolLabels).toHaveBeenCalledWith({
spool_ids: [1],
template: 'ams_30x15',
template: 'ams_holder_75x55',
});
});
expect(api.printSpoolLabels).not.toHaveBeenCalled();
@@ -296,9 +304,10 @@ describe('LabelTemplatePickerModal', () => {
/>,
);
// All five templates must be in the DOM. Use the dimension suffix to
// disambiguate the two "Box label …" entries.
expect(screen.getByText(/AMS holder/i)).toBeInTheDocument();
// All six templates must be in the DOM (#1426 added two AMS variants).
// Use the dimension suffix to disambiguate same-family entries.
expect(screen.getByText(/AMS holder — small \(74 × 33 mm\)/i)).toBeInTheDocument();
expect(screen.getByText(/AMS holder — large \(75 × 55 mm\)/i)).toBeInTheDocument();
expect(screen.getByText(/Box label \(40 × 30 mm\)/i)).toBeInTheDocument();
expect(screen.getByText(/Box label \(62 × 29 mm\)/i)).toBeInTheDocument();
expect(screen.getByText(/Avery L7160/i)).toBeInTheDocument();
@@ -311,7 +320,7 @@ describe('LabelTemplatePickerModal', () => {
const templatesSection = container.querySelector('div.grid.sm\\:grid-cols-2');
expect(templatesSection).not.toBeNull();
expect(templatesSection!.className).toContain('grid-cols-1');
expect(templatesSection!.querySelectorAll('button').length).toBe(5);
expect(templatesSection!.querySelectorAll('button').length).toBe(6);
// Spool list still uses min-h-0 so it can yield further on very tight viewports.
const spoolListScroller = container.querySelector('div.flex-1.overflow-y-auto');
+7 -1
View File
@@ -2415,7 +2415,13 @@ export interface SpoolmanFilamentEntry {
// Inventory types
// Label printing (#809). Mirror of backend.app.services.label_renderer.TemplateName.
export type SpoolLabelTemplate = 'ams_30x15' | 'box_40x30' | 'box_62x29' | 'avery_5160' | 'avery_l7160';
export type SpoolLabelTemplate =
| 'ams_holder_74x33'
| 'ams_holder_75x55'
| 'box_40x30'
| 'box_62x29'
| 'avery_5160'
| 'avery_l7160';
export interface InventorySpool {
id: number;
@@ -33,10 +33,16 @@ interface TemplateOption {
const TEMPLATE_OPTIONS: TemplateOption[] = [
{
value: 'ams_30x15',
i18nKey: 'ams',
fallbackLabel: 'AMS holder (30 × 15 mm)',
fallbackHint: 'Single label per page; fits the popular AMS filament label holder.',
value: 'ams_holder_74x33',
i18nKey: 'amsHolderSmall',
fallbackLabel: 'AMS holder — small (74 × 33 mm)',
fallbackHint: 'Single label per page; matches the printable label from MakerWorld model 752566 (AMS Filament Label Holder).',
},
{
value: 'ams_holder_75x55',
i18nKey: 'amsHolderLarge',
fallbackLabel: 'AMS holder — large (75 × 55 mm)',
fallbackHint: 'Single label per page; fits the cardstock-insert variant of the AMS Filament Label Holder. Roomy enough for swatch, brand, material, ID, and QR code.',
},
{
value: 'box_40x30',
+7 -3
View File
@@ -3542,9 +3542,13 @@ export default {
color: 'Nach Farbe',
},
templates: {
ams: {
label: 'AMS holder (30 × 15 mm)',
hint: 'Ein Etikett pro Seite; passt in den beliebten AMS-Filament-Etikettenhalter.',
amsHolderSmall: {
label: 'AMS-Halter — klein (74 × 33 mm)',
hint: 'Ein Etikett pro Seite; passt zum druckbaren Etikett aus dem MakerWorld-Modell 752566 (AMS-Filament-Etikettenhalter).',
},
amsHolderLarge: {
label: 'AMS-Halter — groß (75 × 55 mm)',
hint: 'Ein Etikett pro Seite; passt zur Kartoneinleger-Variante des AMS-Filament-Etikettenhalters. Genug Platz für Farbprobe, Marke, Material, ID und QR-Code.',
},
box40x30: {
label: 'Boxetikett (40 × 30 mm)',
+7 -3
View File
@@ -3545,9 +3545,13 @@ export default {
color: 'By colour',
},
templates: {
ams: {
label: 'AMS holder (30 × 15 mm)',
hint: 'Single label per page; fits the popular AMS filament label holder.',
amsHolderSmall: {
label: 'AMS holder — small (74 × 33 mm)',
hint: 'Single label per page; matches the printable label from MakerWorld model 752566 (AMS Filament Label Holder).',
},
amsHolderLarge: {
label: 'AMS holder — large (75 × 55 mm)',
hint: 'Single label per page; fits the cardstock-insert variant of the AMS Filament Label Holder. Roomy enough for swatch, brand, material, ID, and QR code.',
},
box40x30: {
label: 'Box label (40 × 30 mm)',
+7 -3
View File
@@ -3531,9 +3531,13 @@ export default {
color: 'Par couleur',
},
templates: {
ams: {
label: 'AMS holder (30 × 15 mm)',
hint: 'Une étiquette par page ; convient au support d\'étiquettes AMS populaire.',
amsHolderSmall: {
label: 'Support AMS — petit (74 × 33 mm)',
hint: 'Une étiquette par page ; correspond à l\'étiquette imprimable du modèle MakerWorld 752566 (Support d\'étiquettes pour filament AMS).',
},
amsHolderLarge: {
label: 'Support AMS — grand (75 × 55 mm)',
hint: 'Une étiquette par page ; s\'adapte à la variante carton du support d\'étiquettes pour filament AMS. Assez d\'espace pour pastille, marque, matériau, ID et QR code.',
},
box40x30: {
label: 'Étiquette boîte (40 × 30 mm)',
+7 -3
View File
@@ -3530,9 +3530,13 @@ export default {
color: 'Per colore',
},
templates: {
ams: {
label: 'AMS holder (30 × 15 mm)',
hint: 'Una etichetta per pagina; adatta al popolare portaetichette AMS.',
amsHolderSmall: {
label: 'Supporto AMS — piccolo (74 × 33 mm)',
hint: 'Un\'etichetta per pagina; corrisponde all\'etichetta stampabile del modello MakerWorld 752566 (Porta-etichette filamento AMS).',
},
amsHolderLarge: {
label: 'Supporto AMS — grande (75 × 55 mm)',
hint: 'Un\'etichetta per pagina; si adatta alla variante cartoncino del porta-etichette filamento AMS. Spazio sufficiente per campione colore, marca, materiale, ID e codice QR.',
},
box40x30: {
label: 'Etichetta scatola (40 × 30 mm)',
+7 -3
View File
@@ -3542,9 +3542,13 @@ export default {
color: '色順',
},
templates: {
ams: {
label: 'AMS holder (30 × 15 mm)',
hint: '1ページに1ラベル、人気のAMSフィラメントラベルホルダーに適合。',
amsHolderSmall: {
label: 'AMS ホルダー — 小 (74 × 33 mm)',
hint: '1ページにつき1枚;MakerWorld モデル 752566 (AMS フィラメントラベルホルダー) の印刷可能なラベルに対応します。',
},
amsHolderLarge: {
label: 'AMS ホルダー — 大 (75 × 55 mm)',
hint: '1ページにつき1枚;AMS フィラメントラベルホルダーのカード紙挿入バージョンに対応します。色見本、ブランド、素材、ID、QR コードを表示できる広さです。',
},
box40x30: {
label: 'ボックスラベル (40 × 30 mm)',
+7 -3
View File
@@ -3530,9 +3530,13 @@ export default {
color: 'Por cor',
},
templates: {
ams: {
label: 'AMS holder (30 × 15 mm)',
hint: 'Uma etiqueta por página; serve no porta-etiquetas AMS popular.',
amsHolderSmall: {
label: 'Suporte AMS — pequeno (74 × 33 mm)',
hint: 'Uma etiqueta por página; corresponde à etiqueta imprimível do modelo MakerWorld 752566 (Suporte de etiquetas de filamento AMS).',
},
amsHolderLarge: {
label: 'Suporte AMS — grande (75 × 55 mm)',
hint: 'Uma etiqueta por página; encaixa na variante com inserto em cartão do Suporte de etiquetas de filamento AMS. Espaço suficiente para amostra de cor, marca, material, ID e QR code.',
},
box40x30: {
label: 'Etiqueta de caixa (40 × 30 mm)',
+7 -3
View File
@@ -3530,9 +3530,13 @@ export default {
color: '按颜色',
},
templates: {
ams: {
label: 'AMS holder (30 × 15 mm)',
hint: '每页一个标签;适用于流行的 AMS 耗材标签托架。',
amsHolderSmall: {
label: 'AMS 支架 — 小 (74 × 33 mm)',
hint: '每页一个标签;适配 MakerWorld 模型 752566 (AMS 耗材标签支架) 中可打印的标签。',
},
amsHolderLarge: {
label: 'AMS 支架 — 大 (75 × 55 mm)',
hint: '每页一个标签;适配 AMS 耗材标签支架的卡片插入款。空间充足,可放置色板、品牌、材料、ID 与二维码。',
},
box40x30: {
label: '盒标签 (40 × 30 mm)',
+7 -3
View File
@@ -3530,9 +3530,13 @@ export default {
color: '按顏色',
},
templates: {
ams: {
label: 'AMS holder (30 × 15 mm)',
hint: '每頁一個標籤;適用於熱門的 AMS 耗材標籤托架。',
amsHolderSmall: {
label: 'AMS 支架 — 小 (74 × 33 mm)',
hint: '每頁一個標籤;對應 MakerWorld 模型 752566 (AMS 耗材標籤支架) 中可列印的標籤。',
},
amsHolderLarge: {
label: 'AMS 支架 — 大 (75 × 55 mm)',
hint: '每頁一個標籤;對應 AMS 耗材標籤支架的紙卡插入款。空間足夠放置色塊、品牌、材質、ID 與 QR 碼。',
},
box40x30: {
label: '盒標籤 (40 × 30 mm)',
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-D9JqDSB2.js"></script>
<script type="module" crossorigin src="/assets/index-Brndhbee.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-KYwGxnG9.css">
</head>
<body>