| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441 |
- /**
- * Tests for the FilamentMapping component's Filament Track Switch (FTS)
- * handling (#1162).
- *
- * The FTS accessory routes any AMS slot to either extruder dynamically. When
- * present (printer status `fila_switch.installed === true`), the per-extruder
- * dropdown filter must be suppressed — otherwise the print modal's filament
- * dropdown is empty since the printer reports info bits 8-11 = 0xE
- * (uninitialized) for every AMS unit.
- */
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
- import { screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
- import { http, HttpResponse } from 'msw';
- import { render } from '../utils';
- import { server } from '../mocks/server';
- import { FilamentMapping } from '../../components/PrintModal/FilamentMapping';
- import type { PrinterStatus } from '../../api/client';
- const mockFilamentReqs = {
- filaments: [
- // Required filament asks for the LEFT extruder (nozzle_id=1).
- // Without FTS the dropdown filter would only allow slots with extruderId=1.
- { slot_id: 1, type: 'PETG', color: '#00FF00', used_grams: 25, used_meters: 8.5, nozzle_id: 1 },
- ],
- };
- function createStatus(overrides: Partial<PrinterStatus>): PrinterStatus {
- return {
- id: 1,
- name: 'X2D',
- connected: true,
- state: 'IDLE',
- ams: [
- {
- id: 0,
- // Realistic FTS-installed bundle: AMS reports extruder bits 8-11 = 0xE,
- // so ams_extruder_map ends up empty.
- tray: [
- { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'Bambu PLA' },
- { id: 1, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFG00', tray_sub_brands: 'Bambu PETG' },
- ],
- },
- ],
- vt_tray: [],
- ams_extruder_map: {},
- ...overrides,
- } as PrinterStatus;
- }
- afterEach(() => {
- cleanup();
- vi.clearAllMocks();
- });
- describe('FilamentMapping — FTS routing', () => {
- beforeEach(() => {
- server.use(
- http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])),
- );
- });
- it('shows all loaded slots in the dropdown when FTS is installed', async () => {
- server.use(
- http.get(
- '/api/v1/printers/:id/status',
- () =>
- HttpResponse.json(
- createStatus({
- fila_switch: {
- installed: true,
- in_slots: [-1, 1],
- out_extruders: [0, 1],
- stat: 0,
- info: 2,
- },
- ams_switch_inlet: { '0': 'A' },
- }),
- ),
- ),
- );
- render(
- <FilamentMapping
- printerId={1}
- filamentReqs={mockFilamentReqs}
- manualMappings={{}}
- onManualMappingChange={() => {}}
- currencySymbol="$"
- defaultCostPerKg={0}
- defaultExpanded
- />,
- );
- // Both PLA and PETG slots must appear in the dropdown despite ams_extruder_map
- // being empty and the requirement asking for nozzle 1. Without the FTS guard
- // the dropdown would render only the "-- Select slot --" placeholder.
- await waitFor(() => {
- expect(screen.getByText(/Bambu PLA/)).toBeInTheDocument();
- });
- expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
- // Each slot is badged for the switch INLET its AMS is plumbed into, using
- // the same L-for-In-A lettering as the printer card. Both slots are in
- // AMS 0, which is on In-A.
- expect(screen.getByText(/Bambu PETG/).textContent).toMatch(/\[L\]/);
- expect(screen.getByText(/Bambu PLA/).textContent).toMatch(/\[L\]/);
- });
- it('does not badge slots whose AMS has no inlet binding yet', async () => {
- // A switch that has been fitted but not set up on the printer's Manual AMS
- // Setup screen reports no binding. Better a missing badge than a made-up one.
- server.use(
- http.get(
- '/api/v1/printers/:id/status',
- () =>
- HttpResponse.json(
- createStatus({
- fila_switch: { installed: true, in_slots: [-1, 1], out_extruders: [0, 1], stat: 0, info: 2 },
- ams_switch_inlet: {},
- }),
- ),
- ),
- );
- render(
- <FilamentMapping
- printerId={1}
- filamentReqs={mockFilamentReqs}
- manualMappings={{}}
- onManualMappingChange={() => {}}
- currencySymbol="$"
- defaultCostPerKg={0}
- defaultExpanded
- />,
- );
- await waitFor(() => {
- expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
- });
- expect(screen.getByText(/Bambu PETG/).textContent).not.toMatch(/\[[LR]\]/);
- });
- it('renders the per-slot force-color-match checkbox in printer mode (#1717)', async () => {
- // Specific-printer assignment used to render FilamentMapping with no
- // force-color-match UI even though the dispatcher honours the flag. Pin
- // that the checkbox is now mounted and bubbles toggle events up.
- server.use(
- http.get(
- '/api/v1/printers/:id/status',
- () =>
- HttpResponse.json(
- createStatus({
- fila_switch: null,
- ams_extruder_map: { '0': 1 }, // AMS 0 → left nozzle, matching the requirement
- }),
- ),
- ),
- );
- const onForceColorMatchChange = vi.fn();
- render(
- <FilamentMapping
- printerId={1}
- filamentReqs={mockFilamentReqs}
- manualMappings={{}}
- onManualMappingChange={() => {}}
- currencySymbol="$"
- defaultCostPerKg={0}
- defaultExpanded
- forceColorMatch={{}}
- onForceColorMatchChange={onForceColorMatchChange}
- />,
- );
- const checkbox = await waitFor(() => {
- const cb = screen.getByLabelText(/Force color match/i) as HTMLInputElement;
- expect(cb).toBeInTheDocument();
- return cb;
- });
- expect(checkbox.checked).toBe(false);
- fireEvent.click(checkbox);
- expect(onForceColorMatchChange).toHaveBeenCalledTimes(1);
- expect(onForceColorMatchChange).toHaveBeenCalledWith(1, true);
- });
- it('omits the force-color-match checkbox when no handler is provided', async () => {
- // The checkbox is only meaningful when the caller is wired to persist the
- // toggle; absent a handler we must not render dead UI.
- server.use(
- http.get(
- '/api/v1/printers/:id/status',
- () =>
- HttpResponse.json(
- createStatus({
- fila_switch: null,
- ams_extruder_map: { '0': 1 },
- }),
- ),
- ),
- );
- render(
- <FilamentMapping
- printerId={1}
- filamentReqs={mockFilamentReqs}
- manualMappings={{}}
- onManualMappingChange={() => {}}
- currencySymbol="$"
- defaultCostPerKg={0}
- defaultExpanded
- />,
- );
- // Wait for the panel to finish mounting (Re-read button only renders once
- // printer status has loaded and the expanded view is open) before asserting
- // the checkbox is absent — otherwise the queryByLabelText would pass
- // trivially during the loading window.
- await waitFor(() => {
- expect(screen.getByText(/Re-read/i)).toBeInTheDocument();
- });
- expect(screen.queryByLabelText(/Force color match/i)).not.toBeInTheDocument();
- });
- it('offers cross-extruder slots in the dropdown without FTS (#1722)', async () => {
- // Before #1722 the dropdown filtered to only slots whose extruder matched
- // the filament's slicer-assigned nozzle. On a dual-nozzle printer with one
- // AMS per side, that prevented the user from picking a slot on the OTHER
- // extruder even when they'd intentionally loaded the required filament
- // there. The fix: trust the user, show every loaded slot regardless of
- // which extruder it's wired to. The L/R badge on the filament row still
- // tells the user what the slicer planned; the printer firmware accepts
- // or rejects the cross-extruder ams_mapping at start-print.
- server.use(
- http.get(
- '/api/v1/printers/:id/status',
- () =>
- HttpResponse.json(
- createStatus({
- fila_switch: null,
- ams_extruder_map: { '0': 0 }, // AMS 0 → right nozzle (extruder 0)
- }),
- ),
- ),
- );
- render(
- <FilamentMapping
- printerId={1}
- filamentReqs={mockFilamentReqs}
- manualMappings={{}}
- onManualMappingChange={() => {}}
- currencySymbol="$"
- defaultCostPerKg={0}
- defaultExpanded
- />,
- );
- // Required nozzle is 1 (LEFT) and AMS 0 is wired to extruder 0 (RIGHT).
- // Both slots must STILL appear so the user can pick them — explicitly the
- // cross-extruder scenario the #1722 fix unblocks.
- await waitFor(() => {
- expect(screen.getByText(/Bambu PLA/)).toBeInTheDocument();
- });
- expect(screen.getByText(/Bambu PETG/)).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(
- <FilamentMapping
- printerId={1}
- filamentReqs={charcoalReqs}
- manualMappings={{}}
- onManualMappingChange={() => {}}
- 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();
- });
- });
- it('pins the gram usage so a long name cannot clip it (#2669)', async () => {
- // Long resolved name + gram usage. The name must be the truncating
- // element; the "(25g)" must sit in its own non-truncating, shrink-0 span
- // so it stays visible on narrow/mobile widths.
- server.use(
- http.get('/api/v1/printers/:id/status', () => HttpResponse.json(createStatus({}))),
- http.get('/api/v1/cloud/builtin-filaments', () =>
- HttpResponse.json([{ filament_id: 'GFA01', name: 'Polymaker 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 })),
- );
- render(
- <FilamentMapping
- printerId={1}
- filamentReqs={{
- filaments: [
- { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, nozzle_id: 1, tray_info_idx: 'GFA01' },
- ],
- }}
- manualMappings={{}}
- onManualMappingChange={() => {}}
- currencySymbol="$"
- defaultCostPerKg={0}
- defaultExpanded
- />,
- );
- const grams = await screen.findByText('(25g)');
- // The gram usage never truncates and never shrinks away.
- expect(grams.className).toContain('shrink-0');
- expect(grams.className).not.toContain('truncate');
- // The name is the element that truncates instead.
- const name = await screen.findByText('Polymaker PLA Matte');
- expect(name.className).toContain('truncate');
- // Name and grams are separate siblings, so the name shrinking can't take
- // the grams with it.
- expect(name).not.toBe(grams);
- expect(grams.parentElement).toBe(name.parentElement);
- });
- });
- describe('FilamentMapping — FTS same-inlet advisory', () => {
- // Bambu's own guidance: a change between two filaments on the SAME switch
- // inlet has to retract the outgoing one all the way back to its AMS before
- // the incoming one can be fed up the shared tube. A change across the two
- // inlets only retracts as far as the switch. When every filament a job needs
- // sits behind one inlet, every change in that job takes the slow path — the
- // one arrangement worth telling the operator about, since moving a single
- // spool fixes it.
- const twoFilamentReqs = {
- filaments: [
- { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 20, used_meters: 7, nozzle_id: 0 },
- { slot_id: 2, type: 'PETG', color: '#00FF00', used_grams: 25, used_meters: 8.5, nozzle_id: 1 },
- ],
- };
- // Two AMS units, one filament matching in each, so the pick is unambiguous.
- const twoAmsStatus = (amsSwitchInlet: Record<string, 'A' | 'B'>): Partial<PrinterStatus> => ({
- ams: [
- { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'Bambu PLA' }] },
- { id: 1, tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFG00', tray_sub_brands: 'Bambu PETG' }] },
- ],
- fila_switch: { installed: true, in_slots: [-1, -1], out_extruders: [1, 0], stat: 0, info: 0 },
- ams_switch_inlet: amsSwitchInlet,
- } as Partial<PrinterStatus>);
- const renderWith = (amsSwitchInlet: Record<string, 'A' | 'B'>) => {
- server.use(
- http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])),
- http.get('/api/v1/printers/:id/status', () => HttpResponse.json(createStatus(twoAmsStatus(amsSwitchInlet)))),
- );
- render(
- <FilamentMapping
- printerId={1}
- filamentReqs={twoFilamentReqs}
- manualMappings={{}}
- onManualMappingChange={() => {}}
- currencySymbol="$"
- defaultCostPerKg={0}
- defaultExpanded
- />,
- );
- };
- it('warns when every filament for the print is behind one inlet', async () => {
- renderWith({ '0': 'A', '1': 'A' });
- // Names the inlet, so the operator knows which spool to move.
- expect(await screen.findByText(/on Filament Track Switch IN-A\./)).toBeInTheDocument();
- expect(screen.getByText(/same inlet is slower/i)).toBeInTheDocument();
- });
- it('stays quiet when the filaments are split across both inlets', async () => {
- renderWith({ '0': 'A', '1': 'B' });
- await waitFor(() => {
- expect(screen.getAllByText(/Bambu PETG/).length).toBeGreaterThan(0);
- });
- expect(screen.queryByText(/same inlet is slower/i)).not.toBeInTheDocument();
- });
- it('stays quiet when the bindings are not known', async () => {
- // No advisory can be justified without knowing where the spools actually are.
- renderWith({});
- await waitFor(() => {
- expect(screen.getAllByText(/Bambu PETG/).length).toBeGreaterThan(0);
- });
- expect(screen.queryByText(/same inlet is slower/i)).not.toBeInTheDocument();
- });
- });
|