v0.2.2.2 (#830)
This commit is contained in:
MartinNYHC
2026-03-27 09:27:47 +01:00
committed by GitHub
parent 8270030687
commit 67f91d94de
112 changed files with 18595 additions and 9993 deletions
@@ -43,6 +43,7 @@ const createMockPlug = (overrides: Partial<SmartPlug> = {}): SmartPlug => ({
enabled: true,
auto_on: true,
auto_off: true,
auto_off_persistent: false,
off_delay_mode: 'time',
off_delay_minutes: 5,
off_temp_threshold: 70,
@@ -241,6 +242,51 @@ describe('SmartPlugCard', () => {
});
});
describe('persistent auto-off', () => {
it('shows Keep Enabled toggle when auto_off is enabled', async () => {
const user = userEvent.setup();
const plug = createMockPlug({ auto_off: true, auto_off_persistent: false });
render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
await user.click(screen.getByText('Automation Settings'));
await waitFor(() => {
expect(screen.getByText('Keep Enabled')).toBeInTheDocument();
expect(screen.getByText('Stay enabled between prints instead of one-shot')).toBeInTheDocument();
});
});
it('does not show Keep Enabled toggle when auto_off is disabled', async () => {
const user = userEvent.setup();
const plug = createMockPlug({ auto_off: false });
render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
await user.click(screen.getByText('Automation Settings'));
await waitFor(() => {
expect(screen.queryByText('Keep Enabled')).not.toBeInTheDocument();
});
});
it('shows Keep Enabled toggle for HA plugs with auto_off enabled', async () => {
const user = userEvent.setup();
const plug = createMockPlug({
plug_type: 'homeassistant',
ip_address: null,
ha_entity_id: 'switch.bentobox_filter',
auto_off: true,
auto_off_persistent: true,
});
render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
await user.click(screen.getByText('Automation Settings'));
await waitFor(() => {
expect(screen.getByText('Keep Enabled')).toBeInTheDocument();
});
});
});
describe('disabled state', () => {
it('renders plug even when disabled', () => {
const plug = createMockPlug({ enabled: false });
@@ -9,11 +9,26 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastProvider } from '../../contexts/ToastContext';
// Track WebSocket instances created during tests
let wsInstances: MockWebSocket[] = [];
let originalWebSocket: typeof WebSocket;
// Mock react-i18next BEFORE any modules that use it are imported
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (key === 'printers.toast.missingSpoolAssignment' && options) {
const { printer, slots } = options as { printer: string; slots: string };
return `Missing assignments for ${printer}: ${slots}`;
}
return key;
},
i18n: {},
}),
}));
// Enhanced MockWebSocket that tracks instances
class MockWebSocket {
static readonly CONNECTING = 0;
@@ -77,13 +92,17 @@ function createTestQueryClient() {
});
}
// Wrapper with QueryClient for hook testing
// Wrapper with QueryClient and ToastProvider for hook testing
function createWrapper(queryClient: QueryClient) {
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(
QueryClientProvider,
{ client: queryClient },
children
ToastProvider,
{},
React.createElement(
QueryClientProvider,
{ client: queryClient },
children
)
);
};
}
@@ -99,6 +118,7 @@ describe('useWebSocket hook', () => {
vi.clearAllMocks();
wsInstances = [];
queryClient = createTestQueryClient();
// Save original and install mock
originalWebSocket = globalThis.WebSocket;
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
@@ -164,8 +184,6 @@ describe('useWebSocket hook', () => {
describe('hook connection', () => {
it('connects to WebSocket on mount', async () => {
// Reset module cache to get fresh import with our mock
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
renderHook(() => useWebSocket(), {
@@ -178,7 +196,6 @@ describe('useWebSocket hook', () => {
});
it('reports connected state when WebSocket opens', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const { result } = renderHook(() => useWebSocket(), {
@@ -260,7 +277,6 @@ describe('useWebSocket hook', () => {
cb(0);
return 0;
});
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -303,7 +319,6 @@ describe('useWebSocket hook', () => {
cb(0);
return 0;
});
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -345,7 +360,6 @@ describe('useWebSocket hook', () => {
cb(0);
return 0;
});
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -380,8 +394,39 @@ describe('useWebSocket hook', () => {
vi.unstubAllGlobals();
});
it('handles missing_spool_assignment message without error', async () => {
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const { useWebSocket } = await import('../../hooks/useWebSocket');
renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
act(() => {
ws.open();
});
// This test verifies that the hook properly handles missing_spool_assignment messages
// without throwing an error. The actual toast display is tested via the UI.
expect(() => {
act(() => {
ws.simulateMessage({
type: 'missing_spool_assignment',
printer_id: 7,
printer_name: 'Printer B',
missing_slots: [{ slot: 'A2' }, { slot: 'Ext-L' }],
});
});
}).not.toThrow();
vi.unstubAllGlobals();
});
it('ignores pong messages without error', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -409,7 +454,6 @@ describe('useWebSocket hook', () => {
});
it('handles malformed JSON gracefully', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
renderHook(() => useWebSocket(), {
@@ -438,7 +482,6 @@ describe('useWebSocket hook', () => {
});
it('handles unknown message types gracefully', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -470,7 +513,6 @@ describe('useWebSocket hook', () => {
describe('sendMessage', () => {
it('sends JSON message when connected', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const { result } = renderHook(() => useWebSocket(), {
@@ -494,7 +536,6 @@ describe('useWebSocket hook', () => {
});
it('does not send when disconnected', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const { result } = renderHook(() => useWebSocket(), {
@@ -516,7 +557,6 @@ describe('useWebSocket hook', () => {
describe('reconnection', () => {
it('reconnects after connection closes', async () => {
vi.useFakeTimers();
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
@@ -551,7 +591,6 @@ describe('useWebSocket hook', () => {
});
it('cleans up on unmount', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const { unmount } = renderHook(() => useWebSocket(), {
@@ -0,0 +1,302 @@
/**
* Tests for External Folder functionality in FileManagerPage.
*/
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 { FileManagerPage } from '../../pages/FileManagerPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
// Mock data with external folder
const mockFoldersWithExternal = [
{
id: 1,
name: 'Regular Folder',
parent_id: null,
file_count: 3,
project_id: null,
archive_id: null,
project_name: null,
archive_name: null,
is_external: false,
external_path: null,
external_readonly: false,
children: [],
},
{
id: 2,
name: 'NAS Prints',
parent_id: null,
file_count: 5,
project_id: null,
archive_id: null,
project_name: null,
archive_name: null,
is_external: true,
external_path: '/mnt/nas/prints',
external_readonly: true,
children: [],
},
{
id: 3,
name: 'USB Drive',
parent_id: null,
file_count: 2,
project_id: null,
archive_id: null,
project_name: null,
archive_name: null,
is_external: true,
external_path: '/mnt/usb',
external_readonly: false,
children: [],
},
];
const mockFiles = [
{
id: 1,
filename: 'benchy.3mf',
file_path: '/mnt/nas/prints/benchy.3mf',
file_size: 1048576,
file_type: '3mf',
folder_id: 2,
is_external: true,
thumbnail_path: null,
print_name: 'Benchy',
print_time_seconds: 3600,
print_count: 0,
duplicate_count: 0,
created_at: '2024-01-01T00:00:00Z',
},
];
const mockStats = {
total_files: 10,
total_folders: 3,
total_size_bytes: 104857600,
disk_free_bytes: 10737418240,
disk_total_bytes: 107374182400,
};
describe('FileManagerPage - External Folders', () => {
beforeEach(() => {
localStorage.clear();
server.use(
http.get('/api/v1/library/folders', () => {
return HttpResponse.json(mockFoldersWithExternal);
}),
http.get('/api/v1/library/files', () => {
return HttpResponse.json(mockFiles);
}),
http.get('/api/v1/library/stats', () => {
return HttpResponse.json(mockStats);
}),
http.get('/api/v1/settings/', () => {
return HttpResponse.json({
check_updates: false,
check_printer_firmware: false,
library_disk_warning_gb: 5,
});
}),
http.post('/api/v1/library/folders/external', async ({ request }) => {
const body = await request.json() as { name: string; external_path: string };
return HttpResponse.json({
id: 10,
name: body.name,
parent_id: null,
is_external: true,
external_path: body.external_path,
external_readonly: true,
external_show_hidden: false,
file_count: 0,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
});
}),
http.post('/api/v1/library/folders/:id/scan', () => {
return HttpResponse.json({ status: 'success', added: 3, removed: 0 });
}),
http.get('/api/v1/projects/', () => {
return HttpResponse.json([]);
}),
http.get('/api/v1/archives/', () => {
return HttpResponse.json([]);
}),
http.delete('/api/v1/library/folders/:id', () => {
return HttpResponse.json({ success: true });
}),
http.delete('/api/v1/library/files/:id', () => {
return HttpResponse.json({ success: true });
}),
http.post('/api/v1/library/files/move', () => {
return HttpResponse.json({ success: true });
}),
);
});
describe('rendering', () => {
it('shows Link External button', async () => {
render(<FileManagerPage />);
await waitFor(() => {
expect(screen.getByText('Link External')).toBeInTheDocument();
});
});
it('shows external folder in sidebar', async () => {
render(<FileManagerPage />);
await waitFor(() => {
expect(screen.getByText('NAS Prints')).toBeInTheDocument();
expect(screen.getByText('USB Drive')).toBeInTheDocument();
});
});
it('shows regular folder alongside external', async () => {
render(<FileManagerPage />);
await waitFor(() => {
expect(screen.getByText('Regular Folder')).toBeInTheDocument();
expect(screen.getByText('NAS Prints')).toBeInTheDocument();
});
});
it('shows read-only indicator for readonly external folders', async () => {
render(<FileManagerPage />);
await waitFor(() => {
// NAS Prints is readonly, should have a lock icon title
const lockIcons = document.querySelectorAll('[title="Read Only"]');
expect(lockIcons.length).toBeGreaterThan(0);
});
});
});
describe('external folder modal', () => {
it('opens modal when Link External clicked', async () => {
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => {
expect(screen.getByText('Link External')).toBeInTheDocument();
});
await user.click(screen.getByText('Link External'));
await waitFor(() => {
expect(screen.getByText('Link External Folder')).toBeInTheDocument();
});
});
it('modal has name and path fields', async () => {
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => {
expect(screen.getByText('Link External')).toBeInTheDocument();
});
await user.click(screen.getByText('Link External'));
await waitFor(() => {
expect(screen.getByPlaceholderText('e.g., NAS Prints')).toBeInTheDocument();
expect(screen.getByPlaceholderText('/mnt/nas/3d-prints')).toBeInTheDocument();
});
});
it('modal has readonly checkbox checked by default', async () => {
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => {
expect(screen.getByText('Link External')).toBeInTheDocument();
});
await user.click(screen.getByText('Link External'));
await waitFor(() => {
const readonlyCheckbox = screen.getByText('Read Only').previousElementSibling as HTMLInputElement;
expect(readonlyCheckbox).toBeChecked();
});
});
it('modal can be closed', async () => {
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => {
expect(screen.getByText('Link External')).toBeInTheDocument();
});
await user.click(screen.getByText('Link External'));
await waitFor(() => {
expect(screen.getByText('Link External Folder')).toBeInTheDocument();
});
await user.click(screen.getByText('Cancel'));
await waitFor(() => {
expect(screen.queryByText('Link External Folder')).not.toBeInTheDocument();
});
});
});
describe('external folder info bar', () => {
it('shows info bar when external folder selected', async () => {
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => {
expect(screen.getByText('NAS Prints')).toBeInTheDocument();
});
// Click on NAS Prints folder - there are multiple elements, get the one in the sidebar
const folderElements = screen.getAllByText('NAS Prints');
await user.click(folderElements[0]);
await waitFor(() => {
expect(screen.getByText('External Folder')).toBeInTheDocument();
expect(screen.getByText('/mnt/nas/prints')).toBeInTheDocument();
});
});
it('shows scan button for external folders', async () => {
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => {
expect(screen.getByText('NAS Prints')).toBeInTheDocument();
});
const folderElements = screen.getAllByText('NAS Prints');
await user.click(folderElements[0]);
await waitFor(() => {
expect(screen.getByText('Scan')).toBeInTheDocument();
});
});
it('does not show info bar for regular folders', async () => {
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => {
expect(screen.getByText('Regular Folder')).toBeInTheDocument();
});
const folderElements = screen.getAllByText('Regular Folder');
await user.click(folderElements[0]);
// External Folder label should NOT appear
await waitFor(() => {
expect(screen.queryByText('External Folder')).not.toBeInTheDocument();
});
});
});
});
@@ -1,8 +1,9 @@
/**
* Tests for SpoolBuddySettingsPage:
* - Renders 4 tabs (Device, Display, Scale, Updates)
* - Renders 5 tabs (Device, Display, Scale, Updates, System)
* - Device tab shows hostname, IP, NFC status
* - Updates tab shows "Check for Updates" button
* - System tab shows OS stats
* - Tab switching works
*/
@@ -39,6 +40,15 @@ vi.mock('../../api/client', () => ({
uptime_s: 3600,
update_status: null,
update_message: null,
system_stats: {
os: { os: 'Raspbian GNU/Linux 12', kernel: '6.1.0-rpi7', arch: 'aarch64', python: '3.11.2' },
cpu_temp_c: 52.1,
cpu_count: 4,
load_avg: [0.15, 0.22, 0.18],
memory: { total_mb: 1024, available_mb: 512, used_mb: 512, percent: 50.0 },
disk: { total_gb: 29.7, used_gb: 8.2, free_gb: 21.5, percent: 27.6 },
system_uptime_s: 86400,
},
online: true,
}]),
updateDisplay: vi.fn().mockResolvedValue({ status: 'ok' }),
@@ -50,6 +60,7 @@ vi.mock('../../api/client', () => ({
update_available: false,
}),
triggerUpdate: vi.fn().mockResolvedValue({ status: 'ok', message: '' }),
getSSHPublicKey: vi.fn().mockResolvedValue({ public_key: 'ssh-ed25519 AAAA test-key' }),
},
}));
@@ -105,13 +116,14 @@ describe('SpoolBuddySettingsPage', () => {
vi.clearAllMocks();
});
it('renders 4 tabs', async () => {
it('renders 5 tabs', async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText('Device')).toBeDefined();
expect(screen.getByText('Display')).toBeDefined();
expect(screen.getByText('Scale')).toBeDefined();
expect(screen.getByText('Updates')).toBeDefined();
expect(screen.getByText('System')).toBeDefined();
});
});
@@ -137,7 +149,7 @@ describe('SpoolBuddySettingsPage', () => {
});
});
it('switching to Updates tab shows Check for Updates button', async () => {
it('switching to Updates tab shows Check for Updates and Force Update buttons', async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText('Updates')).toBeDefined();
@@ -145,6 +157,46 @@ describe('SpoolBuddySettingsPage', () => {
fireEvent.click(screen.getByText('Updates'));
await waitFor(() => {
expect(screen.getByText('Check for Updates')).toBeDefined();
expect(screen.getByText('Force Update')).toBeDefined();
});
});
it('Updates tab shows current version', async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText('Updates')).toBeDefined();
});
fireEvent.click(screen.getByText('Updates'));
await waitFor(() => {
expect(screen.getByText('1.2.3')).toBeDefined();
});
});
it('Updates tab shows SSH Setup section', async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText('Updates')).toBeDefined();
});
fireEvent.click(screen.getByText('Updates'));
await waitFor(() => {
expect(screen.getByText('SSH Setup')).toBeDefined();
});
});
it('Updates tab shows Apply Update when update is available', async () => {
const { spoolbuddyApi } = await import('../../api/client');
vi.mocked(spoolbuddyApi.checkDaemonUpdate).mockResolvedValue({
current_version: '1.2.3',
latest_version: '1.3.0',
update_available: true,
});
renderPage();
await waitFor(() => {
expect(screen.getByText('Updates')).toBeDefined();
});
fireEvent.click(screen.getByText('Updates'));
await waitFor(() => {
expect(screen.getByText('Apply Update')).toBeDefined();
});
});
@@ -170,4 +222,92 @@ describe('SpoolBuddySettingsPage', () => {
expect(screen.getByText('Calibrate')).toBeDefined();
});
});
it('System tab shows CPU info', async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText('System')).toBeDefined();
});
fireEvent.click(screen.getByText('System'));
await waitFor(() => {
expect(screen.getByText('CPU')).toBeDefined();
expect(screen.getByText('4')).toBeDefined(); // cpu_count
expect(screen.getByText('0.15 / 0.22 / 0.18')).toBeDefined(); // load_avg
});
});
it('System tab shows memory stats', async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText('System')).toBeDefined();
});
fireEvent.click(screen.getByText('System'));
await waitFor(() => {
expect(screen.getByText('Memory')).toBeDefined();
expect(screen.getByText('512 / 1024 MB')).toBeDefined();
});
});
it('System tab shows disk stats', async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText('System')).toBeDefined();
});
fireEvent.click(screen.getByText('System'));
await waitFor(() => {
expect(screen.getByText('Disk')).toBeDefined();
expect(screen.getByText('8.2 / 29.7 GB')).toBeDefined();
});
});
it('System tab shows OS info', async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText('System')).toBeDefined();
});
fireEvent.click(screen.getByText('System'));
await waitFor(() => {
expect(screen.getByText('Raspbian GNU/Linux 12')).toBeDefined();
expect(screen.getByText('aarch64')).toBeDefined();
expect(screen.getByText('3.11.2')).toBeDefined();
});
});
it('System tab shows waiting message when no stats', async () => {
const { spoolbuddyApi } = await import('../../api/client');
vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([{
id: 1,
device_id: 'sb-test-001',
hostname: 'spoolbuddy-pi',
ip_address: '192.168.1.100',
firmware_version: '1.2.3',
has_nfc: true,
has_scale: true,
tare_offset: 0,
calibration_factor: 1.0,
nfc_reader_type: 'PN532',
nfc_connection: 'I2C',
display_brightness: 80,
display_blank_timeout: 300,
has_backlight: true,
last_calibrated_at: null,
last_seen: '2026-03-22T12:00:00Z',
pending_command: null,
nfc_ok: true,
scale_ok: true,
uptime_s: 3600,
update_status: null,
update_message: null,
system_stats: null,
online: true,
}]);
renderPage();
await waitFor(() => {
expect(screen.getByText('System')).toBeDefined();
});
fireEvent.click(screen.getByText('System'));
await waitFor(() => {
expect(screen.getByText('Waiting for system stats...')).toBeDefined();
});
});
});
@@ -26,6 +26,10 @@ describe('getCurrencySymbol', () => {
expect(getCurrencySymbol('MYR')).toBe('RM');
});
it('returns ₴ for UAH', () => {
expect(getCurrencySymbol('UAH')).toBe('₴');
});
it('returns the code itself for unknown currencies', () => {
expect(getCurrencySymbol('XYZ')).toBe('XYZ');
});
@@ -46,6 +50,6 @@ describe('SUPPORTED_CURRENCIES', () => {
});
it('has 28 entries', () => {
expect(SUPPORTED_CURRENCIES).toHaveLength(28);
expect(SUPPORTED_CURRENCIES).toHaveLength(29);
});
});