GCodeViewerPage.test.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. /**
  2. * The full-page G-code preview.
  3. *
  4. * This used to be an iframe onto a vendored copy of PrettyGCode, and most of
  5. * the page was machinery for detecting when a proxy refused the embed. It now
  6. * renders Bambuddy's own toolpath viewer directly, so what is worth testing is
  7. * that the right file reaches it -- including the plate, which a multi-plate
  8. * archive needs or the viewer silently shows a different plate than the one
  9. * that was picked.
  10. */
  11. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  12. import { screen, waitFor } from '@testing-library/react';
  13. import userEvent from '@testing-library/user-event';
  14. import { http, HttpResponse } from 'msw';
  15. import { render } from '../utils';
  16. import { server } from '../mocks/server';
  17. import { GCodeViewerPage } from '../../pages/GCodeViewerPage';
  18. // The viewer itself needs WebGL, which jsdom has no answer for. Its own
  19. // behaviour is covered by the parser and renderer tests; here we only care
  20. // which URL the page hands it.
  21. vi.mock('../../components/GcodeToolpathViewer', () => ({
  22. GcodeToolpathViewer: ({ gcodeUrl, filamentColors }: { gcodeUrl: string; filamentColors?: string[] }) => (
  23. <div data-testid="toolpath-viewer" data-url={gcodeUrl} data-colors={(filamentColors ?? []).join(',')} />
  24. ),
  25. }));
  26. function visit(search: string) {
  27. window.history.pushState({}, '', `/gcode-viewer${search}`);
  28. render(<GCodeViewerPage />);
  29. }
  30. const viewerUrl = () => screen.getByTestId('toolpath-viewer').getAttribute('data-url');
  31. const plate = (index: number, color: string) => ({
  32. index,
  33. name: null,
  34. objects: [],
  35. has_thumbnail: false,
  36. thumbnail_url: null,
  37. print_time_seconds: null,
  38. filament_used_grams: null,
  39. filaments: [{ slot_id: 1, type: 'PLA', color, used_grams: 1, used_meters: 1 }],
  40. });
  41. const TWO_PLATES = {
  42. file_id: 7,
  43. filename: 'two.gcode.3mf',
  44. is_multi_plate: true,
  45. plates: [plate(1, '#ff0000'), plate(2, '#00ff00')],
  46. };
  47. describe('GCodeViewerPage', () => {
  48. const originalUrl = window.location.href;
  49. beforeEach(() => {
  50. window.history.pushState({}, '', '/gcode-viewer');
  51. });
  52. afterEach(() => {
  53. window.history.pushState({}, '', originalUrl);
  54. });
  55. it('previews an archive', () => {
  56. visit('?archive=82');
  57. expect(viewerUrl()).toContain('/archives/82/gcode');
  58. });
  59. it('previews a library file', () => {
  60. visit('?library_file=7');
  61. expect(viewerUrl()).toContain('/library/files/7/gcode');
  62. });
  63. it('carries the plate through for a multi-plate source', () => {
  64. // Dropping this shows whichever plate the backend defaults to rather than
  65. // the one the user picked, with nothing on screen to say so.
  66. visit('?archive=82&plate=3');
  67. expect(viewerUrl()).toContain('plate=3');
  68. });
  69. it('omits the plate parameter when there is no plate', () => {
  70. visit('?archive=82');
  71. expect(viewerUrl()).not.toContain('plate=');
  72. });
  73. it('says so when no file was given rather than rendering an empty viewer', () => {
  74. visit('');
  75. expect(screen.queryByTestId('toolpath-viewer')).not.toBeInTheDocument();
  76. expect(screen.getByText(/No file was given/i)).toBeInTheDocument();
  77. });
  78. it('gives the viewer pane a height to fill (#2887)', () => {
  79. // The viewer is `flex-1 min-h-0`, which divides nothing unless this column
  80. // has a definite height. `h-full` did not provide one -- it resolves against
  81. // `<main>`, whose height comes from `flex-1` under a `min-h-screen` root, so
  82. // the percentage fell through to content and the canvas grew the page it was
  83. // measured against, without limit.
  84. visit('?archive=82');
  85. const column = screen.getByTestId('toolpath-viewer').parentElement as HTMLElement;
  86. expect(column.className).not.toContain('h-full');
  87. expect(column.className).toContain('h-[calc(100vh-64px)]');
  88. });
  89. it('offers a way back to where the file came from', () => {
  90. visit('?archive=82');
  91. expect(screen.getByRole('button', { name: /Back to Print Archives/i })).toBeInTheDocument();
  92. });
  93. it('names the file manager when the source was a library file', () => {
  94. visit('?library_file=7');
  95. expect(screen.getByRole('button', { name: /Back to File Manager/i })).toBeInTheDocument();
  96. });
  97. });
  98. describe('GCodeViewerPage — plate switcher', () => {
  99. const originalUrl = window.location.href;
  100. afterEach(() => window.history.pushState({}, '', originalUrl));
  101. it('offers every plate of a multi-plate file and shows the one being previewed', async () => {
  102. // Nothing that opens this page from the File Manager passes a plate, so
  103. // without a switcher the other plates of a sliced multi-plate 3MF were
  104. // simply unreachable.
  105. server.use(http.get('/api/v1/library/files/:id/plates', () => HttpResponse.json(TWO_PLATES)));
  106. window.history.pushState({}, '', '/gcode-viewer?library_file=7');
  107. render(<GCodeViewerPage />);
  108. const first = await screen.findByRole('button', { name: /Plate 1/ });
  109. const second = screen.getByRole('button', { name: /Plate 2/ });
  110. // No plate in the URL means the backend serves the lowest-numbered one,
  111. // which is what the switcher has to agree with.
  112. expect(first).toHaveAttribute('aria-pressed', 'true');
  113. expect(second).toHaveAttribute('aria-pressed', 'false');
  114. });
  115. it('asks for the plate that was picked', async () => {
  116. server.use(http.get('/api/v1/library/files/:id/plates', () => HttpResponse.json(TWO_PLATES)));
  117. const user = userEvent.setup();
  118. window.history.pushState({}, '', '/gcode-viewer?library_file=7');
  119. render(<GCodeViewerPage />);
  120. await user.click(await screen.findByRole('button', { name: /Plate 2/ }));
  121. await waitFor(() => expect(viewerUrl()).toContain('plate=2'));
  122. // The choice lives in the URL, so the view survives a reload or a share.
  123. expect(window.location.search).toContain('plate=2');
  124. });
  125. it('stays out of the way for a single-plate file', async () => {
  126. server.use(
  127. http.get('/api/v1/library/files/:id/plates', () =>
  128. HttpResponse.json({ ...TWO_PLATES, is_multi_plate: false, plates: [plate(1, '#ff0000')] }),
  129. ),
  130. );
  131. window.history.pushState({}, '', '/gcode-viewer?library_file=7');
  132. render(<GCodeViewerPage />);
  133. await waitFor(() => expect(screen.getByTestId('toolpath-viewer')).toBeInTheDocument());
  134. expect(screen.queryByRole('button', { name: /Plate 1/ })).not.toBeInTheDocument();
  135. });
  136. });
  137. describe('GCodeViewerPage — filament colours', () => {
  138. const originalUrl = window.location.href;
  139. afterEach(() => window.history.pushState({}, '', originalUrl));
  140. it('indexes library-file colours by tool number, not slot number', async () => {
  141. // slot_id is 1-based and the G-code's T numbers are 0-based; indexing
  142. // straight by slot puts every colour one filament out, so a two-material
  143. // print comes out with the wrong body colour.
  144. server.use(
  145. http.get('/api/v1/library/files/:id/plates', () =>
  146. HttpResponse.json({
  147. file_id: 7,
  148. filename: 'duck.gcode.3mf',
  149. is_multi_plate: false,
  150. plates: [
  151. {
  152. index: 1,
  153. name: null,
  154. objects: [],
  155. has_thumbnail: false,
  156. thumbnail_url: null,
  157. print_time_seconds: null,
  158. filament_used_grams: null,
  159. filaments: [
  160. { slot_id: 1, type: 'PLA', color: '#ffffff', used_grams: 1, used_meters: 1 },
  161. { slot_id: 2, type: 'PLA', color: '#000000', used_grams: 1, used_meters: 1 },
  162. ],
  163. },
  164. ],
  165. }),
  166. ),
  167. );
  168. window.history.pushState({}, '', '/gcode-viewer?library_file=7');
  169. render(<GCodeViewerPage />);
  170. await waitFor(() =>
  171. expect(screen.getByTestId('toolpath-viewer')).toHaveAttribute('data-colors', '#ffffff,#000000'),
  172. );
  173. });
  174. it('colours from the plate being previewed, not the first one', async () => {
  175. // Plate 2's toolpath rendered in plate 1's colours is the same wrong-plate
  176. // mistake one layer up: both have to follow the `plate` parameter.
  177. server.use(http.get('/api/v1/library/files/:id/plates', () => HttpResponse.json(TWO_PLATES)));
  178. window.history.pushState({}, '', '/gcode-viewer?library_file=7&plate=2');
  179. render(<GCodeViewerPage />);
  180. await waitFor(() => expect(screen.getByTestId('toolpath-viewer')).toHaveAttribute('data-colors', '#00ff00'));
  181. });
  182. it('previews without colours when the plate metadata carries none', async () => {
  183. server.use(
  184. http.get('/api/v1/library/files/:id/plates', () =>
  185. HttpResponse.json({ file_id: 7, filename: 'x', is_multi_plate: false, plates: [] }),
  186. ),
  187. );
  188. window.history.pushState({}, '', '/gcode-viewer?library_file=7');
  189. render(<GCodeViewerPage />);
  190. // The preview must still render; colours are a bonus, not a prerequisite.
  191. expect(screen.getByTestId('toolpath-viewer')).toBeInTheDocument();
  192. });
  193. });