mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 15:11:21 +02:00
Backend correctly defers MQTT configuration when the AMS slot is empty
at assign time (state ∈ {9, 10}) — firmware drops the push silently;
on_ams_change replays the config once a spool is detected. The
response carries pending_config=true to communicate that. The
printer-card AssignSpoolModal was ignoring the flag and always
showing "Spool assigned and AMS slot configured" — the SpoolBuddy
modal has handled this since it shipped. Now mirrors the same
branch and shows "Assigned. Slot will configure when you insert
the spool." when pending_config is true.
488 lines
17 KiB
TypeScript
488 lines
17 KiB
TypeScript
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();
|
|
});
|
|
});
|