Files
bambuddy/frontend/src/__tests__/components/FileUploadModal.test.tsx
T
AneoPsyandMartinNYHC fa9d3779cc [Feature]: Printer Page - Add a print button & Drop zone on the printer card (#569)
* Add printer card print flow with drag-drop and reusable LibraryUploadModal

Extract inline upload modal from FileManagerPage into a reusable
LibraryUploadModal component. Add a Print button and drag-drop zone
to printer cards that upload files to the library and open PrintModal
with the printer pre-selected. Only .gcode/.gcode.3mf files are
accepted for printing. PrintModal hides printer selector when a
printer is provided via initialSelectedPrinterIds prop.

* Add printer compatibility check for drag-drop and upload-to-print flows

Validates sliced_for_model from file metadata against the target printer model
before opening the print modal, preventing users from printing with incompatible files.

* Improve FileUploadModal: auto-close on success, inline errors, file validation, and i18n

- Close modal automatically after successful upload instead of showing summary
- Show printer compatibility errors inline in the modal instead of closing
- Add validateFile and accept props to restrict file types for print flow
- Add onFileUploaded error return to prevent modal close on incompatible files
- Internationalize all hardcoded error/warning strings across 6 locales

* Clean up incompatible files from library and fix print button i18n

- Delete uploaded file from library when printer compatibility check fails
  in both drag-drop and modal upload flows
- Fix print button tooltip to use existing common.print i18n key
- Fix accept attribute to use .gcode,.3mf for proper browser support

* Add printer information modal accessible from card 3-dot menu

Replace inline IP/serial display at bottom of printer card with a
dedicated "Printer Information" modal opened via the card menu. The
modal shows model, status, state, IP address (copyable), serial number
(copyable), WiFi signal, firmware, developer mode, nozzle count, SD
card, auto-archive, total print hours, location, and added date, along
with the printer image. Includes full i18n support across all 6 locales
and accessibility attributes (role="dialog", aria-modal).

* Extract getPrinterImage and getWifiStrength into shared printer utils

---------

Co-authored-by: MartinNYHC <mz@v8w.de>
2026-03-03 10:26:30 +01:00

655 lines
24 KiB
TypeScript

/**
* Tests for the FileUploadModal component.
* Tests file upload, drag-and-drop, ZIP/3MF/STL detection, and autoUpload mode.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { FileUploadModal } from '../../components/FileUploadModal';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
describe('FileUploadModal', () => {
const defaultProps = {
folderId: null as number | null,
onClose: vi.fn(),
onUploadComplete: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
server.use(
http.post('/api/v1/library/files', () => {
return HttpResponse.json({
id: 1,
filename: 'test.gcode.3mf',
file_type: '3mf',
file_size: 1048576,
thumbnail_path: null,
duplicate_of: null,
metadata: null,
});
}),
http.post('/api/v1/library/extract-zip', () => {
return HttpResponse.json({
extracted: 3,
errors: [],
});
})
);
});
describe('rendering', () => {
it('renders the modal with title', () => {
render(<FileUploadModal {...defaultProps} />);
expect(screen.getByText('Upload Files')).toBeInTheDocument();
});
it('renders drag and drop zone', () => {
render(<FileUploadModal {...defaultProps} />);
expect(screen.getByText(/Drag & drop/)).toBeInTheDocument();
});
it('renders click to browse text', () => {
render(<FileUploadModal {...defaultProps} />);
expect(screen.getByText(/click to browse/i)).toBeInTheDocument();
});
it('renders Cancel button', () => {
render(<FileUploadModal {...defaultProps} />);
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
});
it('renders Upload button disabled when no files', () => {
render(<FileUploadModal {...defaultProps} />);
const uploadButton = screen.getByRole('button', { name: /Upload/i });
expect(uploadButton).toBeDisabled();
});
it('shows all file types supported text', () => {
render(<FileUploadModal {...defaultProps} />);
expect(screen.getByText(/All file types supported/i)).toBeInTheDocument();
});
});
describe('file selection', () => {
it('shows added file in the list', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
const file = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
expect(screen.getByText('model.gcode.3mf')).toBeInTheDocument();
});
it('shows file size in MB', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
const file = new File(['x'.repeat(1048576)], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
expect(screen.getByText('1.00 MB')).toBeInTheDocument();
});
it('enables Upload button when files are added', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
expect(uploadButton).not.toBeDisabled();
});
it('shows file count in Upload button', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
const files = [
new File(['a'], 'file1.3mf', { type: 'application/octet-stream' }),
new File(['b'], 'file2.stl', { type: 'application/octet-stream' }),
];
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, files);
expect(screen.getByRole('button', { name: /Upload \(2\)/i })).toBeInTheDocument();
});
it('accepts any file type (not restricted like UploadModal)', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
const file = new File(['content'], 'readme.txt', { type: 'text/plain' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
expect(screen.getByText('readme.txt')).toBeInTheDocument();
});
});
describe('file removal', () => {
it('removes a file when X button is clicked', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
expect(screen.getByText('model.3mf')).toBeInTheDocument();
const fileRow = screen.getByText('model.3mf').closest('.flex');
const removeButton = fileRow?.querySelector('button');
if (removeButton) {
await user.click(removeButton);
}
await waitFor(() => {
expect(screen.queryByText('model.3mf')).not.toBeInTheDocument();
});
});
it('disables Upload button after removing all files', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const fileRow = screen.getByText('model.3mf').closest('.flex');
const removeButton = fileRow?.querySelector('button');
if (removeButton) {
await user.click(removeButton);
}
await waitFor(() => {
const uploadButton = screen.getByRole('button', { name: /Upload/i });
expect(uploadButton).toBeDisabled();
});
});
});
describe('file type detection', () => {
it('shows ZIP options when .zip file is added', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, zipFile);
await waitFor(() => {
expect(screen.getByText('ZIP files detected')).toBeInTheDocument();
expect(screen.getByText(/Preserve folder structure/)).toBeInTheDocument();
expect(screen.getByText(/Create folder from ZIP/)).toBeInTheDocument();
});
});
it('shows 3MF info when .3mf file is added', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
const threemfFile = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, threemfFile);
await waitFor(() => {
expect(screen.getByText('3MF files detected')).toBeInTheDocument();
});
});
it('shows STL thumbnail option when .stl file is added', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
const stlFile = new File(['solid'], 'bracket.stl', { type: 'application/sla' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, stlFile);
await waitFor(() => {
expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
expect(screen.getByText(/Thumbnails can be generated/i)).toBeInTheDocument();
});
});
it('shows STL thumbnail option when ZIP file is added (may contain STLs)', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, zipFile);
await waitFor(() => {
expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
expect(screen.getByText(/ZIP files may contain STL/i)).toBeInTheDocument();
});
});
});
describe('ZIP options', () => {
it('preserve structure checkbox is checked by default', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, zipFile);
await waitFor(() => {
const label = screen.getByText(/Preserve folder structure/).closest('label');
const checkbox = label?.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox).toBeChecked();
});
});
it('create folder checkbox is unchecked by default', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, zipFile);
await waitFor(() => {
const label = screen.getByText(/Create folder from ZIP/).closest('label');
const checkbox = label?.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox).not.toBeChecked();
});
});
it('can toggle ZIP options', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, zipFile);
await waitFor(() => {
expect(screen.getByText('ZIP files detected')).toBeInTheDocument();
});
const preserveLabel = screen.getByText(/Preserve folder structure/).closest('label');
const preserveCheckbox = preserveLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement;
await user.click(preserveCheckbox);
expect(preserveCheckbox).not.toBeChecked();
const createFolderLabel = screen.getByText(/Create folder from ZIP/).closest('label');
const createFolderCheckbox = createFolderLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement;
await user.click(createFolderCheckbox);
expect(createFolderCheckbox).toBeChecked();
});
});
describe('upload flow', () => {
it('calls onUploadComplete after successful upload', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
await user.click(uploadButton);
await waitFor(() => {
expect(defaultProps.onUploadComplete).toHaveBeenCalled();
});
});
it('calls onFileUploaded with response data for each file', async () => {
const onFileUploaded = vi.fn();
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} onFileUploaded={onFileUploaded} />);
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
await user.click(uploadButton);
await waitFor(() => {
expect(onFileUploaded).toHaveBeenCalledWith(
expect.objectContaining({
id: 1,
filename: 'test.gcode.3mf',
})
);
});
});
it('shows uploading state while uploading', async () => {
// Delay the response to observe uploading state
server.use(
http.post('/api/v1/library/files', async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
return HttpResponse.json({
id: 1,
filename: 'model.3mf',
file_type: '3mf',
file_size: 1024,
thumbnail_path: null,
duplicate_of: null,
metadata: null,
});
})
);
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
await user.click(uploadButton);
// Should show uploading state
await waitFor(() => {
expect(screen.getByText('Uploading...')).toBeInTheDocument();
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
});
it('shows error state on upload failure', async () => {
server.use(
http.post('/api/v1/library/files', () => {
return HttpResponse.json({ detail: 'File too large' }, { status: 413 });
})
);
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
await user.click(uploadButton);
await waitFor(() => {
expect(defaultProps.onUploadComplete).toHaveBeenCalled();
});
});
it('closes modal after manual upload completes', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
await user.click(uploadButton);
await waitFor(() => {
expect(defaultProps.onUploadComplete).toHaveBeenCalled();
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
});
describe('autoUpload mode', () => {
it('uploads immediately when file is added', async () => {
const onFileUploaded = vi.fn();
const user = userEvent.setup();
render(
<FileUploadModal
{...defaultProps}
autoUpload
onFileUploaded={onFileUploaded}
/>
);
const file = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
await waitFor(() => {
expect(onFileUploaded).toHaveBeenCalledWith(
expect.objectContaining({ id: 1 })
);
});
});
it('calls onClose after autoUpload completes', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} autoUpload />);
const file = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
await waitFor(() => {
expect(defaultProps.onClose).toHaveBeenCalled();
expect(defaultProps.onUploadComplete).toHaveBeenCalled();
});
});
});
describe('close behavior', () => {
it('calls onClose when Cancel button is clicked', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('calls onClose when X button is clicked', async () => {
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} />);
// The X button is the one in the header (not file remove buttons)
const headerButtons = screen.getByText('Upload Files').parentElement?.querySelectorAll('button');
const closeButton = headerButtons?.[0];
if (closeButton) {
await user.click(closeButton);
expect(defaultProps.onClose).toHaveBeenCalled();
}
});
it('always shows Cancel button (modal auto-closes after upload)', () => {
render(<FileUploadModal {...defaultProps} />);
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
});
});
describe('drag and drop', () => {
it('highlights drop zone on drag over', () => {
render(<FileUploadModal {...defaultProps} />);
const dropZone = screen.getByText(/Drag & drop/).closest('div[class*="border-dashed"]');
if (dropZone) {
fireEvent.dragOver(dropZone, { dataTransfer: { files: [] } });
expect(dropZone.className).toContain('border-bambu-green');
}
});
it('removes highlight on drag leave', () => {
render(<FileUploadModal {...defaultProps} />);
const dropZone = screen.getByText(/Drag & drop/).closest('div[class*="border-dashed"]');
if (dropZone) {
fireEvent.dragOver(dropZone, { dataTransfer: { files: [] } });
fireEvent.dragLeave(dropZone, { dataTransfer: { files: [] } });
expect(dropZone.className).not.toContain('bg-bambu-green');
}
});
});
describe('folder context', () => {
it('accepts folderId prop for uploading to specific folder', () => {
render(<FileUploadModal {...defaultProps} folderId={5} />);
// Component should render without errors with a folder context
expect(screen.getByText('Upload Files')).toBeInTheDocument();
});
});
describe('validateFile prop', () => {
it('rejects files that fail validation and shows error', async () => {
const user = userEvent.setup();
render(
<FileUploadModal
{...defaultProps}
validateFile={(file) => {
if (!file.name.endsWith('.gcode')) return 'Only .gcode files allowed';
}}
/>
);
const file = new File(['content'], 'model.stl', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
// Error should be shown
expect(screen.getByText('Only .gcode files allowed')).toBeInTheDocument();
// File should NOT be added to the list
expect(screen.queryByText('model.stl')).not.toBeInTheDocument();
});
it('allows files that pass validation', async () => {
const user = userEvent.setup();
render(
<FileUploadModal
{...defaultProps}
validateFile={(file) => {
if (!file.name.endsWith('.gcode')) return 'Only .gcode files allowed';
}}
/>
);
const file = new File(['content'], 'model.gcode', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
expect(screen.getByText('model.gcode')).toBeInTheDocument();
expect(screen.queryByText('Only .gcode files allowed')).not.toBeInTheDocument();
});
it('clears validation error when a new file is added', async () => {
const user = userEvent.setup();
render(
<FileUploadModal
{...defaultProps}
validateFile={(file) => {
if (!file.name.endsWith('.gcode')) return 'Only .gcode files allowed';
}}
/>
);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
// First add an invalid file
const badFile = new File(['content'], 'model.stl', { type: 'application/octet-stream' });
await user.upload(fileInput, badFile);
expect(screen.getByText('Only .gcode files allowed')).toBeInTheDocument();
// Then add a valid file — error should clear
const goodFile = new File(['content'], 'model.gcode', { type: 'application/octet-stream' });
await user.upload(fileInput, goodFile);
expect(screen.queryByText('Only .gcode files allowed')).not.toBeInTheDocument();
});
});
describe('accept prop', () => {
it('sets accept attribute on file input', () => {
render(<FileUploadModal {...defaultProps} accept=".gcode,.gcode.3mf" />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(fileInput.accept).toBe('.gcode,.gcode.3mf');
});
it('does not set accept attribute when prop is omitted', () => {
render(<FileUploadModal {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(fileInput.accept).toBe('');
});
});
describe('onFileUploaded error handling', () => {
it('shows error and keeps modal open when onFileUploaded returns a string', async () => {
const user = userEvent.setup();
render(
<FileUploadModal
{...defaultProps}
onFileUploaded={() => 'This file was sliced for the wrong printer'}
/>
);
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
await user.click(uploadButton);
await waitFor(() => {
expect(screen.getByText('This file was sliced for the wrong printer')).toBeInTheDocument();
});
// Modal should NOT close
expect(defaultProps.onClose).not.toHaveBeenCalled();
});
it('clears file list when onFileUploaded returns an error', async () => {
const user = userEvent.setup();
render(
<FileUploadModal
{...defaultProps}
onFileUploaded={() => 'Incompatible printer'}
/>
);
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
await user.click(uploadButton);
await waitFor(() => {
expect(screen.getByText('Incompatible printer')).toBeInTheDocument();
});
// File list should be cleared
expect(screen.queryByText('model.3mf')).not.toBeInTheDocument();
});
it('closes modal normally when onFileUploaded returns undefined', async () => {
const onFileUploaded = vi.fn();
const user = userEvent.setup();
render(<FileUploadModal {...defaultProps} onFileUploaded={onFileUploaded} />);
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
await user.click(uploadButton);
await waitFor(() => {
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
});
});