Add SpoolBuddy backend + frontend test coverage

21 backend integration tests for all 12 SpoolBuddy API endpoints
  (device register/re-register/list, heartbeat status/commands/404/
  offline broadcast, NFC tag match/unmatch/removal, scale reading/
  weight calculation/missing spool, calibration tare/set-tare/
  set-factor/zero-delta/get) and 20 frontend component tests for
  WeightDisplay, SpoolInfoCard, UnknownTagCard, and TagDetectedModal.
This commit is contained in:
maziggy
2026-03-01 15:08:38 +01:00
parent 42eb19afce
commit c2326b4fcb
5 changed files with 779 additions and 0 deletions
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { render } from '../utils';
import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
const mockUpdateSpoolWeight = vi.fn();
vi.mock('../../api/client', () => ({
api: {
getSettings: vi.fn().mockResolvedValue({}),
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
},
spoolbuddyApi: {
updateSpoolWeight: (...args: unknown[]) => mockUpdateSpoolWeight(...args),
},
}));
const mockSpool: MatchedSpool = {
id: 42,
tag_uid: 'AABBCCDD11223344',
material: 'PLA',
subtype: 'Matte',
color_name: 'Jade White',
rgba: 'E8F5E9FF',
brand: 'Bambu',
label_weight: 1000,
core_weight: 250,
weight_used: 200,
};
describe('SpoolInfoCard', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUpdateSpoolWeight.mockResolvedValue({ status: 'ok', weight_used: 300 });
});
it('renders spool material, brand, color name', () => {
render(<SpoolInfoCard spool={mockSpool} scaleWeight={null} />);
expect(screen.getByText('Jade White')).toBeInTheDocument();
expect(screen.getByText(/Bambu/)).toBeInTheDocument();
expect(screen.getByText(/PLA/)).toBeInTheDocument();
});
it('shows spool color circle with correct hex color', () => {
const { container } = render(<SpoolInfoCard spool={mockSpool} scaleWeight={null} />);
// SpoolIcon renders an SVG circle with fill=colorHex
const circle = container.querySelector('circle[fill="#E8F5E9"]');
expect(circle).toBeInTheDocument();
});
it('shows remaining weight and fill percentage', () => {
// scaleWeight=900g, core=250g → remaining = 900-250 = 650g
// fillPercent = round(650/1000 * 100) = 65%
render(<SpoolInfoCard spool={mockSpool} scaleWeight={900} />);
expect(screen.getByText('650g')).toBeInTheDocument();
expect(screen.getByText('65%')).toBeInTheDocument();
});
it('calls onAssignToAms when "Assign to AMS" button clicked', () => {
const onAssign = vi.fn();
render(
<SpoolInfoCard spool={mockSpool} scaleWeight={800} onAssignToAms={onAssign} />
);
fireEvent.click(screen.getByText('Assign to AMS'));
expect(onAssign).toHaveBeenCalledTimes(1);
});
it('calls onSyncWeight when sync button clicked', async () => {
const onSync = vi.fn();
render(
<SpoolInfoCard spool={mockSpool} scaleWeight={800} onSyncWeight={onSync} />
);
fireEvent.click(screen.getByText('Sync Weight'));
await waitFor(() => {
expect(mockUpdateSpoolWeight).toHaveBeenCalledWith(42, 800);
});
});
it('calls onClose when close button clicked', () => {
const onClose = vi.fn();
render(
<SpoolInfoCard spool={mockSpool} scaleWeight={null} onClose={onClose} />
);
fireEvent.click(screen.getByText('Close'));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe('UnknownTagCard', () => {
it('renders tag UID', () => {
render(<UnknownTagCard tagUid="DEADBEEF12345678" scaleWeight={null} />);
expect(screen.getByText('DEADBEEF12345678')).toBeInTheDocument();
expect(screen.getByText('New Tag Detected')).toBeInTheDocument();
});
it('shows "Add to Inventory" button', () => {
const onAdd = vi.fn();
render(
<UnknownTagCard tagUid="DEADBEEF" scaleWeight={null} onAddToInventory={onAdd} />
);
const btn = screen.getByText('Add to Inventory');
expect(btn).toBeInTheDocument();
fireEvent.click(btn);
expect(onAdd).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { render } from '../utils';
import { TagDetectedModal } from '../../components/spoolbuddy/TagDetectedModal';
import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
const mockUpdateSpoolWeight = vi.fn();
vi.mock('../../api/client', () => ({
api: {
getSettings: vi.fn().mockResolvedValue({}),
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
},
spoolbuddyApi: {
updateSpoolWeight: (...args: unknown[]) => mockUpdateSpoolWeight(...args),
},
}));
const mockSpool: MatchedSpool = {
id: 7,
tag_uid: 'AA11BB22CC33DD44',
material: 'PETG',
subtype: 'HF',
color_name: 'Orange',
rgba: 'FF6600FF',
brand: 'Overture',
label_weight: 1000,
core_weight: 250,
weight_used: 100,
};
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
spool: mockSpool,
tagUid: 'AA11BB22CC33DD44',
scaleWeight: 950.0,
weightStable: true,
onSyncWeight: vi.fn(),
onAssignToAms: vi.fn(),
onLinkSpool: vi.fn(),
onAddToInventory: vi.fn(),
};
describe('TagDetectedModal', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUpdateSpoolWeight.mockResolvedValue({ status: 'ok', weight_used: 300 });
});
it('does not render when isOpen=false', () => {
render(<TagDetectedModal {...defaultProps} isOpen={false} />);
expect(screen.queryByText('Spool Detected')).not.toBeInTheDocument();
});
it('renders known spool view when spool provided', () => {
render(<TagDetectedModal {...defaultProps} />);
expect(screen.getByText('Spool Detected')).toBeInTheDocument();
expect(screen.getByText('Orange')).toBeInTheDocument();
expect(screen.getByText(/Overture/)).toBeInTheDocument();
expect(screen.getByText(/PETG/)).toBeInTheDocument();
});
it('renders unknown tag view when spool is null', () => {
render(
<TagDetectedModal
{...defaultProps}
spool={null}
tagUid="DEADBEEF11223344"
/>
);
expect(screen.getByText('New Tag Detected')).toBeInTheDocument();
expect(screen.getByText('DEADBEEF11223344')).toBeInTheDocument();
});
it('closes on Escape key', () => {
const onClose = vi.fn();
render(<TagDetectedModal {...defaultProps} onClose={onClose} />);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('shows weight from scale', () => {
// scaleWeight=950g, core=250g → remaining = 950-250 = 700g
render(<TagDetectedModal {...defaultProps} scaleWeight={950} />);
expect(screen.getByText('700g')).toBeInTheDocument();
});
it('shows action buttons (Assign to AMS, Sync Weight)', () => {
const onAssign = vi.fn();
const onSync = vi.fn();
render(
<TagDetectedModal
{...defaultProps}
onAssignToAms={onAssign}
onSyncWeight={onSync}
/>
);
expect(screen.getByText('Assign to AMS')).toBeInTheDocument();
expect(screen.getByText('Sync Weight')).toBeInTheDocument();
fireEvent.click(screen.getByText('Assign to AMS'));
expect(onAssign).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { render } from '../utils';
import { WeightDisplay } from '../../components/spoolbuddy/WeightDisplay';
const mockTare = vi.fn();
vi.mock('../../api/client', () => ({
api: {
getSettings: vi.fn().mockResolvedValue({}),
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
},
spoolbuddyApi: {
tare: (...args: unknown[]) => mockTare(...args),
},
}));
const defaultProps = {
weight: 823.4,
weightStable: true,
deviceOnline: true,
deviceId: 'sb-0001',
};
describe('WeightDisplay', () => {
beforeEach(() => {
vi.clearAllMocks();
mockTare.mockResolvedValue({ status: 'ok' });
});
it('renders weight value with 1 decimal place', () => {
render(<WeightDisplay {...defaultProps} weight={823.456} />);
expect(screen.getByText('823.5')).toBeInTheDocument();
});
it('shows green dot when stable and online', () => {
const { container } = render(
<WeightDisplay {...defaultProps} weightStable={true} deviceOnline={true} />
);
const dot = container.querySelector('.bg-green-500');
expect(dot).toBeInTheDocument();
expect(screen.getByText('Stable')).toBeInTheDocument();
});
it('shows amber dot when unstable', () => {
const { container } = render(
<WeightDisplay {...defaultProps} weightStable={false} deviceOnline={true} />
);
const dot = container.querySelector('.bg-amber-500');
expect(dot).toBeInTheDocument();
expect(screen.getByText('Measuring...')).toBeInTheDocument();
});
it('shows gray dot when offline', () => {
const { container } = render(
<WeightDisplay {...defaultProps} deviceOnline={false} />
);
const dot = container.querySelector('.bg-zinc-600');
expect(dot).toBeInTheDocument();
expect(screen.getByText('No reading')).toBeInTheDocument();
});
it('tare button calls spoolbuddyApi.tare(deviceId)', async () => {
render(<WeightDisplay {...defaultProps} />);
const tareButton = screen.getByText('Tare');
fireEvent.click(tareButton);
await waitFor(() => {
expect(mockTare).toHaveBeenCalledWith('sb-0001');
});
});
it('tare button is disabled when no deviceId', () => {
render(<WeightDisplay {...defaultProps} deviceId={null} />);
const tareButton = screen.getByText('Tare');
expect(tareButton).toBeDisabled();
});
});