/**
* Tests for the SettingsPage component.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { act, fireEvent, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { render } from '../utils';
import { ThemeProvider } from '../../contexts/ThemeContext';
import { ToastProvider } from '../../contexts/ToastContext';
import { AuthProvider } from '../../contexts/AuthContext';
import { SettingsPage } from '../../pages/SettingsPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import { SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, SIDEBAR_ORDER_KEY } from '../../utils/sidebarLayout';
import { setAuthToken } from '../../api/client';
const mockSettings = {
auto_archive: true,
save_thumbnails: true,
capture_finish_photo: true,
default_filament_cost: 25.0,
currency: 'USD',
ams_humidity_good: 40,
ams_humidity_fair: 60,
ams_temp_good: 30,
ams_temp_fair: 35,
time_format: 'system',
date_format: 'system',
mqtt_enabled: false,
mqtt_host: '',
mqtt_port: 1883,
spoolman_enabled: false,
spoolman_url: '',
ha_enabled: false,
ha_url: '',
ha_token: '',
check_updates: false,
check_printer_firmware: false,
bed_cooled_threshold: 35,
};
describe('SettingsPage', () => {
beforeEach(() => {
// BrowserRouter shares window.location across tests; reset it so a tab
// switch in one test (e.g. clicking "Workflow") doesn't carry into
// sibling tests that expect to land on the default General tab.
window.history.replaceState({}, '', '/');
vi.mocked(localStorage.getItem).mockReset();
vi.mocked(localStorage.setItem).mockReset();
vi.mocked(localStorage.removeItem).mockReset();
vi.mocked(localStorage.clear).mockReset();
localStorage.clear();
setAuthToken(null);
server.use(
http.get('/api/v1/settings/', () => {
return HttpResponse.json(mockSettings);
}),
http.put('/api/v1/settings/', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ ...mockSettings, ...body });
}),
http.get('/api/v1/printers/', () => {
return HttpResponse.json([]);
}),
http.get('/api/v1/smart-plugs/', () => {
return HttpResponse.json([]);
}),
http.get('/api/v1/notifications/', () => {
return HttpResponse.json([]);
}),
http.get('/api/v1/api-keys/', () => {
return HttpResponse.json([]);
}),
http.get('/api/v1/mqtt/status', () => {
return HttpResponse.json({ enabled: false });
}),
http.get('/api/v1/virtual-printer/status', () => {
return HttpResponse.json({ running: false });
}),
http.get('/api/v1/auth/status', () => {
return HttpResponse.json({ auth_enabled: false, requires_setup: false });
}),
http.get('/api/v1/external-links/', () => {
return HttpResponse.json([]);
})
);
});
describe('rendering', () => {
it('renders the page title', async () => {
render();
await waitFor(() => {
// Use role-based query to avoid conflicts with dropdown options
expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument();
});
});
it('shows settings tabs', async () => {
render();
await waitFor(() => {
// Use getAllByText since "General" appears both as tab and section heading
expect(screen.getAllByText('General').length).toBeGreaterThan(0);
expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
expect(screen.getByText('Network')).toBeInTheDocument();
expect(screen.getByText('API Keys')).toBeInTheDocument();
});
});
});
describe('finish photo plate restore (#2547)', () => {
const restoreLabel = 'Restore plate for finish photo';
it('offers the toggle while finish photos are enabled', async () => {
render();
expect(await screen.findByText(restoreLabel)).toBeInTheDocument();
});
it('hides the toggle when finish photos are switched off', async () => {
// It only describes how the finish photo is framed, so it is meaningless
// when no finish photo is taken at all.
server.use(
http.get('/api/v1/settings/', () =>
HttpResponse.json({ ...mockSettings, capture_finish_photo: false })
)
);
render();
await screen.findByRole('heading', { name: 'Settings' });
await waitFor(() => {
expect(screen.queryByText(restoreLabel)).not.toBeInTheDocument();
});
});
it('defaults to on when the backend has never stored the setting', async () => {
// Existing installs have no row for it; the UI must not read that as off.
render();
const label = await screen.findByText(restoreLabel);
const row = label.closest('div')!.parentElement!;
expect(within(row).getByRole('checkbox')).toBeChecked();
});
it('sends the new value on save', async () => {
let saved: Record | null = null;
server.use(
http.put('/api/v1/settings/', async ({ request }) => {
saved = (await request.json()) as Record;
return HttpResponse.json({ ...mockSettings, ...saved });
})
);
render();
const label = await screen.findByText(restoreLabel);
// The page suppresses auto-save for 100ms after the settings load, so a
// click landing inside that window is swallowed with no re-trigger.
await new Promise((resolve) => setTimeout(resolve, 200));
const row = label.closest('div')!.parentElement!;
await userEvent.click(within(row).getByRole('checkbox'));
// The page auto-saves on a 500ms debounce, so the default 1s waitFor
// window is only just wide enough — give the request room to land.
await waitFor(() => {
expect(saved).not.toBeNull();
}, { timeout: 3000 });
expect(saved!.finish_photo_restore_plate).toBe(false);
});
});
describe('general settings', () => {
it('shows date format setting', async () => {
render();
await waitFor(() => {
expect(screen.getByText('Date Format')).toBeInTheDocument();
});
});
it('shows time format setting', async () => {
render();
await waitFor(() => {
expect(screen.getByText('Time Format')).toBeInTheDocument();
});
});
it('shows default printer setting', async () => {
render();
await waitFor(() => {
expect(screen.getByText('Default Printer')).toBeInTheDocument();
});
});
it('shows preferred slicer setting on Workflow tab', async () => {
const user = userEvent.setup();
render();
await waitFor(() => {
expect(screen.getByText('Workflow')).toBeInTheDocument();
});
await user.click(screen.getByText('Workflow'));
await waitFor(() => {
expect(screen.getByText('Preferred Slicer')).toBeInTheDocument();
});
});
it('shows slicer dropdown with both options on Workflow tab', async () => {
const user = userEvent.setup();
render();
await waitFor(() => {
expect(screen.getByText('Workflow')).toBeInTheDocument();
});
await user.click(screen.getByText('Workflow'));
await waitFor(() => {
const slicerSelect = screen.getAllByDisplayValue('Bambu Studio');
expect(slicerSelect.length).toBeGreaterThan(0);
});
});
it('shows appearance section', async () => {
render();
await waitFor(() => {
expect(screen.getByText('Appearance')).toBeInTheDocument();
});
});
it('shows updates section with firmware toggle', async () => {
render();
await waitFor(() => {
expect(screen.getByText('Updates')).toBeInTheDocument();
expect(screen.getByText('Check for updates')).toBeInTheDocument();
expect(screen.getByText('Check printer firmware')).toBeInTheDocument();
});
});
it('hides a Bambuddy sidebar page from Sidebar', async () => {
const user = userEvent.setup();
render();
await screen.findByRole('heading', { name: 'Sidebar' });
await screen.findAllByText('Visible in sidebar');
vi.mocked(localStorage.setItem).mockClear();
await user.click((await screen.findAllByLabelText('Hide page'))[0]);
expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify(['printers']));
expect(screen.getByText('Hidden from sidebar')).toBeInTheDocument();
});
it('shows a previously hidden Bambuddy sidebar page from Sidebar', async () => {
vi.mocked(localStorage.getItem).mockImplementation((key) => {
if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers']);
return null;
});
const user = userEvent.setup();
render();
await screen.findByRole('heading', { name: 'Sidebar' });
await screen.findByText('Hidden from sidebar');
vi.mocked(localStorage.setItem).mockClear();
await user.click(await screen.findByLabelText('Show page'));
expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
expect(screen.getAllByText('Visible in sidebar').length).toBeGreaterThan(0);
});
it('does not allow Settings to be hidden from Sidebar', async () => {
render();
await screen.findByRole('heading', { name: 'Sidebar' });
await screen.findByText('Required in sidebar');
const settingsVisibilityButton = await screen.findByLabelText('Settings cannot be hidden');
expect(settingsVisibilityButton).toBeDisabled();
expect(screen.getByText('Required in sidebar')).toBeInTheDocument();
});
it('presents external links and Bambuddy pages in saved sidebar order', async () => {
vi.mocked(localStorage.getItem).mockImplementation((key) => {
if (key === SIDEBAR_ORDER_KEY) return JSON.stringify(['ext-7', 'printers', 'settings']);
return null;
});
server.use(
http.get('/api/v1/external-links/', () =>
HttpResponse.json([
{
id: 7,
name: 'Docs',
url: 'https://docs.example.test',
icon: 'Link',
open_in_new_tab: true,
custom_icon: null,
sort_order: 0,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
]),
),
);
render();
await screen.findByRole('heading', { name: 'Sidebar' });
const docs = await screen.findByText('Docs');
const printers = screen.getAllByText('Printers').find(element => element.closest('[draggable="true"]'));
expect(printers).toBeDefined();
expect(docs.compareDocumentPosition(printers) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('saves mixed Sidebar order when items are dragged', async () => {
server.use(
http.get('/api/v1/external-links/', () =>
HttpResponse.json([
{
id: 7,
name: 'Docs',
url: 'https://docs.example.test',
icon: 'Link',
open_in_new_tab: true,
custom_icon: null,
sort_order: 0,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
]),
),
);
render();
await screen.findByRole('heading', { name: 'Sidebar' });
const docsRow = (await screen.findByText('Docs')).closest('[draggable="true"]');
const printersRow = screen.getAllByText('Printers')
.find(element => element.closest('[draggable="true"]'))
?.closest('[draggable="true"]');
expect(docsRow).not.toBeNull();
expect(printersRow).not.toBeNull();
vi.mocked(localStorage.setItem).mockClear();
const dataTransfer = {
effectAllowed: '',
dropEffect: '',
setData: vi.fn(),
};
fireEvent.dragStart(docsRow!, { dataTransfer });
fireEvent.dragOver(printersRow!, { dataTransfer });
fireEvent.drop(printersRow!, { dataTransfer });
expect(localStorage.setItem).toHaveBeenCalledWith(
SIDEBAR_ORDER_KEY,
JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'notifications', 'settings']),
);
});
it('resets Sidebar to all pages first and configured links at the bottom', async () => {
vi.mocked(localStorage.getItem).mockImplementation((key) => {
if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers', 'stats']);
if (key === SIDEBAR_ORDER_KEY) return JSON.stringify(['ext-7', 'settings', 'printers']);
return null;
});
server.use(
http.get('/api/v1/external-links/', () =>
HttpResponse.json([
{
id: 7,
name: 'Docs',
url: 'https://docs.example.test',
icon: 'Link',
open_in_new_tab: true,
custom_icon: null,
sort_order: 0,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
]),
),
);
const user = userEvent.setup();
render();
const heading = await screen.findByRole('heading', { name: 'Sidebar' });
const card = heading.closest('#card-sidebar-links');
expect(card).not.toBeNull();
await screen.findByText('Docs');
vi.mocked(localStorage.setItem).mockClear();
await user.click(within(card as HTMLElement).getByRole('button', { name: /reset/i }));
expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
expect(localStorage.setItem).toHaveBeenCalledWith(
SIDEBAR_ORDER_KEY,
JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'notifications', 'settings', 'ext-7']),
);
const settingsRow = screen.getAllByText('Settings')
.find(element => element.closest('[draggable="true"]'))
?.closest('[draggable="true"]');
const docsRow = screen.getByText('Docs').closest('[draggable="true"]');
expect(settingsRow).not.toBeNull();
expect(docsRow).not.toBeNull();
expect(settingsRow!.compareDocumentPosition(docsRow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.queryByText('Hidden from sidebar')).not.toBeInTheDocument();
});
it('sets the current Sidebar order as the backend default for settings admins', async () => {
let defaultSidebarOrderPayload: string | null = null;
vi.mocked(localStorage.getItem).mockImplementation((key) => {
if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['stats']);
return null;
});
server.use(
http.get('/api/v1/auth/status', () =>
HttpResponse.json({ auth_enabled: true, requires_setup: false }),
),
http.get('/api/v1/auth/me', () =>
HttpResponse.json({
id: 1,
username: 'admin',
role: 'admin',
is_active: true,
is_admin: false,
groups: [{ id: 1, name: 'Administrators' }],
permissions: ['settings:update'],
created_at: '2026-01-01T00:00:00Z',
}),
),
http.get('/api/v1/settings/', () =>
HttpResponse.json({ ...mockSettings, default_sidebar_order: '' }),
),
http.put('/api/v1/settings/', async ({ request }) => {
const body = await request.json() as { default_sidebar_order?: string };
defaultSidebarOrderPayload = body.default_sidebar_order ?? null;
return HttpResponse.json({ ...mockSettings, ...body });
}),
);
setAuthToken('test-token');
const user = userEvent.setup();
render();
const heading = await screen.findByRole('heading', { name: 'Sidebar' });
const card = heading.closest('#card-sidebar-links');
expect(card).not.toBeNull();
await user.click(within(card as HTMLElement).getByRole('switch', { name: 'Set Default' }));
await waitFor(() => {
expect(defaultSidebarOrderPayload).not.toBeNull();
});
expect(JSON.parse(defaultSidebarOrderPayload!)).toEqual({
order: [
'printers',
'inventory',
'archives',
'queue',
'projects',
'files',
'makerworld',
'profiles',
'maintenance',
'stats',
'notifications',
'settings',
],
hiddenSystemItemIds: ['stats'],
});
});
});
describe('update CTA per deployment shape', () => {
// The update card branches on the deployment shape returned by
// /updates/check. Each branch is mutually exclusive — verify the right
// one wins so HA addon users never see the docker-compose snippet
// (which they can't run from inside an HA addon container) and Docker
// users never see the in-app Install button (which would no-op).
const renderWithUpdateCheck = async (
checkBody: Record,
) => {
server.use(
http.get('/api/v1/settings/', () =>
HttpResponse.json({ ...mockSettings, check_updates: true }),
),
http.get('/api/v1/updates/check', () => HttpResponse.json(checkBody)),
);
render();
await waitFor(() => {
expect(screen.getByText('Updates')).toBeInTheDocument();
});
};
it('shows the HA Supervisor message when running as an HA addon', async () => {
await renderWithUpdateCheck({
update_available: true,
current_version: '0.2.4',
latest_version: '0.2.5',
release_name: '0.2.5',
release_notes: '',
release_url: 'https://example.invalid/r',
published_at: '2099-01-01T00:00:00Z',
is_docker: true,
is_ha_addon: true,
update_method: 'ha_addon',
});
await waitFor(() => {
expect(
screen.getByText(/Home Assistant Supervisor/i),
).toBeInTheDocument();
});
// Docker hint must NOT render — HA branch wins.
expect(screen.queryByText('docker compose pull && docker compose up -d')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
});
it('shows the docker-compose snippet for Docker (non-HA) deployments', async () => {
await renderWithUpdateCheck({
update_available: true,
current_version: '0.2.4',
latest_version: '0.2.5',
release_name: '0.2.5',
release_notes: '',
release_url: 'https://example.invalid/r',
published_at: '2099-01-01T00:00:00Z',
is_docker: true,
is_ha_addon: false,
update_method: 'docker',
});
await waitFor(() => {
expect(screen.getByText('docker compose pull && docker compose up -d')).toBeInTheDocument();
});
expect(screen.queryByText(/Home Assistant Supervisor/i)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
});
it('shows the installer-download link for Windows installer installs', async () => {
const downloadUrl =
'https://github.com/maziggy/bambuddy/releases/download/v0.2.5/bambuddy-0.2.5-windows-x64-setup.exe';
await renderWithUpdateCheck({
update_available: true,
current_version: '0.2.4',
latest_version: '0.2.5',
release_name: '0.2.5',
release_notes: '',
release_url: 'https://github.com/maziggy/bambuddy/releases/tag/v0.2.5',
published_at: '2099-01-01T00:00:00Z',
is_docker: false,
is_ha_addon: false,
is_windows_installer: true,
update_method: 'windows_installer',
installer_download_url: downloadUrl,
});
const link = await screen.findByRole('link', { name: /download installer for v0\.2\.5/i });
expect(link).toHaveAttribute('href', downloadUrl);
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
// The in-app update button must NOT render — the git-fetch path can't
// work from an installer payload.
expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
expect(screen.queryByText(/Home Assistant Supervisor/i)).not.toBeInTheDocument();
expect(screen.queryByText('docker compose pull && docker compose up -d')).not.toBeInTheDocument();
});
});
describe('tabs navigation', () => {
it('can switch to Network tab', async () => {
const user = userEvent.setup();
render();
// Wait for settings to load first
await waitFor(() => {
expect(screen.getByText('Date Format')).toBeInTheDocument();
});
await user.click(screen.getByText('Network'));
await waitFor(() => {
// Network tab contains MQTT Publishing section
expect(screen.getByText('MQTT Publishing')).toBeInTheDocument();
});
});
it('can switch to Smart Plugs tab', async () => {
const user = userEvent.setup();
render();
await waitFor(() => {
expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
});
await user.click(screen.getByText('Smart Plugs'));
await waitFor(() => {
expect(screen.getByText('Add Smart Plug')).toBeInTheDocument();
});
});
it('can switch to Notifications tab', async () => {
const user = userEvent.setup();
render();
await waitFor(() => {
expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
});
// Click the tab button (not the mobile dropdown option)
const notificationButtons = screen.getAllByText('Notifications');
const tabButton = notificationButtons.find(el => el.tagName === 'BUTTON') || notificationButtons[0];
await user.click(tabButton);
await waitFor(() => {
expect(screen.getByText('Add Provider')).toBeInTheDocument();
});
});
it('can switch to Filament tab', async () => {
const user = userEvent.setup();
render();
await waitFor(() => {
expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
});
await user.click(screen.getAllByText('Filament')[0]);
await waitFor(() => {
expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();
});
});
});
describe('Workflow tab', () => {
it('can switch to Workflow tab', async () => {
const user = userEvent.setup();
render();
await waitFor(() => {
expect(screen.getByText('Workflow')).toBeInTheDocument();
});
await user.click(screen.getByText('Workflow'));
await waitFor(() => {
expect(screen.getByText('Staggered Start')).toBeInTheDocument();
});
});
it('shows stagger settings on Workflow tab', async () => {
const user = userEvent.setup();
render();
await waitFor(() => {
expect(screen.getByText('Workflow')).toBeInTheDocument();
});
await user.click(screen.getByText('Workflow'));
await waitFor(() => {
expect(screen.getByText('Staggered Start')).toBeInTheDocument();
expect(screen.getByText('Group size')).toBeInTheDocument();
expect(screen.getByText('Interval (minutes)')).toBeInTheDocument();
});
});
it('shows auto-drying settings on Workflow tab', async () => {
const user = userEvent.setup();
render();
await waitFor(() => {
expect(screen.getByText('Workflow')).toBeInTheDocument();
});
await user.click(screen.getByText('Workflow'));
await waitFor(() => {
expect(screen.getByText('Queue Auto-Drying')).toBeInTheDocument();
});
});
it('shows per-filament humidity threshold editor on Workflow tab (#1605)', async () => {
const user = userEvent.setup();
render();
await waitFor(() => {
expect(screen.getByText('Workflow')).toBeInTheDocument();
});
await user.click(screen.getByText('Workflow'));
await waitFor(() => {
expect(screen.getByText('Humidity Thresholds')).toBeInTheDocument();
// Default row is unique to the humidity editor (drying presets has no
// default row), so we can pin it without disambiguating from the
// adjacent drying-presets table that also lists PLA/ASA/etc.
expect(screen.getByText('Default (unknown types)')).toBeInTheDocument();
// Filament rows render in both tables — assert by count instead of
// a single getByText. 8 default filaments × 2 tables = 16 PLAs etc.
expect(screen.getAllByText('PLA').length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText('ASA').length).toBeGreaterThanOrEqual(2);
});
});
it('shows default print options on Workflow tab', async () => {
const user = userEvent.setup();
render();
await waitFor(() => {
expect(screen.getByText('Workflow')).toBeInTheDocument();
});
await user.click(screen.getByText('Workflow'));
await waitFor(() => {
expect(screen.getByText('Default Print Options')).toBeInTheDocument();
expect(screen.getByText('Bed Levelling')).toBeInTheDocument();
expect(screen.getByText('Flow Calibration')).toBeInTheDocument();
expect(screen.getByText('Vibration Calibration')).toBeInTheDocument();
expect(screen.getByText('First Layer Inspection')).toBeInTheDocument();
expect(screen.getByText('Timelapse')).toBeInTheDocument();
});
});
it('shows default print options description', async () => {
const user = userEvent.setup();
render();
await waitFor(() => {
expect(screen.getByText('Workflow')).toBeInTheDocument();
});
await user.click(screen.getByText('Workflow'));
await waitFor(() => {
expect(screen.getByText(/overridden per print in the print dialog/)).toBeInTheDocument();
});
});
});
describe('API Keys tab', () => {
it('can switch to API Keys tab', async () => {
const user = userEvent.setup();
render();
await waitFor(() => {
expect(screen.getByText('API Keys')).toBeInTheDocument();
});
await user.click(screen.getByText('API Keys'));
await waitFor(() => {
// Button text is "Create Key"
expect(screen.getByText('Create Key')).toBeInTheDocument();
});
});
});
describe('SpoolBuddy tab badge', () => {
const baseDevice = {
id: 1,
device_id: 'sb-0001',
hostname: 'sb-kitchen',
ip_address: '10.0.0.1',
backend_url: null,
firmware_version: '1.0.0',
has_nfc: true,
has_scale: true,
tare_offset: 0,
calibration_factor: 1.0,
nfc_reader_type: null,
nfc_connection: null,
display_brightness: 100,
display_blank_timeout: 0,
has_backlight: false,
last_calibrated_at: null,
last_seen: new Date().toISOString(),
pending_command: null,
nfc_ok: true,
scale_ok: true,
uptime_s: 100,
update_status: null,
update_message: null,
system_stats: null,
online: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
it('shows device count and green bullet when at least one device is online', async () => {
server.use(
http.get('/api/v1/spoolbuddy/devices', () => {
return HttpResponse.json([
{ ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'sb-kitchen', online: true },
{ ...baseDevice, id: 2, device_id: 'sb-0002', hostname: 'sb-ghost', online: false },
]);
})
);
render();
// Find the tab button (not the header) — it's the