| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396 |
- /**
- * Tests for low stock threshold functionality in InventoryPage.
- *
- * Tests that the low stock threshold:
- * - Is loaded from backend settings API
- * - Can be updated via the UI
- * - Persists changes to the backend
- * - Does not use localStorage
- */
- import { describe, it, expect, beforeEach } from 'vitest';
- import { screen, waitFor } from '@testing-library/react';
- import userEvent from '@testing-library/user-event';
- import { render } from '../utils';
- import InventoryPageRouter from '../../pages/InventoryPage';
- import { http, HttpResponse } from 'msw';
- import { server } from '../mocks/server';
- const mockSettings = {
- auto_archive: true,
- save_thumbnails: true,
- capture_finish_photo: true,
- default_filament_cost: 25.0,
- currency: 'USD',
- energy_cost_per_kwh: 0.15,
- energy_tracking_mode: 'total',
- spoolman_enabled: false,
- spoolman_url: '',
- spoolman_sync_mode: 'auto',
- spoolman_disable_weight_sync: false,
- spoolman_report_partial_usage: true,
- check_updates: true,
- check_printer_firmware: true,
- include_beta_updates: false,
- language: 'en',
- notification_language: 'en',
- bed_cooled_threshold: 35,
- ams_humidity_good: 40,
- ams_humidity_fair: 60,
- ams_temp_good: 28,
- ams_temp_fair: 35,
- ams_history_retention_days: 30,
- per_printer_mapping_expanded: false,
- date_format: 'system',
- time_format: 'system',
- default_printer_id: null,
- virtual_printer_enabled: false,
- virtual_printer_access_code: '',
- virtual_printer_mode: 'immediate',
- dark_style: 'classic',
- dark_background: 'neutral',
- dark_accent: 'green',
- light_style: 'classic',
- light_background: 'neutral',
- light_accent: 'green',
- ftp_retry_enabled: true,
- ftp_retry_count: 3,
- ftp_retry_delay: 2,
- ftp_timeout: 30,
- mqtt_enabled: false,
- mqtt_broker: '',
- mqtt_port: 1883,
- mqtt_username: '',
- mqtt_password: '',
- mqtt_topic_prefix: 'bambuddy',
- mqtt_use_tls: false,
- external_url: '',
- ha_enabled: false,
- ha_url: '',
- ha_token: '',
- ha_url_from_env: false,
- ha_token_from_env: false,
- ha_env_managed: false,
- library_archive_mode: 'ask',
- library_disk_warning_gb: 5.0,
- camera_view_mode: 'window',
- preferred_slicer: 'bambu_studio',
- prometheus_enabled: false,
- prometheus_token: '',
- low_stock_threshold: 20.0,
- };
- const mockSpools = [
- {
- id: 1,
- material: 'PLA',
- subtype: null,
- brand: 'Polymaker',
- color_name: 'Red',
- rgba: 'FF0000FF',
- label_weight: 1000,
- core_weight: 250,
- weight_used: 900, // 10% remaining - low stock
- slicer_filament: null,
- slicer_filament_name: null,
- nozzle_temp_min: null,
- nozzle_temp_max: null,
- note: null,
- added_full: null,
- last_used: null,
- encode_time: null,
- tag_uid: null,
- tray_uuid: null,
- data_origin: null,
- tag_type: null,
- archived_at: null,
- created_at: '2025-01-01T00:00:00Z',
- updated_at: '2025-01-01T00:00:00Z',
- k_profiles: [],
- cost_per_kg: null,
- last_scale_weight: null,
- last_weighed_at: null,
- },
- {
- id: 2,
- material: 'PETG',
- subtype: null,
- brand: 'eSun',
- color_name: 'Blue',
- rgba: '0000FFFF',
- label_weight: 1000,
- core_weight: 250,
- weight_used: 200, // 80% remaining - not low stock
- slicer_filament: null,
- slicer_filament_name: null,
- nozzle_temp_min: null,
- nozzle_temp_max: null,
- note: null,
- added_full: null,
- last_used: null,
- encode_time: null,
- tag_uid: null,
- tray_uuid: null,
- data_origin: null,
- tag_type: null,
- archived_at: null,
- created_at: '2025-01-02T00:00:00Z',
- updated_at: '2025-01-02T00:00:00Z',
- k_profiles: [],
- cost_per_kg: null,
- last_scale_weight: null,
- last_weighed_at: null,
- },
- {
- id: 3,
- material: 'ABS',
- subtype: null,
- brand: 'Hatchbox',
- color_name: 'Black',
- rgba: '000000FF',
- label_weight: 1000,
- core_weight: 250,
- weight_used: 850, // 15% remaining - low stock
- slicer_filament: null,
- slicer_filament_name: null,
- nozzle_temp_min: null,
- nozzle_temp_max: null,
- note: null,
- added_full: null,
- last_used: null,
- encode_time: null,
- tag_uid: null,
- tray_uuid: null,
- data_origin: null,
- tag_type: null,
- archived_at: null,
- created_at: '2025-01-03T00:00:00Z',
- updated_at: '2025-01-03T00:00:00Z',
- k_profiles: [],
- cost_per_kg: null,
- last_scale_weight: null,
- last_weighed_at: null,
- },
- ];
- describe('InventoryPage - Low Stock Threshold', () => {
- beforeEach(() => {
- // Clear localStorage to ensure we're not relying on it
- localStorage.clear();
- server.use(
- http.get('/api/v1/settings/', () => {
- return HttpResponse.json(mockSettings);
- }),
- http.put('/api/v1/settings/', async ({ request }) => {
- const body = (await request.json()) as Partial<typeof mockSettings>;
- return HttpResponse.json({ ...mockSettings, ...body });
- }),
- http.get('/api/v1/inventory/spools', () => {
- return HttpResponse.json(mockSpools);
- }),
- http.get('/api/v1/inventory/assignments', () => {
- return HttpResponse.json([]);
- }),
- http.get('/api/v1/spoolman/settings', () => {
- return HttpResponse.json({ spoolman_enabled: 'false' });
- })
- );
- });
- describe('default threshold from backend', () => {
- it('loads the default threshold of 20% from backend settings', async () => {
- render(<InventoryPageRouter />);
- await waitFor(() => {
- // Find the low stock stat showing the threshold
- expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
- });
- });
- it('calculates low stock count based on default threshold', async () => {
- render(<InventoryPageRouter />);
- await waitFor(() => {
- // With default 20% threshold, spools with 10% and 15% remaining should be counted (2 spools)
- const lowStockSection = screen.getByText(/low stock/i).closest('div');
- expect(lowStockSection).toBeInTheDocument();
- });
- });
- it('does not use localStorage for threshold', async () => {
- // Set a value in localStorage that should be ignored
- localStorage.setItem('bambuddy-low-stock-threshold', '50');
- render(<InventoryPageRouter />);
- await waitFor(() => {
- // Should show backend value (20%), not localStorage value (50%)
- expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
- });
- });
- });
- describe('updating threshold via UI', () => {
- it('shows edit button for threshold', async () => {
- const user = userEvent.setup();
- render(<InventoryPageRouter />);
- await waitFor(() => {
- expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
- });
- // Find the edit button within the low stock threshold section
- const thresholdText = screen.getByText(/< 20%/i);
- const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
- expect(editButton).toBeInTheDocument();
- await user.click(editButton);
- // Input field should appear with default threshold value
- await waitFor(() => {
- const input = screen.getByDisplayValue('20');
- expect(input).toBeInTheDocument();
- });
- });
- it('updates threshold and persists to backend', async () => {
- const user = userEvent.setup();
- let updatedSettings: Partial<typeof mockSettings> | null = null;
- server.use(
- http.put('/api/v1/settings/', async ({ request }) => {
- const body = (await request.json()) as Partial<typeof mockSettings>;
- updatedSettings = body;
- return HttpResponse.json({ ...mockSettings, ...body });
- })
- );
- render(<InventoryPageRouter />);
- await waitFor(() => {
- expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
- });
- // Click edit button within the low stock threshold section
- const thresholdText = screen.getByText(/< 20%/i);
- const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
- await user.click(editButton);
- // Enter new value
- const input = screen.getByDisplayValue('20');
- await user.clear(input);
- await user.type(input, '15.5');
- // Submit form
- const saveButton = screen.getByRole('button', { name: /save/i });
- await user.click(saveButton);
- // Verify API was called with correct value
- await waitFor(() => {
- expect(updatedSettings).toEqual({ low_stock_threshold: 15.5 });
- });
- });
- it('validates threshold input range', async () => {
- const user = userEvent.setup();
- let updatedSettings: Partial<typeof mockSettings> | null = null;
- server.use(
- http.put('/api/v1/settings/', async ({ request }) => {
- const body = (await request.json()) as Partial<typeof mockSettings>;
- updatedSettings = body;
- return HttpResponse.json({ ...mockSettings, ...body });
- })
- );
- render(<InventoryPageRouter />);
- await waitFor(() => {
- expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
- });
- // Click edit button within the low stock threshold section
- const thresholdText = screen.getByText(/< 20%/i);
- const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
- await user.click(editButton);
- // Try invalid values
- const input = screen.getByDisplayValue('20');
- // Too low (0 is below the 0.1 minimum)
- await user.clear(input);
- await user.type(input, '0');
- const saveButton = screen.getByRole('button', { name: /save/i });
- await user.click(saveButton);
- // Should show error and NOT call the PUT endpoint
- await waitFor(() => {
- expect(updatedSettings).toBeNull();
- });
- });
- it('allows canceling threshold edit', async () => {
- const user = userEvent.setup();
- render(<InventoryPageRouter />);
- await waitFor(() => {
- expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
- });
- // Click edit button within the low stock threshold section
- const thresholdText = screen.getByText(/< 20%/i);
- const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
- await user.click(editButton);
- // Change value
- const input = screen.getByDisplayValue('20');
- await user.clear(input);
- await user.type(input, '30');
- // Cancel
- const cancelButton = screen.getByRole('button', { name: /cancel/i });
- await user.click(cancelButton);
- // Should revert to original display
- await waitFor(() => {
- expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
- });
- });
- });
- describe('custom threshold from backend', () => {
- it('loads custom threshold value from backend', async () => {
- server.use(
- http.get('/api/v1/settings/', () => {
- return HttpResponse.json({ ...mockSettings, low_stock_threshold: 25.0 });
- })
- );
- render(<InventoryPageRouter />);
- await waitFor(() => {
- expect(screen.getByText(/< 25%/i)).toBeInTheDocument();
- });
- });
- it('applies custom threshold to low stock filtering', async () => {
- // With threshold at 30%, all 3 test spools should be low stock (10%, 15%, and we'd need to check 80%)
- server.use(
- http.get('/api/v1/settings/', () => {
- return HttpResponse.json({ ...mockSettings, low_stock_threshold: 30.0 });
- })
- );
- render(<InventoryPageRouter />);
- await waitFor(() => {
- expect(screen.getByText(/< 30%/i)).toBeInTheDocument();
- });
- // The low stock count should reflect the new threshold
- // Implementation would show appropriate count based on 30% threshold
- });
- });
- });
|