diff --git a/CHANGELOG.md b/CHANGELOG.md index d451ed759..c2c86b900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ All notable changes to Bambuddy will be documented in this file. - **Print Log page: per-row delete (#1687 part 1, reported by @IndividualGhost1905)** — Reporter noted that the existing "Also remove this print from Quick Stats" toggle on archive delete is one-shot: if you tick "keep stats" at delete time, there was no later way to drop the row from /stats; and rows that aren't tied to an archive (errors, aborts, manual entries) had no delete affordance at all. **Fix:** every row in the Archives → Print Log table now has a trash icon next to the filament cell, gated on `archives:delete_own` (own rows) or `archives:delete_all` (any row), matching the archive-delete permission shape. Click → confirm modal → row is gone, and because /archives/stats aggregates over `PrintLogEntry` the filament / time / cost contribution drops out of Quick Stats in the same response cycle. The matching archive (if any) is untouched — the log row is a sibling, not a child. **Backend:** new `DELETE /print-log/{entry_id}` mirrors `delete_archive`'s ownership flow via `require_ownership_permission(ARCHIVES_DELETE_ALL, ARCHIVES_DELETE_OWN)`; owners can drop their own rows, admins can drop any row, missing IDs return 404 rather than 200-silently. **Frontend:** new `deletePrintLogEntry` API helper, per-row mutation that invalidates both `print-log` and `archives-stats` query keys so the totals re-render without a manual refresh. **i18n:** 4 new keys (`deleteEntryTitle`, `deleteEntryConfirm`, `entryDeleted`, `entryDeleteFailed`) translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). **Tests:** 3 backend integration cases — delete drops the row from /stats while keeping the linked archive listed, missing ID returns 404, delete-one does not touch siblings (regression guard against an accidental `delete(PrintLogEntry)` without a `where`). Frontend ArchivesPage / PrintLogModal vitests stay green (31 / 31). i18n parity green (5099 leaves × 11 locales). Issue #1687 also asks for per-row tagging (already covered by `EditArchiveModal`'s tags field) and per-row filament-usage-history edits (deferred — see the issue thread for the reasoning). ### Fixed +- **Filament Override "Original" label shows base type + generic color instead of the sliced sub-brand + actual color (#1718, reported by @SamNuttall)** — The Print Queue's filament-override panel rendered every "Original" row as `{type} ({colorName})` — just the raw 3MF `` attribute, which is always the base material ("PLA", "PETG-HF") — plus the generic color-bucket name from `getColorName(hex)`. A model sliced with "Bambu PLA Matte Charcoal" therefore showed up as "PLA (Black)" in the dropdown's original-filament option, and the schedule dialog gave no way to confirm the user was actually overriding what they thought they were. The 3MF DOES carry the Bambu SKU (`tray_info_idx`, e.g. `GFA01`) on each `` element — `backend/app/api/routes/archives.py:3634/3665` already returns it in the `/archives/{id}/filament-requirements` response — but `FilamentReqsData` at `frontend/src/components/PrintModal/types.ts:178` didn't carry the field, so `FilamentOverride.tsx` couldn't see it. The resolution path was also already in place: `_BUILTIN_FILAMENT_NAMES` at `backend/app/api/routes/cloud.py:568` maps Bambu factory SKUs (`GFA01` → "Bambu PLA Matte"), exposed as `/cloud/builtin-filaments`; `/cloud/filament-id-map` returns the same shape for user custom presets (`P*` prefix). `KProfilesView.tsx:791` already merges those two for its own labels. **Fix:** add `tray_info_idx?: string` to the `FilamentReqsData.filaments` type. `FilamentOverride` now loads both maps via `useQuery(['builtin-filaments'])` + `useQuery(['filament-id-map'])` (both shared caches the rest of the app already populates, `staleTime: 5 min`) and merges them into a single `idx → name` lookup — user cloud preset names win over the builtin entries for the same id (the user-authored label is more specific). Both the dropdown's "original" placeholder option AND the swatch tooltip use the resolved name; the raw `req.type` stays as the fallback when the SKU is unknown to both sources so unknown ids degrade to today's behaviour instead of rendering blank. Color side note: Bambu Studio's specific color names ("Charcoal") live in their cloud catalog, not in the 3MF — the file carries only the hex — so Bambuddy still renders the color from `getColorName(hex)`. "Bambu PLA Matte (Black)" is the realistic best we can do; user-readable sub-brand IS now exposed. **Color disambiguation (round 2):** the sub-brand half above is necessary but not sufficient — `getColorName(hex)` resolved through `/api/inventory/colors/map`, which collapses every catalog entry sharing a hex to a single name via "Bambu Lab > is_default > first" priority. Hex `#000000` has 9 Bambu Lab catalog entries (Black for 8 materials, Charcoal for PLA Matte) all at the same priority, so "Black" — first encountered — wins the race and "Charcoal" is dropped before the frontend ever sees it. A new endpoint `GET /api/inventory/colors/by-material?hex=X&material=Y` (`backend/app/api/routes/inventory.py:get_color_by_material`) preserves the material context: same case-insensitive hex match as `/colors/map`, then a `material` filter on top. When no entry matches the requested material it falls back to the same priority order as `/colors/map`, so callers without a material hint (or with an unknown one) get exactly the existing answer — no regression for the flat-map consumers (PrintersPage, InventoryPage). `FilamentOverride.tsx` derives a material hint from the resolved sub-brand by stripping the leading brand token ("Bambu PLA Matte" → "PLA Matte", "PolyLite ABS" → "ABS"), dispatches one `useQuery` per slot via `useQueries` keyed on `(hex, material)`, and uses `data.color_name || getColorName(hex)` so a slow query never blanks out the placeholder. Five new tests in `test_color_catalog_extras.py` pin: same hex + different material returns the correctly-paired name; unknown material falls back to priority order; missing hex returns `color_name=null` (no 404); mixed-case input on both sides matches; invalid hex (<6 chars) returns null without crashing. Three new vitest cases pin: PLA Matte Charcoal scenario lands "Bambu PLA Matte (Charcoal)", per-slot disambiguation (regression guard so a Matte slot doesn't adopt a Basic slot's answer when both share a hex), null lookup falls back to `getColorName(hex)`. **Tests overall:** 20 `FilamentOverride.test.tsx` cases green; 12 `test_color_catalog_extras.py` integration cases green; combined PrintModal + FilamentOverride + FilamentMapping suite 79/79 green. **Same fix applies to printer-mode FilamentMapping (round 3):** the schedule modal's "Specific Printer" branch renders `FilamentMapping` instead of `FilamentOverride` and was reading the same raw fields (`item.type` + generic `getColorName(item.color)`) for the required-side row and the colour swatch tooltip — so a Charcoal slice opened against a specific printer still showed "Required: PLA - Black" while the model-mode branch already read "Bambu PLA Matte - Charcoal" against the same 3MF (caught when Sam's Specific-Printer screenshot still showed the old text after round 2 shipped). Extracted the three-query resolution machinery from `FilamentOverride.tsx` into a shared hook `useFilamentLabels` in `frontend/src/components/PrintModal/useFilamentLabels.ts` so the two panels can't drift on label content; `FilamentOverride` and `FilamentMapping` now both call `useFilamentLabels(filamentReqs?.filaments)` and read positional `{ resolvedName, colorLabel }` per slot. The hook also exports the `extractMaterialHint` helper so backend material-hint test parity is mechanical (one source of truth for "strip the leading brand token"). FilamentMapping's required-side type label now reads `{resolvedName}` instead of raw `{item.type}`, and the colour swatch tooltip reads `Required: {resolvedName} - {colorLabel}` instead of `Required: {item.type} - getColorName(item.color)`. New vitest case `renders sub-brand + material-disambiguated colour on the required side (#1718)` mirrors the FilamentOverride Charcoal scenario against FilamentMapping (msw stubs for builtin-filaments + by-material). Existing FTS dropdown-filter / force-color-match cases stay green. Hook itself gets direct unit coverage in a new `useFilamentLabels.test.tsx` (11 cases — extractMaterialHint corner cases, SKU resolution, cloud-over-builtin precedence, fallbacks, positional alignment across slots with same hex but different materials, and the `enabled: !!color` query gate). The earlier "case-insensitive on both inputs" backend test (in `test_color_catalog_extras.py`) is rewritten to actually seed an upper-case stored hex and query it with lower-case input — the original version only checked invalid-hex returns null, which is the wrong assertion for the test name. Combined PrintModal + FilamentOverride + FilamentMapping + useFilamentLabels + useFilamentMapping suite 144/144 green; eslint clean, build clean. **What this fix can NOT recover:** for hexes the catalog has no entry for (third-party filament manually loaded, etc.), the color label degrades to the existing HSL-bucket name from `getColorName(hex)` — still strictly better than blank, but Bambu's specific color names only live in the seeded catalog. Frontend + backend; no migration, no new i18n keys; ruff clean, eslint clean, frontend build clean, i18n parity unchanged. + - **Force-color-match checkbox missing when scheduling against a specific printer (#1717, reported by @SamNuttall)** — The Print Queue's schedule dialog hides the per-slot "Force color match" checkbox in the "Specific printer" path. Picking "Any A1" (model-mode dispatch) renders `FilamentOverride` which carries the checkbox, but picking a single printer renders `FilamentMapping` instead — a separate component that had no force-match UI. The dispatcher in `print_scheduler.py:535` already honours `force_color_match` regardless of how the queue item was created (the flag survives end-to-end on the `filament_overrides` payload `buildFilamentOverridesArray` constructs in `PrintModal/index.tsx:613`), so this was a pure UI gap — printer-mode users could not request the safety guard from the modal even though the backend would have respected it. **Fix:** `FilamentMapping` accepts new optional `forceColorMatch` + `onForceColorMatchChange` props mirroring `FilamentOverride`'s shape; it renders the same ``-iconed checkbox under each filament row when a handler is provided. `PrintModal/index.tsx:1100` passes the existing `forceColorMatch` state and a `setForceColorMatch` setter through — same state object both modes write into, so toggling between modes preserves what the user selected. No new i18n keys (the existing `printModal.forceColorMatch` key already ships in all 11 locales). The checkbox is suppressed when no handler is wired (avoids dead UI in callers that don't manage the flag). **Tests:** new `renders the per-slot force-color-match checkbox in printer mode (#1717)` case clicks the checkbox and asserts `onForceColorMatchChange(slotId, true)` fires; companion `omits the force-color-match checkbox when no handler is provided` case pins the absent-handler branch. Existing FTS dropdown-filter tests stay green. `FilamentMapping.test.tsx` 4/4 green; combined PrintModal + FilamentOverride + FilamentMapping suite 73/73 green; eslint clean; frontend build clean; i18n parity 5120 leaves × 11 locales green. - **In-app updater fails when DATA_DIR is on a separate mount from the install (#1715, reported by @francescocozzi)** — Native installs that follow the systemd template `WorkingDirectory=/opt/bambuddy` with `Environment="DATA_DIR=/srv/bambuddy/data"` (or any layout where `DATA_DIR` is not a subdirectory of the install path) couldn't apply in-app updates. Every git step in `_perform_update` (`remote get-url`, `remote set-url`, `fetch`, `reset --hard`) used `cwd=settings.base_dir`, and `safe.directory` was pointed at `base_dir` too. On the standard install (DATA_DIR=INSTALL_PATH/data) this happened to work by accident — git walks up from a subdirectory of the repo to find `.git` — but on a separate-mount layout the data dir is not under the install, the walk-up has nowhere to go, and every operation returns "fatal: not a git repository." Even on the standard install `safe.directory={base_dir}` was wrong (it must equal the repo root git discovers, not the data dir), surfacing on hardened systemd units as "fatal: detected dubious ownership." **Fix:** route every git subprocess in `_perform_update` and `_origin_points_at_repo` through `cwd=settings.app_dir` (the working tree), and set `safe.directory={app_dir}` to match. `app_dir` is now resolved once at the top of `_perform_update` instead of lazily re-resolved before the pip step. The `base_dir` parameter on `_origin_points_at_repo` is renamed to `app_dir` so the signature documents the contract. The pip-install step keeps `cwd=app_dir` (unchanged — that step was already correct). **Tests:** new `test_perform_update_runs_git_in_app_dir_when_data_dir_on_separate_mount` integration case constructs a sibling-paths layout (`tmp/opt/bambuddy` + `tmp/srv/bambuddy/data` — the exact #1715 shape), mocks `asyncio.create_subprocess_exec` to capture every call's cwd, and pins (a) every git subprocess runs with `cwd=app_dir`, (b) the embedded `safe.directory=` config equals `app_dir` on every git call. The existing pip-cwd test stays green (pip's cwd was already `app_dir`). Existing SSH-origin-preserve + origin-rewrite + reset-target tests stay green (they don't assert on git cwd). Full `test_updates_api.py` 21/21 green; ruff clean. **Credit:** root cause + fix shape from francescocozzi via PR #1716 (couldn't be merged as-is — that branch had drifted off an older `dev` and pulled in unrelated upstream commits including a version regression). diff --git a/backend/app/api/routes/inventory.py b/backend/app/api/routes/inventory.py index 7203afb2b..5443e7cad 100644 --- a/backend/app/api/routes/inventory.py +++ b/backend/app/api/routes/inventory.py @@ -513,6 +513,10 @@ class ColorLookupResult(BaseModel): material: str | None = None +class ColorByMaterialResult(BaseModel): + color_name: str | None = None + + # ── Spool Catalog CRUD ───────────────────────────────────────────────────── @@ -790,6 +794,73 @@ async def lookup_color( return ColorLookupResult(found=False) +@router.get("/colors/by-material", response_model=ColorByMaterialResult) +async def get_color_by_material( + hex: str, + material: str | None = None, + db: AsyncSession = Depends(get_db), + _: User | None = Depends(require_auth_if_enabled), +): + """Disambiguated hex→name lookup that respects material context. + + ``/colors/map`` collapses every catalog entry sharing a hex to a single + name with "Bambu Lab > is_default > first" priority — that loses, e.g., + "PLA Matte Charcoal" (#000000) behind "PLA Basic Black" (also #000000). + This endpoint preserves the material context so the queue scheduler's + Filament Override label can show the actually-sliced sub-brand colour + instead of the generic bucket. #1718. + + Returns ``color_name=None`` when the hex isn't in the catalog at all. + When the hex IS in the catalog but no entry matches the requested + material (or none was supplied), falls back to the same priority order + as ``/colors/map`` so callers without a material hint don't regress. + + Not gated on INVENTORY_READ for the same reason ``/colors/map`` isn't — + every queue / archive view that renders a sliced filament colour needs + this, including read-only roles. + """ + key = hex.lstrip("#").lower()[:6] + if len(key) != 6: + return ColorByMaterialResult(color_name=None) + + material_norm = (material or "").strip().lower() + + # Catalog rows are stored as ``#RRGGBB`` (verified at write time and + # against production); lookup uses lower-cased hex equality so mixed-case + # writes from older imports still match. + result = await db.execute( + select( + ColorCatalogEntry.color_name, + ColorCatalogEntry.manufacturer, + ColorCatalogEntry.material, + ColorCatalogEntry.is_default, + ).where(func.lower(ColorCatalogEntry.hex_color) == f"#{key}") + ) + candidates = [(name, mfg, mat, is_default) for name, mfg, mat, is_default in result.all() if name] + if not candidates: + return ColorByMaterialResult(color_name=None) + + if material_norm: + for name, _mfg, mat, _is_default in candidates: + if mat and mat.strip().lower() == material_norm: + return ColorByMaterialResult(color_name=name) + + # Same priority order as ``/colors/map`` so a caller passing no (or an + # unrecognised) material gets the existing answer, not a degraded one. + best_name: str | None = None + best_priority = -1 + for name, mfg, _mat, is_default in candidates: + priority = 0 + if mfg and mfg.strip().lower() == "bambu lab": + priority += 2 + if is_default: + priority += 1 + if priority > best_priority: + best_name = name + best_priority = priority + return ColorByMaterialResult(color_name=best_name) + + @router.get("/colors/search", response_model=list[ColorEntryResponse]) async def search_colors( manufacturer: str | None = None, diff --git a/backend/tests/integration/test_color_catalog_extras.py b/backend/tests/integration/test_color_catalog_extras.py index 56334ef39..440df03dd 100644 --- a/backend/tests/integration/test_color_catalog_extras.py +++ b/backend/tests/integration/test_color_catalog_extras.py @@ -157,3 +157,130 @@ async def test_create_spool_with_color_extras(async_client: AsyncClient): assert patch.status_code == 200 assert patch.json()["extra_colors"] is None assert patch.json()["effect_type"] is None + + +# ---- /colors/by-material — disambiguated lookup (#1718) ------------------- + + +async def _seed_black_collision(client: AsyncClient) -> None: + """Seed the #000000 ambiguity the endpoint was built to resolve. + + PLA Matte → Charcoal, PLA Basic → Black, both at #000000 — same shape as + Bambu's production catalog. + """ + for entry in ( + { + "manufacturer": "Bambu Lab", + "color_name": "Charcoal", + "hex_color": "#000000", + "material": "PLA Matte", + }, + { + "manufacturer": "Bambu Lab", + "color_name": "Black", + "hex_color": "#000000", + "material": "PLA Basic", + }, + ): + response = await client.post("/api/v1/inventory/colors", json=entry) + assert response.status_code == 200, response.text + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_by_material_returns_material_specific_name(async_client: AsyncClient): + """Same hex + different material → returns the correctly-paired name.""" + await _seed_black_collision(async_client) + + matte = await async_client.get( + "/api/v1/inventory/colors/by-material", params={"hex": "#000000", "material": "PLA Matte"} + ) + assert matte.status_code == 200, matte.text + assert matte.json() == {"color_name": "Charcoal"} + + basic = await async_client.get( + "/api/v1/inventory/colors/by-material", params={"hex": "#000000", "material": "PLA Basic"} + ) + assert basic.status_code == 200, basic.text + assert basic.json() == {"color_name": "Black"} + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_by_material_falls_back_to_first_when_material_unknown(async_client: AsyncClient): + """Unknown / unsupplied material → priority-order fallback, same as + ``/colors/map`` so existing flat-map callers don't regress.""" + await _seed_black_collision(async_client) + + # Unknown material → first Bambu Lab entry wins (matches /map's priority). + unknown = await async_client.get( + "/api/v1/inventory/colors/by-material", params={"hex": "#000000", "material": "PLA-Nope"} + ) + assert unknown.status_code == 200, unknown.text + assert unknown.json()["color_name"] in {"Charcoal", "Black"} + + # No material at all → same fallback. + nomat = await async_client.get("/api/v1/inventory/colors/by-material", params={"hex": "#000000"}) + assert nomat.status_code == 200, nomat.text + assert nomat.json()["color_name"] in {"Charcoal", "Black"} + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_by_material_returns_null_when_hex_missing(async_client: AsyncClient): + """Hex not present in the catalog → color_name=None (do NOT 404).""" + response = await async_client.get( + "/api/v1/inventory/colors/by-material", + params={"hex": "#abcdef", "material": "PLA Matte"}, + ) + assert response.status_code == 200, response.text + assert response.json() == {"color_name": None} + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_by_material_case_insensitive_on_both_inputs(async_client: AsyncClient): + """Lookup must tolerate mixed-case hex (legacy imports stored ``#B39B84``- + style upper-case) and material (frontend derives material from sub-brand + names whose casing isn't pinned). The endpoint uses ``func.lower`` on + ``hex_color`` and lower-cases ``material`` before equality, so both + directions of the case mismatch must round-trip.""" + # Seed an upper-case stored hex to exercise the lower-cased comparison. + seed = await async_client.post( + "/api/v1/inventory/colors", + json={ + "manufacturer": "Bambu Lab", + "color_name": "Iridium Gold Metallic", + "hex_color": "#B39B84", # stored upper-case + "material": "PLA Metal", + }, + ) + assert seed.status_code == 200, seed.text + + # Query the upper-case hex with lower-case input — must still match. + lower_query = await async_client.get( + "/api/v1/inventory/colors/by-material", + params={"hex": "#b39b84", "material": "PLA Metal"}, + ) + assert lower_query.status_code == 200 + assert lower_query.json() == {"color_name": "Iridium Gold Metallic"} + + # Material is matched case-insensitively too. + await _seed_black_collision(async_client) + mixed_mat = await async_client.get( + "/api/v1/inventory/colors/by-material", + params={"hex": "#000000", "material": "pla matte"}, + ) + assert mixed_mat.json() == {"color_name": "Charcoal"} + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_by_material_rejects_short_hex(async_client: AsyncClient): + """Invalid hex (< 6 chars after stripping '#') → color_name=None, no crash.""" + response = await async_client.get( + "/api/v1/inventory/colors/by-material", + params={"hex": "#abc", "material": "PLA Matte"}, + ) + assert response.status_code == 200 + assert response.json() == {"color_name": None} diff --git a/frontend/src/__tests__/components/FilamentMapping.test.tsx b/frontend/src/__tests__/components/FilamentMapping.test.tsx index dd8c97c0d..dd4d2cfb0 100644 --- a/frontend/src/__tests__/components/FilamentMapping.test.tsx +++ b/frontend/src/__tests__/components/FilamentMapping.test.tsx @@ -227,4 +227,66 @@ describe('FilamentMapping — FTS routing', () => { expect(screen.queryByText(/Bambu PETG/)).not.toBeInTheDocument(); }); }); + + it('renders sub-brand + material-disambiguated colour on the required side (#1718)', async () => { + // Same fix as FilamentOverride: required-side label was rendering the + // raw 3MF type ("PLA") and the generic getColorName bucket ("Black"). + // After the shared useFilamentLabels hook it must now resolve + // tray_info_idx → "Bambu PLA Matte" and the material-disambiguated + // colour catalogue → "Charcoal" — the Specific-Printer panel matched + // the Any-Model panel that was already correct. + server.use( + http.get( + '/api/v1/printers/:id/status', + () => + HttpResponse.json( + createStatus({ + fila_switch: null, + ams_extruder_map: { '0': 1 }, + }), + ), + ), + http.get('/api/v1/cloud/builtin-filaments', () => + HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]), + ), + http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})), + http.get('/api/v1/inventory/colors/by-material', ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get('hex') === '#000000' && url.searchParams.get('material') === 'PLA Matte') { + return HttpResponse.json({ color_name: 'Charcoal' }); + } + return HttpResponse.json({ color_name: null }); + }), + ); + + const charcoalReqs = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, nozzle_id: 1, tray_info_idx: 'GFA01' }, + ], + }; + + render( + {}} + currencySymbol="$" + defaultCostPerKg={0} + defaultExpanded + />, + ); + + // Required-side type text picks up the resolved sub-brand. + await waitFor(() => { + expect(screen.getByText(/Bambu PLA Matte/)).toBeInTheDocument(); + }); + // The swatch tooltip carries the disambiguated "Charcoal" instead of + // the generic "Black" bucket; check the title attr on the colour + // circle's parent span. + await waitFor(() => { + const swatch = screen.getByTitle(/Required: Bambu PLA Matte - Charcoal/); + expect(swatch).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/__tests__/components/FilamentOverride.test.tsx b/frontend/src/__tests__/components/FilamentOverride.test.tsx index 183a697a4..6481cf772 100644 --- a/frontend/src/__tests__/components/FilamentOverride.test.tsx +++ b/frontend/src/__tests__/components/FilamentOverride.test.tsx @@ -6,8 +6,10 @@ */ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { screen, fireEvent, cleanup } from '@testing-library/react'; +import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; import { render } from '../utils'; +import { server } from '../mocks/server'; import { FilamentOverride } from '../../components/PrintModal/FilamentOverride'; import type { FilamentReqsData } from '../../components/PrintModal/types'; @@ -337,4 +339,241 @@ describe('FilamentOverride', () => { expect(mockOnChange).toHaveBeenCalledWith({}); }); }); + + describe('original-label SKU resolution (#1718)', () => { + it('uses the builtin filament name when tray_info_idx maps to a known SKU', async () => { + // Stamped by Bambu Studio when slicing with PLA Matte Charcoal: 3MF + // carries type=PLA + the GFA01 SKU. Without resolution the label + // collapses to "PLA (Black)" which was Sam's bug. + server.use( + http.get('/api/v1/cloud/builtin-filaments', () => + HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]), + ), + http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})), + ); + + const reqs: FilamentReqsData = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#1A1A1A', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' }, + ], + }; + + render( + , + ); + + // Wait for the queries to resolve and the resolved label to land in + // the dropdown's "original" placeholder option. The tooltip on the + // color swatch carries the same text, so we scope to the option to + // avoid the multi-match. + await waitFor(() => { + const select = screen.getByRole('combobox'); + const placeholder = select.querySelector('option[value=""]'); + expect(placeholder?.textContent).toMatch(/Bambu PLA Matte/); + }); + }); + + it('prefers the cloud user-preset name over the builtin entry for the same id', async () => { + // Cloud user-preset names are more specific than the builtin fallback — + // e.g. a user has renamed GFA00 to "My House PLA". + server.use( + http.get('/api/v1/cloud/builtin-filaments', () => + HttpResponse.json([{ filament_id: 'GFA00', name: 'Bambu PLA Basic' }]), + ), + http.get('/api/v1/cloud/filament-id-map', () => + HttpResponse.json({ GFA00: 'My House PLA' }), + ), + ); + + const reqs: FilamentReqsData = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA00' }, + ], + }; + + render( + , + ); + + await waitFor(() => { + const select = screen.getByRole('combobox'); + const placeholder = select.querySelector('option[value=""]'); + expect(placeholder?.textContent).toMatch(/My House PLA/); + }); + // The builtin fallback must NOT bleed through anywhere — neither the + // placeholder option nor the tooltip. + expect(screen.queryByText(/Bambu PLA Basic/)).not.toBeInTheDocument(); + }); + + it('uses the material-disambiguated catalogue color name (PLA Matte Charcoal — #1718 round 2)', async () => { + // Sam's exact case: 3MF carries hex #000000 + tray_info_idx GFA01. + // Without material context, /colors/map collapses #000000 to "Black" + // (PLA Basic wins the priority race). The override panel must pass + // the derived material hint "PLA Matte" through to /colors/by-material + // so the user sees "Charcoal" — the actually-sliced color. + server.use( + http.get('/api/v1/cloud/builtin-filaments', () => + HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]), + ), + http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})), + http.get('/api/v1/inventory/colors/by-material', ({ request }) => { + const url = new URL(request.url); + const hex = url.searchParams.get('hex'); + const material = url.searchParams.get('material'); + if (hex === '#000000' && material === 'PLA Matte') { + return HttpResponse.json({ color_name: 'Charcoal' }); + } + return HttpResponse.json({ color_name: null }); + }), + ); + + const reqs: FilamentReqsData = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' }, + ], + }; + + render( + , + ); + + await waitFor(() => { + const select = screen.getByRole('combobox'); + const placeholder = select.querySelector('option[value=""]'); + expect(placeholder?.textContent).toMatch(/Bambu PLA Matte \(Charcoal\)/); + }); + }); + + it('disambiguates per slot when two slots share a hex but differ in material', async () => { + // Regression guard: the per-slot useQueries dispatch must key on + // (hex, material) so a "PLA Matte Charcoal" slot does not adopt the + // "PLA Basic Black" slot's answer. + server.use( + http.get('/api/v1/cloud/builtin-filaments', () => + HttpResponse.json([ + { filament_id: 'GFA00', name: 'Bambu PLA Basic' }, + { filament_id: 'GFA01', name: 'Bambu PLA Matte' }, + ]), + ), + http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})), + http.get('/api/v1/inventory/colors/by-material', ({ request }) => { + const url = new URL(request.url); + const material = url.searchParams.get('material'); + if (material === 'PLA Matte') return HttpResponse.json({ color_name: 'Charcoal' }); + if (material === 'PLA Basic') return HttpResponse.json({ color_name: 'Black' }); + return HttpResponse.json({ color_name: null }); + }), + ); + + const reqs: FilamentReqsData = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' }, + { slot_id: 2, type: 'PLA', color: '#000000', used_grams: 10, used_meters: 3.2, tray_info_idx: 'GFA00' }, + ], + }; + + render( + , + ); + + await waitFor(() => { + const selects = screen.getAllByRole('combobox'); + expect(selects).toHaveLength(2); + expect(selects[0].querySelector('option[value=""]')?.textContent).toMatch(/Bambu PLA Matte \(Charcoal\)/); + expect(selects[1].querySelector('option[value=""]')?.textContent).toMatch(/Bambu PLA Basic \(Black\)/); + }); + }); + + it('falls back to getColorName(hex) when the by-material lookup returns null', async () => { + // Any time the catalogue has no entry for the hex (or the endpoint is + // unreachable), the placeholder must still render — the HSL-bucket + // fallback is strictly better than a blank. + server.use( + http.get('/api/v1/cloud/builtin-filaments', () => + HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]), + ), + http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})), + http.get('/api/v1/inventory/colors/by-material', () => + HttpResponse.json({ color_name: null }), + ), + ); + + const reqs: FilamentReqsData = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' }, + ], + }; + + render( + , + ); + + // Wait for the builtin lookup to land so we know the row mounted; the + // colour fallback to getColorName for #FF0000 produces "Red"-shaped text. + await waitFor(() => { + const select = screen.getByRole('combobox'); + const placeholder = select.querySelector('option[value=""]'); + expect(placeholder?.textContent).toMatch(/Bambu PLA Matte/); + expect(placeholder?.textContent).not.toMatch(/null/); + }); + }); + + it('falls back to the raw type when the SKU is unknown to both maps', async () => { + // Unknown ids must not break rendering — the original "PLA" label is + // still better than a blank. + server.use( + http.get('/api/v1/cloud/builtin-filaments', () => HttpResponse.json([])), + http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})), + ); + + const reqs: FilamentReqsData = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFXXX' }, + ], + }; + + render( + , + ); + + // (25g) is the easiest signal the row mounted at all; once it's there, + // assert the placeholder option carries the raw type. + await waitFor(() => { + expect(screen.getByText('(25g)')).toBeInTheDocument(); + }); + const select = screen.getByRole('combobox'); + const placeholder = select.querySelector('option[value=""]'); + expect(placeholder?.textContent).toMatch(/PLA \(/); + }); + }); }); diff --git a/frontend/src/__tests__/hooks/useFilamentLabels.test.tsx b/frontend/src/__tests__/hooks/useFilamentLabels.test.tsx new file mode 100644 index 000000000..585fe805d --- /dev/null +++ b/frontend/src/__tests__/hooks/useFilamentLabels.test.tsx @@ -0,0 +1,230 @@ +/** + * Tests for the shared filament-label resolution hook (#1718 round 3). + * + * Round 3 extracted the three-query resolution machinery out of + * ``FilamentOverride`` so the printer-mode ``FilamentMapping`` could share + * the same label logic without drift. Both panels are integration-tested + * already, but the hook deserves direct coverage so future edits don't break + * a subtle contract (positional output alignment, fallback chain, query + * dedup when the SKU is unknown). + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { renderHook, waitFor, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { type ReactNode } from 'react'; +import { server } from '../mocks/server'; +import { extractMaterialHint, useFilamentLabels } from '../../components/PrintModal/useFilamentLabels'; + +function makeWrapper() { + // Fresh QueryClient per renderHook so cached data from one test doesn't + // bleed into the next — the hook keys queries on (hex, materialHint), so a + // stale "PLA Matte → Charcoal" cache entry would silently mask a misrouted + // request in a later test. + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +afterEach(() => { + cleanup(); +}); + +describe('extractMaterialHint', () => { + it('strips the leading brand token from multi-word names', () => { + expect(extractMaterialHint('Bambu PLA Matte')).toBe('PLA Matte'); + expect(extractMaterialHint('PolyLite ABS')).toBe('ABS'); + expect(extractMaterialHint('Bambu PLA-CF')).toBe('PLA-CF'); + }); + + it('returns single-word names unchanged so "PLA" stays "PLA"', () => { + // The catalog's ``material`` column has plain "PLA" entries; passing the + // single token through means the by-material lookup can still match. + expect(extractMaterialHint('PLA')).toBe('PLA'); + expect(extractMaterialHint('PETG-HF')).toBe('PETG-HF'); + }); + + it('collapses interior whitespace and trims edges', () => { + expect(extractMaterialHint(' Bambu PLA Matte ')).toBe('PLA Matte'); + }); + + it('returns "" when the input is blank', () => { + // Empty material is the same priority-fallback case as omitting the param. + expect(extractMaterialHint('')).toBe(''); + expect(extractMaterialHint(' ')).toBe(''); + }); +}); + +describe('useFilamentLabels', () => { + it('returns [] for undefined or empty inputs', () => { + const { result, rerender } = renderHook( + ({ reqs }: { reqs: undefined | Array<{ type: string; color: string }> }) => + useFilamentLabels(reqs), + { wrapper: makeWrapper(), initialProps: { reqs: undefined } }, + ); + expect(result.current).toEqual([]); + + rerender({ reqs: [] }); + expect(result.current).toEqual([]); + }); + + it('resolves tray_info_idx → sub-brand via the builtin map', async () => { + server.use( + http.get('/api/v1/cloud/builtin-filaments', () => + HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]), + ), + http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})), + http.get('/api/v1/inventory/colors/by-material', () => + HttpResponse.json({ color_name: 'Charcoal' }), + ), + ); + + const { result } = renderHook( + () => useFilamentLabels([{ type: 'PLA', color: '#000000', tray_info_idx: 'GFA01' }]), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => { + expect(result.current[0]?.resolvedName).toBe('Bambu PLA Matte'); + expect(result.current[0]?.colorLabel).toBe('Charcoal'); + }); + }); + + it('prefers the cloud user-preset name over the builtin entry for the same id', async () => { + // Round 2 contract: same id in both maps → cloud wins because the user- + // authored name is the more specific label. + server.use( + http.get('/api/v1/cloud/builtin-filaments', () => + HttpResponse.json([{ filament_id: 'GFA00', name: 'Bambu PLA Basic' }]), + ), + http.get('/api/v1/cloud/filament-id-map', () => + HttpResponse.json({ GFA00: 'My House PLA' }), + ), + http.get('/api/v1/inventory/colors/by-material', () => + HttpResponse.json({ color_name: null }), + ), + ); + + const { result } = renderHook( + () => useFilamentLabels([{ type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00' }]), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => { + expect(result.current[0]?.resolvedName).toBe('My House PLA'); + }); + }); + + it('falls back to req.type when the SKU is unknown to both maps', async () => { + server.use( + http.get('/api/v1/cloud/builtin-filaments', () => HttpResponse.json([])), + http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})), + http.get('/api/v1/inventory/colors/by-material', () => + HttpResponse.json({ color_name: null }), + ), + ); + + const { result } = renderHook( + () => useFilamentLabels([{ type: 'PETG-HF', color: '#00FF00', tray_info_idx: 'GFXXX' }]), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => { + expect(result.current[0]?.resolvedName).toBe('PETG-HF'); + }); + }); + + it('falls back colorLabel to getColorName(hex) when the by-material lookup returns null', async () => { + server.use( + http.get('/api/v1/cloud/builtin-filaments', () => + HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]), + ), + http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})), + http.get('/api/v1/inventory/colors/by-material', () => + HttpResponse.json({ color_name: null }), + ), + ); + + const { result } = renderHook( + () => useFilamentLabels([{ type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA01' }]), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => { + // Anything non-empty from getColorName is fine; the critical contract + // is "never returns the empty string / null in the colorLabel". + expect(result.current[0]?.colorLabel).toBeTruthy(); + expect(result.current[0]?.colorLabel).not.toBe('null'); + }); + }); + + it('keeps positional alignment across slots with different (hex, material) tuples', async () => { + // Regression guard for the position-indexed output contract: labels[i] + // MUST correspond to reqs[i]. If useQueries answered out-of-order or + // dedup'd same-hex slots, FilamentMapping would render a PLA Matte + // Charcoal slot as PLA Basic Black (and vice versa). + server.use( + http.get('/api/v1/cloud/builtin-filaments', () => + HttpResponse.json([ + { filament_id: 'GFA00', name: 'Bambu PLA Basic' }, + { filament_id: 'GFA01', name: 'Bambu PLA Matte' }, + ]), + ), + http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})), + http.get('/api/v1/inventory/colors/by-material', ({ request }) => { + const material = new URL(request.url).searchParams.get('material'); + if (material === 'PLA Matte') return HttpResponse.json({ color_name: 'Charcoal' }); + if (material === 'PLA Basic') return HttpResponse.json({ color_name: 'Black' }); + return HttpResponse.json({ color_name: null }); + }), + ); + + const { result } = renderHook( + () => + useFilamentLabels([ + { type: 'PLA', color: '#000000', tray_info_idx: 'GFA01' }, // PLA Matte + { type: 'PLA', color: '#000000', tray_info_idx: 'GFA00' }, // PLA Basic + ]), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => { + expect(result.current[0]?.resolvedName).toBe('Bambu PLA Matte'); + expect(result.current[0]?.colorLabel).toBe('Charcoal'); + expect(result.current[1]?.resolvedName).toBe('Bambu PLA Basic'); + expect(result.current[1]?.colorLabel).toBe('Black'); + }); + }); + + it('skips the by-material query when the slot has no hex (enabled: !!color)', async () => { + // Defensive: 3MFs occasionally leave the color attribute blank. The + // query is gated on truthy color so we don't fire a request that we + // know can't disambiguate anything. + let byMaterialCalls = 0; + server.use( + http.get('/api/v1/cloud/builtin-filaments', () => + HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]), + ), + http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})), + http.get('/api/v1/inventory/colors/by-material', () => { + byMaterialCalls += 1; + return HttpResponse.json({ color_name: null }); + }), + ); + + const { result } = renderHook( + () => useFilamentLabels([{ type: 'PLA', color: '', tray_info_idx: 'GFA01' }]), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => { + // The sub-brand half resolves from the builtin map even without a hex. + expect(result.current[0]?.resolvedName).toBe('Bambu PLA Matte'); + }); + expect(byMaterialCalls).toBe(0); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1d7dd4f53..136d60b9a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -4529,6 +4529,20 @@ export const api = { getFilamentIdMap: () => request>('/cloud/filament-id-map'), + /** Material-disambiguated hex→name lookup. Same hex can map to different + * catalog names depending on material (e.g. #000000 is "Charcoal" in PLA + * Matte but "Black" in PLA Basic). The flat ``/inventory/colors/map`` + * collapses these to the first hit; this endpoint preserves the material + * context. Returns ``{color_name: null}`` when the hex isn't in the + * catalog at all. #1718. */ + getColorByMaterial: (hex: string, material?: string) => { + const params = new URLSearchParams({ hex }); + if (material) params.set('material', material); + return request<{ color_name: string | null }>( + `/inventory/colors/by-material?${params.toString()}`, + ); + }, + // MakerWorld URL-paste import flow. getMakerworldStatus: () => request('/makerworld/status'), diff --git a/frontend/src/components/PrintModal/FilamentMapping.tsx b/frontend/src/components/PrintModal/FilamentMapping.tsx index 7d71137d5..fc4251cf3 100644 --- a/frontend/src/components/PrintModal/FilamentMapping.tsx +++ b/frontend/src/components/PrintModal/FilamentMapping.tsx @@ -6,6 +6,7 @@ import { api } from '../../api/client'; import { useFilamentMapping } from '../../hooks/useFilamentMapping'; import { getGlobalTrayId } from '../../utils/amsHelpers'; import { getColorName } from '../../utils/colors'; +import { useFilamentLabels } from './useFilamentLabels'; import type { FilamentMappingProps } from './types'; /** @@ -44,6 +45,13 @@ export function FilamentMapping({ const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } = useFilamentMapping(filamentReqs, printerStatus, manualMappings); + // Per-slot sub-brand + material-disambiguated colour labels (#1718). Same + // shared hook the model-mode FilamentOverride uses so both panels render + // the same sliced-3MF identity. Falls back to the raw type / generic + // colour bucket when the SKU is unknown or the by-material lookup hasn't + // resolved — never blanks out the required row. + const filamentLabels = useFilamentLabels(filamentReqs?.filaments); + const trayCostMap = useMemo(() => { const map = new Map(); for (const assignment of assignments || []) { @@ -192,6 +200,11 @@ export function FilamentMapping({ // scheduler honors the flag in both modes; only the UI was missing. const slotId = item.slot_id ?? 0; const canForceMatch = slotId > 0 && onForceColorMatchChange != null; + // #1718: same sub-brand + colour resolution as FilamentOverride. + // Indexing is safe because ``useFilamentLabels`` mirrors the input + // array shape; defensive fallback covers the empty-reqs render + // path that shouldn't reach here anyway. + const { resolvedName, colorLabel } = filamentLabels[idx] ?? { resolvedName: item.type, colorLabel: getColorName(item.color) }; return (
{/* Required color */} - + {/* Required type + grams + nozzle badge */} @@ -212,7 +225,7 @@ export function FilamentMapping({ {item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')} )} - {item.type} ({item.used_grams}g) + {resolvedName} ({item.used_grams}g) {/* Arrow */} → diff --git a/frontend/src/components/PrintModal/FilamentOverride.tsx b/frontend/src/components/PrintModal/FilamentOverride.tsx index 706311981..5632c8ad5 100644 --- a/frontend/src/components/PrintModal/FilamentOverride.tsx +++ b/frontend/src/components/PrintModal/FilamentOverride.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Circle, RotateCcw, Palette } from 'lucide-react'; import { getColorName } from '../../utils/colors'; import { canonicalFilamentType } from '../../utils/amsHelpers'; +import { useFilamentLabels } from './useFilamentLabels'; import type { FilamentReqsData } from './types'; interface FilamentOverrideProps { @@ -32,6 +33,11 @@ export function FilamentOverride({ }: FilamentOverrideProps) { const { t } = useTranslation(); + // Per-slot sub-brand + material-disambiguated colour labels (#1718). The + // shared hook fronts the three queries that power the resolution so this + // component and ``FilamentMapping`` cannot drift apart on label content. + const labels = useFilamentLabels(filamentReqs?.filaments); + // Index available filaments by canonical type for per-slot filtering. // Types in the same equivalence group (e.g. PA-CF / PA12-CF / PAHT-CF) share one bucket. const filamentsByType = useMemo(() => { @@ -69,7 +75,7 @@ export function FilamentOverride({

{t('printModal.filamentOverrideHint')}

- {filaments.map((req) => { + {filaments.map((req, slotIdx) => { const override = overrides[req.slot_id]; const isOverridden = !!override; // Only show filaments of the same type AND compatible nozzle/extruder @@ -81,6 +87,14 @@ export function FilamentOverride({ ? sameType.filter((f) => f.extruder_id == null || f.extruder_id === req.nozzle_id) : sameType; + // #1718: sub-brand resolved from the 3MF's tray_info_idx via the + // builtin / cloud-id maps, plus the material-disambiguated catalogue + // colour for the hex. Both fall back gracefully (resolvedName → + // req.type when the SKU is unknown; colorLabel → getColorName(hex) + // when the by-material lookup hasn't resolved yet, returned null, + // or errored) so a slow query never blanks out the row. + const { resolvedName, colorLabel } = labels[slotIdx] ?? { resolvedName: req.type, colorLabel: getColorName(req.color) }; + return (
{/* Original color swatch */} - + {/* Original type + grams */} - {req.type} ({req.used_grams}g) + {resolvedName} ({req.used_grams}g) {/* Arrow */} → @@ -109,7 +123,7 @@ export function FilamentOverride({ }`} > {compatible.map((f, idx) => (