mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-10 07:55:39 +02:00
401 lines
14 KiB
TypeScript
401 lines
14 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
});
|