| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524 |
- /**
- * 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';
- 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(
- <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 75 }}>
- <div>trigger</div>
- </FilamentHoverCard>
- );
- vi.advanceTimersByTime(100);
- await waitFor(() => {
- expect(screen.getByText('75%')).toBeInTheDocument();
- });
- });
- it('shows dash when fillLevel is null', async () => {
- renderWithHover(
- <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: null }}>
- <div>trigger</div>
- </FilamentHoverCard>
- );
- vi.advanceTimersByTime(100);
- await waitFor(() => {
- expect(screen.getByText('—')).toBeInTheDocument();
- });
- });
- it('shows 0% when fillLevel is zero', async () => {
- renderWithHover(
- <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 0 }}>
- <div>trigger</div>
- </FilamentHoverCard>
- );
- 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(
- <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'spoolman' }}>
- <div>trigger</div>
- </FilamentHoverCard>
- );
- 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(
- <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'inventory' }}>
- <div>trigger</div>
- </FilamentHoverCard>
- );
- 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(
- <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: null, fillSource: 'spoolman' }}>
- <div>trigger</div>
- </FilamentHoverCard>
- );
- 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(
- <FilamentHoverCard data={baseFilamentData} disabled>
- <div>trigger</div>
- </FilamentHoverCard>
- );
- vi.advanceTimersByTime(100);
- // Card should not be visible
- expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument();
- });
- it('shows filament details on hover', async () => {
- renderWithHover(
- <FilamentHoverCard data={baseFilamentData}>
- <div>trigger</div>
- </FilamentHoverCard>
- );
- 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.
- 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(
- <FilamentHoverCard
- data={{ ...baseFilamentData, vendor: 'Bambu Lab' }}
- inventory={{ assignedSpool: null, onAssignSpool: onAssign }}
- >
- <div>trigger</div>
- </FilamentHoverCard>
- );
- 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(
- <FilamentHoverCard
- data={{ ...baseFilamentData, vendor: 'Bambu Lab' }}
- inventory={{
- assignedSpool: {
- id: 1,
- material: 'PLA',
- brand: 'Devil Design',
- color_name: 'Black',
- },
- onUnassignSpool: onUnassign,
- }}
- >
- <div>trigger</div>
- </FilamentHoverCard>
- );
- 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(
- <FilamentHoverCard
- data={{ ...baseFilamentData, vendor: 'Polymaker' as unknown as 'Bambu Lab' }}
- inventory={{ assignedSpool: null, onAssignSpool: onAssign }}
- >
- <div>trigger</div>
- </FilamentHoverCard>
- );
- 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(
- <FilamentHoverCard
- data={{ ...baseFilamentData, vendor: 'Bambu Lab' }}
- inventory={{ assignedSpool: null, onAssignSpool: onAssign, isAssigned: true }}
- >
- <div>trigger</div>
- </FilamentHoverCard>
- );
- 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(
- <FilamentHoverCard
- data={{ ...baseFilamentData, vendor: 'Bambu Lab' }}
- inventory={{ assignedSpool: null, onAssignSpool: onAssign, isAssigned: true }}
- >
- <div>trigger</div>
- </FilamentHoverCard>
- );
- 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',
- brand: 'eSun',
- color_name: 'Black',
- };
- it('shows only one Open in Inventory button when spoolman linkedSpoolId matches assignedSpool id', async () => {
- renderWithHover(
- <FilamentHoverCard
- data={baseFilamentData}
- spoolman={{ enabled: true, linkedSpoolId: 42 }}
- inventory={{ assignedSpool: inventorySpool }}
- >
- <div>trigger</div>
- </FilamentHoverCard>
- );
- 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(
- <FilamentHoverCard
- data={baseFilamentData}
- spoolman={{ enabled: true, linkedSpoolId: 99 }}
- inventory={{ assignedSpool: inventorySpool }}
- >
- <div>trigger</div>
- </FilamentHoverCard>
- );
- 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(
- <FilamentHoverCard
- data={baseFilamentData}
- inventory={{ assignedSpool: inventorySpool }}
- >
- <div>trigger</div>
- </FilamentHoverCard>
- );
- 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(
- <FilamentHoverCard
- data={baseFilamentData}
- inventory={{ assignedSpool: inventorySpool }}
- >
- <div>trigger</div>
- </FilamentHoverCard>
- );
- 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(
- <FilamentHoverCard
- data={baseFilamentData}
- configureSlot={{ enabled: true, onConfigure }}
- >
- <div>trigger</div>
- </FilamentHoverCard>
- );
- 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(
- <FilamentHoverCard
- data={baseFilamentData}
- configureSlot={{ enabled: true, onConfigure: vi.fn() }}
- >
- <div>trigger</div>
- </FilamentHoverCard>
- );
- 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(
- <FilamentHoverCard
- data={baseFilamentData}
- inventory={{ assignedSpool: null, onAssignSpool }}
- >
- <div>trigger</div>
- </FilamentHoverCard>
- );
- 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(
- <FilamentHoverCard
- data={baseFilamentData}
- inventory={{
- assignedSpool: { id: 7, material: 'PLA', brand: 'eSun', color_name: 'Black' },
- onUnassignSpool,
- }}
- >
- <div>trigger</div>
- </FilamentHoverCard>
- );
- 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(
- <EmptySlotHoverCard configureSlot={{ enabled: true, onConfigure: vi.fn() }}>
- <div>trigger</div>
- </EmptySlotHoverCard>
- );
- 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(
- <EmptySlotHoverCard configureSlot={{ enabled: true, onConfigure }}>
- <div>trigger</div>
- </EmptySlotHoverCard>
- );
- 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(
- <EmptySlotHoverCard onAssignSpool={onAssign}>
- <div>trigger</div>
- </EmptySlotHoverCard>
- );
- 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(
- <EmptySlotHoverCard onAssignSpool={onAssign}>
- <div>trigger</div>
- </EmptySlotHoverCard>
- );
- 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);
- });
- // 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(
- <EmptySlotHoverCard configureSlot={{ enabled: true, onConfigure }}>
- <div>trigger</div>
- </EmptySlotHoverCard>
- );
- 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(
- <EmptySlotHoverCard onAssignSpool={onAssign}>
- <div>trigger</div>
- </EmptySlotHoverCard>
- );
- 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());
- });
- });
- });
|