/** * The full-page G-code preview. * * This used to be an iframe onto a vendored copy of PrettyGCode, and most of * the page was machinery for detecting when a proxy refused the embed. It now * renders Bambuddy's own toolpath viewer directly, so what is worth testing is * that the right file reaches it -- including the plate, which a multi-plate * archive needs or the viewer silently shows a different plate than the one * that was picked. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { render } from '../utils'; import { server } from '../mocks/server'; import { GCodeViewerPage } from '../../pages/GCodeViewerPage'; // The viewer itself needs WebGL, which jsdom has no answer for. Its own // behaviour is covered by the parser and renderer tests; here we only care // which URL the page hands it. vi.mock('../../components/GcodeToolpathViewer', () => ({ GcodeToolpathViewer: ({ gcodeUrl, filamentColors }: { gcodeUrl: string; filamentColors?: string[] }) => (
), })); function visit(search: string) { window.history.pushState({}, '', `/gcode-viewer${search}`); render(); } const viewerUrl = () => screen.getByTestId('toolpath-viewer').getAttribute('data-url'); const plate = (index: number, color: string) => ({ index, name: null, objects: [], has_thumbnail: false, thumbnail_url: null, print_time_seconds: null, filament_used_grams: null, filaments: [{ slot_id: 1, type: 'PLA', color, used_grams: 1, used_meters: 1 }], }); const TWO_PLATES = { file_id: 7, filename: 'two.gcode.3mf', is_multi_plate: true, plates: [plate(1, '#ff0000'), plate(2, '#00ff00')], }; describe('GCodeViewerPage', () => { const originalUrl = window.location.href; beforeEach(() => { window.history.pushState({}, '', '/gcode-viewer'); }); afterEach(() => { window.history.pushState({}, '', originalUrl); }); it('previews an archive', () => { visit('?archive=82'); expect(viewerUrl()).toContain('/archives/82/gcode'); }); it('previews a library file', () => { visit('?library_file=7'); expect(viewerUrl()).toContain('/library/files/7/gcode'); }); it('carries the plate through for a multi-plate source', () => { // Dropping this shows whichever plate the backend defaults to rather than // the one the user picked, with nothing on screen to say so. visit('?archive=82&plate=3'); expect(viewerUrl()).toContain('plate=3'); }); it('omits the plate parameter when there is no plate', () => { visit('?archive=82'); expect(viewerUrl()).not.toContain('plate='); }); it('says so when no file was given rather than rendering an empty viewer', () => { visit(''); expect(screen.queryByTestId('toolpath-viewer')).not.toBeInTheDocument(); expect(screen.getByText(/No file was given/i)).toBeInTheDocument(); }); it('gives the viewer pane a height to fill (#2887)', () => { // The viewer is `flex-1 min-h-0`, which divides nothing unless this column // has a definite height. `h-full` did not provide one -- it resolves against // `
`, whose height comes from `flex-1` under a `min-h-screen` root, so // the percentage fell through to content and the canvas grew the page it was // measured against, without limit. visit('?archive=82'); const column = screen.getByTestId('toolpath-viewer').parentElement as HTMLElement; expect(column.className).not.toContain('h-full'); expect(column.className).toContain('h-[calc(100vh-64px)]'); }); it('offers a way back to where the file came from', () => { visit('?archive=82'); expect(screen.getByRole('button', { name: /Back to Print Archives/i })).toBeInTheDocument(); }); it('names the file manager when the source was a library file', () => { visit('?library_file=7'); expect(screen.getByRole('button', { name: /Back to File Manager/i })).toBeInTheDocument(); }); }); describe('GCodeViewerPage — plate switcher', () => { const originalUrl = window.location.href; afterEach(() => window.history.pushState({}, '', originalUrl)); it('offers every plate of a multi-plate file and shows the one being previewed', async () => { // Nothing that opens this page from the File Manager passes a plate, so // without a switcher the other plates of a sliced multi-plate 3MF were // simply unreachable. server.use(http.get('/api/v1/library/files/:id/plates', () => HttpResponse.json(TWO_PLATES))); window.history.pushState({}, '', '/gcode-viewer?library_file=7'); render(); const first = await screen.findByRole('button', { name: /Plate 1/ }); const second = screen.getByRole('button', { name: /Plate 2/ }); // No plate in the URL means the backend serves the lowest-numbered one, // which is what the switcher has to agree with. expect(first).toHaveAttribute('aria-pressed', 'true'); expect(second).toHaveAttribute('aria-pressed', 'false'); }); it('asks for the plate that was picked', async () => { server.use(http.get('/api/v1/library/files/:id/plates', () => HttpResponse.json(TWO_PLATES))); const user = userEvent.setup(); window.history.pushState({}, '', '/gcode-viewer?library_file=7'); render(); await user.click(await screen.findByRole('button', { name: /Plate 2/ })); await waitFor(() => expect(viewerUrl()).toContain('plate=2')); // The choice lives in the URL, so the view survives a reload or a share. expect(window.location.search).toContain('plate=2'); }); it('stays out of the way for a single-plate file', async () => { server.use( http.get('/api/v1/library/files/:id/plates', () => HttpResponse.json({ ...TWO_PLATES, is_multi_plate: false, plates: [plate(1, '#ff0000')] }), ), ); window.history.pushState({}, '', '/gcode-viewer?library_file=7'); render(); await waitFor(() => expect(screen.getByTestId('toolpath-viewer')).toBeInTheDocument()); expect(screen.queryByRole('button', { name: /Plate 1/ })).not.toBeInTheDocument(); }); }); describe('GCodeViewerPage — filament colours', () => { const originalUrl = window.location.href; afterEach(() => window.history.pushState({}, '', originalUrl)); it('indexes library-file colours by tool number, not slot number', async () => { // slot_id is 1-based and the G-code's T numbers are 0-based; indexing // straight by slot puts every colour one filament out, so a two-material // print comes out with the wrong body colour. server.use( http.get('/api/v1/library/files/:id/plates', () => HttpResponse.json({ file_id: 7, filename: 'duck.gcode.3mf', is_multi_plate: false, plates: [ { index: 1, name: null, objects: [], has_thumbnail: false, thumbnail_url: null, print_time_seconds: null, filament_used_grams: null, filaments: [ { slot_id: 1, type: 'PLA', color: '#ffffff', used_grams: 1, used_meters: 1 }, { slot_id: 2, type: 'PLA', color: '#000000', used_grams: 1, used_meters: 1 }, ], }, ], }), ), ); window.history.pushState({}, '', '/gcode-viewer?library_file=7'); render(); await waitFor(() => expect(screen.getByTestId('toolpath-viewer')).toHaveAttribute('data-colors', '#ffffff,#000000'), ); }); it('colours from the plate being previewed, not the first one', async () => { // Plate 2's toolpath rendered in plate 1's colours is the same wrong-plate // mistake one layer up: both have to follow the `plate` parameter. server.use(http.get('/api/v1/library/files/:id/plates', () => HttpResponse.json(TWO_PLATES))); window.history.pushState({}, '', '/gcode-viewer?library_file=7&plate=2'); render(); await waitFor(() => expect(screen.getByTestId('toolpath-viewer')).toHaveAttribute('data-colors', '#00ff00')); }); it('previews without colours when the plate metadata carries none', async () => { server.use( http.get('/api/v1/library/files/:id/plates', () => HttpResponse.json({ file_id: 7, filename: 'x', is_multi_plate: false, plates: [] }), ), ); window.history.pushState({}, '', '/gcode-viewer?library_file=7'); render(); // The preview must still render; colours are a bonus, not a prerequisite. expect(screen.getByTestId('toolpath-viewer')).toBeInTheDocument(); }); });