/**
* Tests for the FilamentHoverCard component.
* Focuses on fill level display and Spoolman source indicator.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '../utils';
import { FilamentHoverCard, EmptySlotHoverCard } from '../../components/FilamentHoverCard';
import { setColorCatalog, __resetColorCatalogForTests } from '../../utils/colors';
const baseFilamentData = {
vendor: 'Bambu Lab' as const,
profile: 'PLA Basic',
colorName: 'Red',
colorHex: 'FF0000',
kFactor: '0.030',
fillLevel: 75,
trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
};
function renderWithHover(ui: React.ReactElement) {
const result = render(ui);
// Trigger hover to show the card
const trigger = result.container.firstElementChild as HTMLElement;
fireEvent.mouseEnter(trigger);
return result;
}
describe('FilamentHoverCard', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
describe('fill level display', () => {
it('shows fill percentage when fillLevel is set', async () => {
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('75%')).toBeInTheDocument();
});
});
it('shows dash when fillLevel is null', async () => {
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('—')).toBeInTheDocument();
});
});
it('shows 0% when fillLevel is zero', async () => {
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('0%')).toBeInTheDocument();
});
});
});
describe('fill source badge transparency (#11)', () => {
it('never shows a Spoolman-source badge — UI stays mode-agnostic', async () => {
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('80%')).toBeInTheDocument();
expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
});
});
it('never shows an inventory-source badge — UI stays mode-agnostic', async () => {
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('80%')).toBeInTheDocument();
expect(screen.queryByText('(Inv)')).not.toBeInTheDocument();
});
});
it('does not render an empty source-label span when fillLevel is null', async () => {
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('—')).toBeInTheDocument();
expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
expect(screen.queryByText('(Inv)')).not.toBeInTheDocument();
});
});
});
describe('hover behavior', () => {
it('does not show card when disabled', () => {
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
// Card should not be visible
expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument();
});
it('shows filament details on hover', async () => {
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('Red')).toBeInTheDocument();
expect(screen.getByText('PLA Basic')).toBeInTheDocument();
expect(screen.getByText('0.030')).toBeInTheDocument();
});
});
});
// The inventory section was previously hidden for `vendor === 'Bambu Lab'`
// because BL spools were assumed to be managed entirely via RFID. #1133
// removed that gate so users who don't want to scan via SpoolBuddy NFC
// can still pick a BL spool from inventory the same way they pick a
// third-party one.
// Paired with the EmptySlotHoverCard assertion below (#2791) — together
// they pin the two render paths to the same Assign-then-Configure order.
it('lists Assign Spool above Configure (#2791)', async () => {
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
const assign = screen.getByText(/assign spool/i);
const configure = screen.getByText(/^configure$/i);
expect(assign.compareDocumentPosition(configure)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
});
describe('inventory section vendor visibility (#1133)', () => {
it('shows the assign-spool button on a Bambu Lab slot when the spool is unassigned', async () => {
const onAssign = vi.fn();
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText(/assign/i)).toBeInTheDocument();
});
});
it('shows the unassign button on a Bambu Lab slot when an inventory spool is already assigned', async () => {
// Regression guard: the original gate hid BOTH the assign and unassign
// buttons for BL slots. A user who'd already assigned an inventory
// spool to a BL slot couldn't undo it without dropping into the
// inventory page directly.
const onUnassign = vi.fn();
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText(/unassign/i)).toBeInTheDocument();
});
});
it('still shows the assign-spool button for a non-Bambu vendor (no behaviour change)', async () => {
const onAssign = vi.fn();
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText(/assign/i)).toBeInTheDocument();
});
});
it('shows the assign-spool button as disabled when isAssigned=true', async () => {
const onAssign = vi.fn();
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText(/assign/i)).toBeInTheDocument();
expect(screen.getByText(/assign/i).closest('button')).toBeDisabled();
});
});
it('does not call onAssignSpool when the button is disabled via isAssigned', async () => {
const onAssign = vi.fn();
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => expect(screen.getByText(/assign/i)).toBeInTheDocument());
const btn = screen.getByText(/assign/i).closest('button')!;
btn.click();
expect(onAssign).not.toHaveBeenCalled();
});
});
// For RFID-synced BL spools, both spoolman.linkedSpoolId and
// inventory.assignedSpool.id point to the same Spoolman spool. Rendering
// both branches gave two identical "Open in Inventory" buttons. The
// inventory-side button is suppressed when it would duplicate the
// spoolman-side link.
describe('"Open in Inventory" deduplication', () => {
const inventorySpool = {
id: 42,
material: 'PLA',
subtype: null,
brand: 'eSun',
color_name: 'Black',
};
it('shows only one Open in Inventory button when spoolman linkedSpoolId matches assignedSpool id', async () => {
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText(/assigned/i)).toBeInTheDocument();
});
expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(1);
});
it('shows two Open in Inventory buttons when spoolman and inventory point to different spools', async () => {
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText(/assigned/i)).toBeInTheDocument();
});
expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(2);
});
it('shows one Open in Inventory button when spoolman is absent but inventory spool is assigned', async () => {
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText(/assigned/i)).toBeInTheDocument();
});
expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(1);
});
it('shows the spool ID in the assigned-spool block', async () => {
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText('#42')).toBeInTheDocument();
});
});
});
// The card is portaled at z-[60] — above ConfigureAmsSlotModal and
// LinkSpoolModal at z-50 — so a card left standing draws OVER the dialog its
// own button just opened. Mouseleave is the only thing that used to hide it,
// and a touch device never sends one after the tap that opened the card, so on
// a tablet it hung there indefinitely: two overlapping layers, competing focus.
describe('dismissal when an action opens a dialog (#2631)', () => {
it('closes the card when Configure is pressed, and still configures', async () => {
const onConfigure = vi.fn();
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
fireEvent.click(screen.getByText(/configure/i));
expect(onConfigure).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
});
it('stays closed with no mouseleave, which is all a tablet ever gives us', async () => {
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
fireEvent.click(screen.getByText(/configure/i));
await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
// A pending show timer would resurrect the card on top of the dialog.
vi.advanceTimersByTime(1000);
expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument();
});
it('closes the card when Assign Spool is pressed', async () => {
const onAssignSpool = vi.fn();
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => expect(screen.getByText(/assign/i)).toBeInTheDocument());
fireEvent.click(screen.getByText(/assign/i));
expect(onAssignSpool).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
});
it('closes the card when Unassign Spool is pressed', async () => {
const onUnassignSpool = vi.fn();
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
await waitFor(() => expect(screen.getByText(/unassign/i)).toBeInTheDocument());
fireEvent.click(screen.getByText(/unassign/i));
expect(onUnassignSpool).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
});
});
});
// EmptySlotHoverCard is the hover wrapper rendered for a physically empty
// AMS slot. #1133 removed its inventory affordance: a slot with nothing
// loaded has no spool to attach an inventory record to, and offering the
// action there only led to users assigning the wrong spool to a slot the
// printer hadn't actually loaded yet. The configure-slot affordance is
// kept, since "preset for the next spool to land here" is still a sensible
// thing to do on an empty slot.
describe('EmptySlotHoverCard (#1133)', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
it('does not render an assign-spool button when onAssignSpool is not provided', async () => {
const result = render(
trigger
);
fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
vi.advanceTimersByTime(100);
await waitFor(() => {
// The card itself is showing — guard the negative assertion against
// a card that simply never opened.
expect(screen.getByText(/empty/i)).toBeInTheDocument();
});
expect(screen.queryByText(/assign spool/i)).not.toBeInTheDocument();
});
it('still shows the configure button on an empty slot', async () => {
const onConfigure = vi.fn();
const result = render(
trigger
);
fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText(/configure/i)).toBeInTheDocument();
});
});
it('shows Assign Spool button when onAssignSpool is provided', async () => {
const onAssign = vi.fn();
const result = render(
trigger
);
fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
vi.advanceTimersByTime(100);
await waitFor(() => {
expect(screen.getByText(/assign spool/i)).toBeInTheDocument();
});
});
it('calls onAssignSpool when Assign Spool button is clicked', async () => {
const onAssign = vi.fn();
const result = render(
trigger
);
fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
vi.advanceTimersByTime(100);
await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
fireEvent.click(screen.getByText(/assign spool/i));
expect(onAssign).toHaveBeenCalledTimes(1);
});
// #2791: the empty-slot and filled-slot cards are separate render paths
// that had drifted into opposite orders, so the menu reshuffled itself
// depending on whether the slot happened to hold filament. Both now put
// the spool action above the slot action; assert it on both paths so the
// two can't drift apart again.
it('lists Assign Spool above Configure, matching the filled-slot card (#2791)', async () => {
const result = render(
trigger
);
fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
vi.advanceTimersByTime(100);
await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
const assign = screen.getByText(/assign spool/i);
const configure = screen.getByText(/^configure$/i);
expect(assign.compareDocumentPosition(configure)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
});
// Same z-[60]-over-a-z-50-dialog problem as FilamentHoverCard (#2631).
describe('dismissal when an action opens a dialog (#2631)', () => {
it('closes the card when Configure is pressed, and still configures', async () => {
const onConfigure = vi.fn();
const result = render(
trigger
);
fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
vi.advanceTimersByTime(100);
await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
fireEvent.click(screen.getByText(/configure/i));
expect(onConfigure).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.queryByText(/empty/i)).not.toBeInTheDocument());
});
it('closes the card when Assign Spool is pressed', async () => {
const onAssign = vi.fn();
const result = render(
trigger
);
fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
vi.advanceTimersByTime(100);
await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
fireEvent.click(screen.getByText(/assign spool/i));
expect(onAssign).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.queryByText(/empty/i)).not.toBeInTheDocument());
});
});
});
describe('FilamentHoverCard colour name (#2875)', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
__resetColorCatalogForTests();
});
const whiteMatte = {
...baseFilamentData,
profile: 'Bambu PLA Matte',
colorHex: 'FFFFFFFF',
// What PrintersPage now resolves with the slot's own tray_sub_brands.
colorName: 'Ivory White',
};
async function showCard(ui: React.ReactElement) {
renderWithHover(ui);
vi.advanceTimersByTime(100);
}
it('shows the resolved catalogue name for the slot', async () => {
await showCard(
trigger
);
await waitFor(() => expect(screen.getByText('Ivory White')).toBeInTheDocument());
expect(screen.queryByText('Jade White')).not.toBeInTheDocument();
});
it('prefers the assigned spool name, which is what the user put in the slot', async () => {
await showCard(
trigger
);
await waitFor(() => expect(screen.getByText('Matte Ivory White')).toBeInTheDocument());
expect(screen.queryByText('Jade White')).not.toBeInTheDocument();
});
it('ignores a Bambu internal colour code on the assigned spool', async () => {
// "A06-D0" is not a name, and is not unique across material families
// (#857) -- the catalogue answer stands.
await showCard(
trigger
);
await waitFor(() => expect(screen.getByText('Ivory White')).toBeInTheDocument());
expect(screen.queryByText('A06-D0')).not.toBeInTheDocument();
});
it.each([
['an empty colour name', ''],
['a whitespace-only colour name', ' '],
])('keeps the catalogue answer for a spool with %s', async (_label, colorName) => {
await showCard(
trigger
);
await waitFor(() => expect(screen.getByText('Ivory White')).toBeInTheDocument());
});
it('keeps the catalogue answer when a spool is assigned with no colour recorded', async () => {
setColorCatalog({ ffffff: 'Jade White' }, { 'pla matte|ffffff': 'Ivory White' });
await showCard(
trigger
);
await waitFor(() => expect(screen.getByText('Ivory White')).toBeInTheDocument());
});
});
// A spool's subtype is part of its name. Dropping it made a wood-filled roll
// read as plain PLA on the slot card, which is the display-side version of
// the mistake #2902 fixed on the backend -- and the printer, the inventory
// page and Studio all named it correctly at the same time, so the card was
// the only thing saying otherwise.
describe('FilamentHoverCard assigned spool name', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
__resetColorCatalogForTests();
});
function showAssigned(assignedSpool: {
id: number;
material: string;
subtype: string | null;
brand: string | null;
color_name: string | null;
}) {
renderWithHover(
trigger
);
vi.advanceTimersByTime(100);
}
it('names a filled filament by its subtype, not by its base material', async () => {
showAssigned({
id: 77,
material: 'PLA',
subtype: 'Wood',
brand: 'Bambu Lab',
color_name: 'Classic Birch',
});
await waitFor(() =>
expect(screen.getByText('Bambu Lab PLA Wood - Classic Birch')).toBeInTheDocument()
);
});
it('omits the subtype entirely for a spool that has none', async () => {
showAssigned({
id: 78,
material: 'PLA',
subtype: null,
brand: 'Bambu Lab',
color_name: 'Jade White',
});
await waitFor(() =>
expect(screen.getByText('Bambu Lab PLA - Jade White')).toBeInTheDocument()
);
});
});