/** * Tests for LOCATION column in InventoryPage when in Spoolman mode. * * Regression test for Phase 8: LOCATION column showed "-" for Spoolman * spools assigned to AMS slots, because only the local * /inventory/assignments endpoint was queried — the Spoolman * /spoolman/inventory/slot-assignments/all endpoint was ignored. */ import { describe, it, expect, beforeEach } from 'vitest'; import { screen, waitFor, within } from '@testing-library/react'; import { render } from '../utils'; import InventoryPageRouter from '../../pages/InventoryPage'; import { http, HttpResponse } from 'msw'; import { server } from '../mocks/server'; import { useQuery } from '@tanstack/react-query'; import { api } from '../../api/client'; // Full settings shape — pattern matches InventoryPageLowStock.test.tsx. 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, 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_disk_warning_gb: 5.0, camera_view_mode: 'window', preferred_slicer: 'bambu_studio', prometheus_enabled: false, prometheus_token: '', low_stock_threshold: 20.0, }; const mockSpoolmanSpool = { id: 216, material: 'PLA', subtype: null, brand: 'Bambu Lab', color_name: 'Orange', rgba: 'FF8800FF', label_weight: 1000, core_weight: 250, weight_used: 200, 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: 'spoolman', tag_type: 'spoolman', 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, storage_location: 'IKEA Regal', }; describe('InventoryPage - LOCATION column (Spoolman mode)', () => { beforeEach(() => { localStorage.clear(); server.use( http.get('/api/v1/settings/', () => HttpResponse.json(mockSettings)), http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])), http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])), ); }); it('shows AMS slot in LOCATION column for spoolman spool with assignment', async () => { server.use( http.get('/api/v1/settings/spoolman', () => HttpResponse.json({ spoolman_enabled: 'true', spoolman_url: 'http://localhost:7912', }) ), http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([mockSpoolmanSpool]) ), http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([ { printer_id: 1, printer_name: 'Sully', ams_id: 0, tray_id: 2, spoolman_spool_id: 216, ams_label: null, }, ]) ), ); const { container } = render(); // LOCATION cell renders "{printerLabel} {slotLabel}" via JSX template, // which splits into separate text nodes. Use innerHTML / textContent inspection. // formatSlotLabel(0, 2, false, false) => "A3" await waitFor(() => { expect(container.textContent).toContain('Sully'); expect(container.textContent).toContain('A3'); }); }); it('labels an H3C slot 3H-n and a per-hotend external input Ext-n', async () => { server.use( http.get('/api/v1/settings/spoolman', () => HttpResponse.json({ spoolman_enabled: 'true', spoolman_url: 'http://localhost:7912' }) ), http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([mockSpoolmanSpool, { ...mockSpoolmanSpool, id: 217, color_name: 'Blue', rgba: '0000FFFF' }]) ), http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([ // AMS 3H slot 7 is stored as (32, 6); input 250 (hotend 5) as (255, 5). { printer_id: 2, printer_name: 'Workshop', ams_id: 32, tray_id: 6, spoolman_spool_id: 216, ams_label: null, printer_model: 'H3C' }, { printer_id: 2, printer_name: 'Workshop', ams_id: 255, tray_id: 5, spoolman_spool_id: 217, ams_label: null, printer_model: 'H3C' }, ]) ), ); const { container } = render(); await waitFor(() => { expect(container.textContent).toContain('Workshop 3H-7'); expect(container.textContent).toContain('Workshop Ext-6'); }); }); it('shows "-" in LOCATION column when spoolman spool has no slot assignment', async () => { server.use( http.get('/api/v1/settings/spoolman', () => HttpResponse.json({ spoolman_enabled: 'true', spoolman_url: 'http://localhost:7912', }) ), http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([mockSpoolmanSpool]) ), http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([]) ), ); const { container } = render(); await waitFor(() => { // Spool row is rendered — brand "Bambu Lab" appears in the table somewhere. expect(container.textContent).toContain('Bambu Lab'); }); // LOCATION cell shows "-" (there may be other "-" cells too — at least one expected). const dashCells = screen.getAllByText('-'); expect(dashCells.length).toBeGreaterThan(0); }); it('does not call /spoolman/inventory/slot-assignments/all in local mode', async () => { let slotEndpointCalled = false; server.use( http.get('/api/v1/settings/spoolman', () => HttpResponse.json({ spoolman_enabled: 'false', spoolman_url: '' }) ), http.get('/api/v1/inventory/spools', () => HttpResponse.json([])), http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => { slotEndpointCalled = true; return HttpResponse.json([]); }), ); render(); // Wait for the page to settle by checking a stable element from the stat cards. await waitFor(() => { expect(screen.getByText(/total inventory/i)).toBeInTheDocument(); }); expect(slotEndpointCalled).toBe(false); }); it('built-in rows another page put in the shared cache stay off a Spoolman spool (#3430)', async () => { // The printer page fetches ['spool-assignments'] in either mode; a // disabled query still hands out what is cached under its key. function OtherPage() { useQuery({ queryKey: ['spool-assignments'], queryFn: () => api.getAssignments() }); return null; } server.use( http.get('/api/v1/settings/spoolman', () => HttpResponse.json({ spoolman_enabled: 'true', spoolman_url: 'http://localhost:7912' }) ), http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([mockSpoolmanSpool])), http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([])), http.get('/api/v1/inventory/assignments', () => HttpResponse.json([ { id: 99, spool_id: 216, printer_id: 7, printer_name: 'LocalPrinter', ams_id: 0, tray_id: 0, ams_label: null, created_at: '2025-01-01T00:00:00Z' }, ]) ), ); const { container } = render( <> ); await waitFor(() => expect(container.textContent).toContain('Bambu Lab')); await new Promise((r) => setTimeout(r, 50)); expect(container.textContent).not.toContain('LocalPrinter'); }); it('does not fetch built-in assignments in Spoolman mode', async () => { let builtInFetched = false; server.use( http.get('/api/v1/settings/spoolman', () => HttpResponse.json({ spoolman_enabled: 'true', spoolman_url: 'http://localhost:7912' }) ), http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([mockSpoolmanSpool])), http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([])), http.get('/api/v1/inventory/assignments', () => { builtInFetched = true; return HttpResponse.json([]); }), ); const { container } = render(); await waitFor(() => expect(container.textContent).toContain('Bambu Lab')); expect(builtInFetched).toBe(false); }); it('counts spoolman slot assignments in the IN PRINTER stat card', async () => { server.use( http.get('/api/v1/settings/spoolman', () => HttpResponse.json({ spoolman_enabled: 'true', spoolman_url: 'http://localhost:7912', }) ), http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([mockSpoolmanSpool]) ), http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([ { printer_id: 1, printer_name: 'Sully', ams_id: 0, tray_id: 2, spoolman_spool_id: 216, ams_label: null, }, ]) ), ); render(); // Find the "IN PRINTER" stat card by its label, then assert the count "1" // appears within the same stat-card div. This verifies the inPrinterCount // sums Spoolman slot assignments (was 0 before Phase 8). await waitFor(() => { const label = screen.getByText(/^in printer$/i); const card = label.closest('div.bg-bambu-dark-secondary'); expect(card).not.toBeNull(); expect(within(card as HTMLElement).getByText('1')).toBeInTheDocument(); }); }); it('a built-in assignment kept from built-in mode is not shown on a Spoolman spool (#3430)', async () => { // Since #2812 built-in rows survive a switch to Spoolman. Their spool_id is // a built-in id, so built-in spool 216 in "LocalPrinter" B1 must not label // Spoolman spool 216, which Spoolman has in "SpoolmanPrinter" C4. server.use( http.get('/api/v1/settings/spoolman', () => HttpResponse.json({ spoolman_enabled: 'true', spoolman_url: 'http://localhost:7912', }) ), http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([mockSpoolmanSpool]) ), http.get('/api/v1/inventory/assignments', () => HttpResponse.json([ { id: 99, spool_id: 216, printer_id: 7, printer_name: 'LocalPrinter', ams_id: 1, tray_id: 0, ams_label: null, created_at: '2025-01-01T00:00:00Z', }, ]) ), http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([ { printer_id: 8, printer_name: 'SpoolmanPrinter', ams_id: 2, tray_id: 3, spoolman_spool_id: 216, ams_label: null, }, ]) ), ); const { container } = render(); await waitFor(() => { expect(container.textContent).toContain('SpoolmanPrinter'); expect(container.textContent).toContain('C4'); }); expect(container.textContent).not.toContain('LocalPrinter'); }); it('a spool Spoolman has in no slot shows no slot, whatever the built-in table holds (#3430)', async () => { // The reporter's case: the spool was taken out and Spoolman's row removed, // but a kept built-in row with the same number still named a slot. server.use( http.get('/api/v1/settings/spoolman', () => HttpResponse.json({ spoolman_enabled: 'true', spoolman_url: 'http://localhost:7912', }) ), http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([mockSpoolmanSpool]) ), http.get('/api/v1/inventory/assignments', () => HttpResponse.json([ { id: 99, spool_id: 216, printer_id: 7, printer_name: 'LocalPrinter', ams_id: 0, tray_id: 0, ams_label: null, created_at: '2025-01-01T00:00:00Z', }, ]) ), http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([])), ); const { container } = render(); await waitFor(() => expect(container.textContent).toContain('Bambu Lab')); expect(container.textContent).not.toContain('LocalPrinter'); // Nor counted as in a printer. const label = screen.getByText(/^in printer$/i); const card = label.closest('div.bg-bambu-dark-secondary'); expect(within(card as HTMLElement).getByText('0')).toBeInTheDocument(); }); }); describe('InventoryPage - Forecast in Spoolman mode (#3406)', () => { it("the forecast does not read the built-in inventory's usage history", async () => { localStorage.clear(); // The Spoolman spools' own rows are read (#3407), never the built-in ones. let builtInUsageRead = false; let spoolmanUsageRead = false; server.use( http.get('/api/v1/settings/', () => HttpResponse.json(mockSettings)), http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])), http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])), http.get('/api/v1/settings/spoolman', () => HttpResponse.json({ spoolman_enabled: 'true', spoolman_url: 'http://localhost:7912' }) ), http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([mockSpoolmanSpool])), http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([])), http.get('/api/v1/inventory/usage', ({ request }) => { if (new URL(request.url).searchParams.get('spoolman') === 'true') spoolmanUsageRead = true; else builtInUsageRead = true; return HttpResponse.json([]); }), ); const { container } = render(); await waitFor(() => expect(container.textContent).toContain('Bambu Lab')); screen.getByRole('button', { name: /forecast/i }).click(); // The forecast is up, with the rate from the spool's own consumption. expect(await screen.findByText('Est.')).toBeInTheDocument(); await waitFor(() => expect(spoolmanUsageRead).toBe(true)); expect(builtInUsageRead).toBe(false); }); });