| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487 |
- import { describe, it, expect, vi, beforeEach } from 'vitest';
- import { screen, waitFor } from '@testing-library/react';
- import { render } from '../utils';
- import { AssignSpoolModal } from '../../components/AssignSpoolModal';
- import { api } from '../../api/client';
- vi.mock('../../api/client', () => ({
- api: {
- getSpools: vi.fn(),
- getAssignments: vi.fn(),
- assignSpool: vi.fn(),
- assignSpoolmanSlot: vi.fn(),
- getSpoolmanInventorySpools: vi.fn(),
- getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]),
- getSettings: vi.fn().mockResolvedValue({}),
- getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
- refreshPrinterStatus: vi.fn().mockResolvedValue({ status: 'ok' }),
- },
- }));
- const mockShowToast = vi.fn();
- vi.mock('../../contexts/ToastContext', async (importOriginal) => {
- const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
- return {
- ...actual,
- useToast: () => ({ showToast: mockShowToast }),
- };
- });
- const defaultProps = {
- isOpen: true,
- onClose: vi.fn(),
- printerId: 1,
- amsId: 0,
- trayId: 0,
- trayInfo: { type: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' },
- };
- const manualSpool = {
- id: 1,
- material: 'PLA',
- subtype: 'Basic',
- brand: 'Polymaker',
- color_name: 'Red',
- rgba: 'FF0000FF',
- label_weight: 1000,
- weight_used: 0,
- tag_uid: null,
- tray_uuid: null,
- slicer_filament_name: 'PLA',
- };
- const blSpool = {
- id: 2,
- material: 'PLA',
- subtype: 'Basic',
- brand: 'Bambu',
- color_name: 'Jade White',
- rgba: 'FFFFFFFE',
- label_weight: 1000,
- weight_used: 50,
- tag_uid: '05CC1E0F00000100',
- tray_uuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
- slicer_filament_name: 'PLA',
- };
- const anotherManualSpool = {
- id: 3,
- material: 'PLA',
- subtype: 'HF',
- brand: 'Overture',
- color_name: 'Black',
- rgba: '000000FF',
- label_weight: 1000,
- weight_used: 200,
- tag_uid: null,
- tray_uuid: null,
- slicer_filament_name: 'PLA',
- };
- describe('AssignSpoolModal', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool, blSpool, anotherManualSpool]);
- (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([]);
- });
- it('renders nothing when closed', () => {
- render(<AssignSpoolModal {...defaultProps} isOpen={false} />);
- expect(screen.queryByText('Assign Spool')).not.toBeInTheDocument();
- });
- // Inverted from the original "filters out BL spools" expectation in #1133.
- // Bambu Lab spools (tag_uid + tray_uuid populated by SpoolBuddy NFC scan or
- // auto-creation) used to be hidden from this picker, blocking the workflow
- // where a user has a BL spool in inventory but doesn't want to scan it via
- // SpoolBuddy each time and just wants to pick it from the list. The picker
- // now lists every spool that isn't already assigned to another slot.
- it('lists Bambu Lab spools (with tag_uid/tray_uuid) alongside manual ones (#1133)', async () => {
- render(<AssignSpoolModal {...defaultProps} />);
- await waitFor(() => {
- expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
- });
- expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
- expect(screen.getByText(/Overture/)).toBeInTheDocument();
- // The previously-excluded BL spool is now visible.
- expect(screen.getByText(/Jade White/)).toBeInTheDocument();
- });
- it('filters out spools already assigned to other slots', async () => {
- (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
- { id: 1, spool_id: 3, printer_id: 1, ams_id: 0, tray_id: 1 }, // spool 3 assigned to different slot
- ]);
- render(<AssignSpoolModal {...defaultProps} />);
- await waitFor(() => {
- expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
- });
- // Spool 1 (not assigned) should be visible
- expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
- // Spool 3 (assigned to another slot) should NOT be visible
- expect(screen.queryByText(/Overture/)).not.toBeInTheDocument();
- });
- it('keeps spool visible if assigned to the current slot', async () => {
- (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
- { id: 1, spool_id: 1, printer_id: 1, ams_id: 0, tray_id: 0 }, // spool 1 assigned to THIS slot
- ]);
- render(<AssignSpoolModal {...defaultProps} />);
- await waitFor(() => {
- expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
- });
- // Spool 1 (assigned to current slot) should still be visible for re-assignment
- expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
- });
- // Empty-state premise reworked for #1133: BL spools no longer trigger
- // the empty state by virtue of being BL, so we exercise the only
- // remaining trigger — every spool already taken by another slot.
- it('shows noAvailableSpools message when every spool is already assigned elsewhere', async () => {
- (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
- (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
- // manualSpool (id=1) is taken by a different (printer/ams/tray) tuple,
- // so it must be filtered out of THIS slot's picker.
- { id: 99, spool_id: 1, printer_id: 1, ams_id: 0, tray_id: 1 },
- ]);
- render(<AssignSpoolModal {...defaultProps} />);
- await waitFor(() => {
- expect(screen.getByText(/No spools available/i)).toBeInTheDocument();
- });
- });
- // The toggle's label says "Show all spools" but originally only bypassed
- // material/profile filtering — spools assigned elsewhere stayed hidden
- // even with the toggle on. That made it impossible to recover from the
- // case where MQTT auto-reassignment beat a manual unassign by a few
- // milliseconds, leaving the just-freed spool in another slot's
- // assignment row and out of reach of this picker. With the toggle on,
- // every spool is now listed regardless of where it's currently assigned.
- it('lists spools assigned to other slots when "Show all spools" toggle is enabled', async () => {
- (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool, anotherManualSpool]);
- (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
- // anotherManualSpool (id=3) is taken by a different slot.
- { id: 99, spool_id: 3, printer_id: 1, ams_id: 0, tray_id: 1 },
- ]);
- render(<AssignSpoolModal {...defaultProps} />);
- // Default state: spool 3 is hidden because it's assigned elsewhere.
- await waitFor(() => {
- expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
- });
- expect(screen.queryByText(/Overture/)).not.toBeInTheDocument();
- // Flip the toggle — both spools must now appear, including the one
- // currently assigned to the other slot.
- const toggle = screen.getByLabelText(/show all spools/i);
- toggle.click();
- await waitFor(() => {
- expect(screen.getByText(/Overture/)).toBeInTheDocument();
- });
- expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
- });
- it('lists spool with no slicer profile when material matches the tray (#1047)', async () => {
- const spoolWithoutSlicerProfile = {
- id: 10,
- material: 'PLA',
- subtype: 'Basic',
- brand: 'Devil Design',
- color_name: 'Red',
- rgba: 'FF0000FF',
- label_weight: 1000,
- weight_used: 0,
- tag_uid: null,
- tray_uuid: null,
- slicer_filament_name: null,
- slicer_filament: null,
- };
- (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([spoolWithoutSlicerProfile]);
- render(
- <AssignSpoolModal
- {...defaultProps}
- trayInfo={{
- type: 'PLA',
- material: 'PLA',
- profile: 'Devil Design PLA Basic',
- color: 'FF0000',
- location: 'AMS 1 - Slot 1',
- }}
- />
- );
- await waitFor(() => {
- expect(screen.getByText(/Devil Design/)).toBeInTheDocument();
- });
- });
- it('lists spool with shorter material when tray advertises a qualified variant (#1047)', async () => {
- // Spool.material = "PLA", tray material = "PLA Basic" — partial match in either direction.
- const shortMaterialSpool = {
- id: 11,
- material: 'PLA',
- subtype: 'Basic',
- brand: 'Devil Design',
- color_name: 'Red',
- rgba: 'FF0000FF',
- label_weight: 1000,
- weight_used: 0,
- tag_uid: null,
- tray_uuid: null,
- slicer_filament_name: null,
- slicer_filament: null,
- };
- (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([shortMaterialSpool]);
- render(
- <AssignSpoolModal
- {...defaultProps}
- trayInfo={{
- type: 'PLA Basic',
- material: 'PLA Basic',
- color: 'FF0000',
- location: 'AMS 1 - Slot 1',
- }}
- />
- );
- await waitFor(() => {
- expect(screen.getByText(/Devil Design/)).toBeInTheDocument();
- });
- });
- it('lists spool whose slicer profile has an @printer qualifier that strips to the tray profile (#1047)', async () => {
- const qualifiedProfileSpool = {
- id: 12,
- material: 'PLA',
- subtype: 'Basic',
- brand: 'Devil Design',
- color_name: 'Red',
- rgba: 'FF0000FF',
- label_weight: 1000,
- weight_used: 0,
- tag_uid: null,
- tray_uuid: null,
- slicer_filament_name: 'Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)',
- };
- // Use a non-matching material to force the filter to rely on the profile path only.
- (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([qualifiedProfileSpool]);
- render(
- <AssignSpoolModal
- {...defaultProps}
- trayInfo={{
- type: 'ABS',
- material: 'ABS',
- profile: 'Devil Design PLA Basic',
- color: 'FF0000',
- location: 'AMS 1 - Slot 1',
- }}
- />
- );
- await waitFor(() => {
- expect(screen.getByText(/Devil Design/)).toBeInTheDocument();
- });
- });
- it('nudges the printer to republish after successful assignment (#1414)', async () => {
- // The backend's assign-spool path issues an MQTT command, but firmware
- // (esp. A1 mini external slots and any non-RFID assignment) doesn't
- // always echo the new tray state back on its own — the printer card
- // then sits on stale data until the user hits Force-refresh. Modal
- // calls refreshPrinterStatus to issue a pushall so the printer
- // republishes state, mirroring the Force-refresh button.
- const { default: userEvent } = await import('@testing-library/user-event');
- const user = userEvent.setup();
- // Tray material matches the spool to skip the mismatch confirm dialog.
- (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
- (api.assignSpool as ReturnType<typeof vi.fn>).mockResolvedValue({
- id: 1, spool_id: 1, printer_id: 7, ams_id: 0, tray_id: 0,
- });
- render(
- <AssignSpoolModal
- {...defaultProps}
- printerId={7}
- trayInfo={{ type: 'PLA', material: 'PLA', profile: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' }}
- />
- );
- await waitFor(() => {
- expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
- });
- await user.click(screen.getByText(/Polymaker/));
- await user.click(screen.getByRole('button', { name: /assign spool/i }));
- await waitFor(() => {
- expect(api.refreshPrinterStatus).toHaveBeenCalledWith(7);
- });
- });
- // #1680: backend returns `pending_config=true` when the AMS slot was empty
- // at assign time (firmware drops the MQTT push silently; on_ams_change
- // re-fires the config when filament is detected). The toast MUST reflect
- // that the slot hasn't been configured yet — saying "AMS slot configured"
- // reads as a lie and is what the reporter hit.
- it('shows the pending-insert toast when backend returns pending_config=true (#1680)', async () => {
- const { default: userEvent } = await import('@testing-library/user-event');
- const user = userEvent.setup();
- (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
- (api.assignSpool as ReturnType<typeof vi.fn>).mockResolvedValue({
- id: 1, spool_id: 1, printer_id: 7, ams_id: 0, tray_id: 0,
- pending_config: true,
- });
- render(
- <AssignSpoolModal
- {...defaultProps}
- printerId={7}
- trayInfo={{ type: 'PLA', material: 'PLA', profile: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' }}
- />
- );
- await waitFor(() => {
- expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
- });
- await user.click(screen.getByText(/Polymaker/));
- await user.click(screen.getByRole('button', { name: /assign spool/i }));
- await waitFor(() => {
- expect(mockShowToast).toHaveBeenCalledWith(
- expect.stringContaining('Slot will configure when you insert the spool'),
- 'success',
- );
- });
- // And the "AMS slot configured" toast must NOT also have fired.
- expect(mockShowToast).not.toHaveBeenCalledWith(
- expect.stringContaining('AMS slot configured'),
- expect.anything(),
- );
- });
- // Counterpart guard: when the backend did configure the slot
- // (pending_config=false or absent), the existing success toast still
- // fires. Without this regression test, the #1680 fix could silently
- // overshoot and label every assign as pending.
- it('shows the configured toast when backend returns pending_config=false (#1680)', async () => {
- const { default: userEvent } = await import('@testing-library/user-event');
- const user = userEvent.setup();
- (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
- (api.assignSpool as ReturnType<typeof vi.fn>).mockResolvedValue({
- id: 1, spool_id: 1, printer_id: 7, ams_id: 0, tray_id: 0,
- pending_config: false,
- });
- render(
- <AssignSpoolModal
- {...defaultProps}
- printerId={7}
- trayInfo={{ type: 'PLA', material: 'PLA', profile: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' }}
- />
- );
- await waitFor(() => {
- expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
- });
- await user.click(screen.getByText(/Polymaker/));
- await user.click(screen.getByRole('button', { name: /assign spool/i }));
- await waitFor(() => {
- expect(mockShowToast).toHaveBeenCalledWith(
- expect.stringContaining('AMS slot configured'),
- 'success',
- );
- });
- expect(mockShowToast).not.toHaveBeenCalledWith(
- expect.stringContaining('Slot will configure when you insert'),
- expect.anything(),
- );
- });
- });
- describe('AssignSpoolModal — Spoolman enabled (T-Gap 7)', () => {
- const spoolmanSpool = {
- id: 200,
- material: 'PETG',
- subtype: 'HF',
- brand: 'Bambu Lab',
- color_name: 'Blue',
- rgba: '0000FFFF',
- label_weight: 1000,
- weight_used: 0,
- tag_uid: null,
- tray_uuid: null,
- archived_at: null,
- };
- beforeEach(() => {
- vi.clearAllMocks();
- (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([]);
- (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([]);
- (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([spoolmanSpool]);
- });
- it('shows Spoolman spool section when spoolmanEnabled=true', async () => {
- render(<AssignSpoolModal {...defaultProps} spoolmanEnabled />);
- await waitFor(() => {
- // Spoolman spool brand should appear in the modal
- expect(screen.getByText(/Bambu Lab/)).toBeInTheDocument();
- });
- expect(api.getSpoolmanInventorySpools).toHaveBeenCalledWith(false);
- });
- it('does not fetch Spoolman spools when spoolmanEnabled=false', async () => {
- render(<AssignSpoolModal {...defaultProps} spoolmanEnabled={false} />);
- // Give the component time to settle
- await waitFor(() => {
- expect(api.getSpools).toHaveBeenCalled();
- });
- expect(api.getSpoolmanInventorySpools).not.toHaveBeenCalled();
- });
- it('hides local spool list when spoolmanEnabled=true (Bug #5)', async () => {
- // Even when local spools exist, they must not appear in Spoolman mode.
- (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
- render(<AssignSpoolModal {...defaultProps} spoolmanEnabled />);
- await waitFor(() => {
- // Spoolman spool is shown
- expect(screen.getByText(/Bambu Lab/)).toBeInTheDocument();
- });
- // Local spool (Polymaker) must NOT appear in Spoolman mode
- expect(screen.queryByText(/Polymaker/)).not.toBeInTheDocument();
- });
- it('hides archived Spoolman spools', async () => {
- const archivedSpool = { ...spoolmanSpool, id: 201, brand: 'Prusa', archived_at: '2025-01-01T00:00:00Z' };
- (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([spoolmanSpool, archivedSpool]);
- render(<AssignSpoolModal {...defaultProps} spoolmanEnabled />);
- await waitFor(() => {
- expect(screen.getByText(/Bambu Lab/)).toBeInTheDocument();
- });
- // Archived spool brand must NOT appear
- expect(screen.queryByText(/Prusa/)).not.toBeInTheDocument();
- });
- });
|