PrintModalNarrowScreen.test.tsx 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. /**
  2. * The print dialog's slot pickers can be read on a phone held upright (#3294).
  3. *
  4. * Two things hid them. The open list took the width of its button and nothing
  5. * more, and the button had almost no width: on a nozzle-rack printer the
  6. * required-filament cell has a 210px floor, which on a ~400px portrait screen
  7. * left the slot button about 55px -- a swatch and a chevron. Only turning the
  8. * phone sideways made the slot names readable.
  9. *
  10. * Now the list is as wide as its rows want, never narrower than its button and
  11. * never wider than the screen, and it moves left rather than running off the
  12. * right edge. Below the `sm` breakpoint the picker gets a line of its own
  13. * under the required filament. jsdom does no layout, so these pin the numbers
  14. * and classes that make it fit.
  15. */
  16. import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
  17. import { screen, fireEvent, cleanup } from '@testing-library/react';
  18. import { http, HttpResponse } from 'msw';
  19. import { render } from '../utils';
  20. import { server } from '../mocks/server';
  21. import { SlotPicker } from '../../components/PrintModal/SlotPicker';
  22. import { FilamentMapping } from '../../components/PrintModal/FilamentMapping';
  23. import { FilamentOverride } from '../../components/PrintModal/FilamentOverride';
  24. import { PrinterSelector } from '../../components/PrintModal/PrinterSelector';
  25. import type { PrinterMappingResult } from '../../hooks/useMultiPrinterFilamentMapping';
  26. import type { Printer, PrinterStatus } from '../../api/client';
  27. const SCREEN = 412;
  28. const LIST_WIDTH = 380;
  29. function rect(left: number, width: number): DOMRect {
  30. return { left, width, top: 300, bottom: 324, right: left + width, height: 24, x: left, y: 300, toJSON: () => ({}) } as DOMRect;
  31. }
  32. describe('SlotPicker list on a narrow screen', () => {
  33. let natural = LIST_WIDTH;
  34. beforeEach(() => {
  35. natural = LIST_WIDTH;
  36. // A screen SCREEN wide; a page scrollbar would make innerWidth larger.
  37. vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(SCREEN);
  38. // The list is as wide as its rows want, within the limits written on it --
  39. // what a browser's layout does with width: max-content.
  40. vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) {
  41. if (this.getAttribute('role') !== 'listbox') return 0;
  42. const min = parseFloat(this.style.minWidth) || 0;
  43. const max = parseFloat(this.style.maxWidth) || Infinity;
  44. return Math.min(Math.max(natural, min), max);
  45. });
  46. });
  47. afterEach(() => {
  48. vi.restoreAllMocks();
  49. cleanup();
  50. });
  51. function openAt(triggerRect: DOMRect) {
  52. render(
  53. <SlotPicker
  54. value=""
  55. options={[{ value: '1', label: 'A2: Polymaker PLA Matte (Charcoal Black)', meta: ' - 602g left', hex: '#2F2E30' }]}
  56. placeholder="-- Select slot --"
  57. ariaLabel="Slot for PLA"
  58. onChange={() => {}}
  59. />,
  60. );
  61. const trigger = screen.getByRole('combobox');
  62. vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(triggerRect);
  63. fireEvent.click(trigger);
  64. return screen.getByRole('listbox');
  65. }
  66. it('is as wide as its rows want, not as narrow as its button', () => {
  67. const list = openAt(rect(320, 55));
  68. expect(list.style.width).toBe('max-content');
  69. expect(list.style.minWidth).toBe('55px');
  70. });
  71. it('is never wider than the screen, less a margin', () => {
  72. const list = openAt(rect(320, 55));
  73. expect(list.style.maxWidth).toBe(`${SCREEN - 16}px`);
  74. });
  75. it('moves left rather than running off the right edge', () => {
  76. const list = openAt(rect(320, 55));
  77. expect(list.style.left).toBe(`${SCREEN - 8 - LIST_WIDTH}px`);
  78. });
  79. it('stays under its button when it fits', () => {
  80. const list = openAt(rect(16, 300));
  81. expect(list.style.left).toBe('16px');
  82. expect(list.style.minWidth).toBe('300px');
  83. });
  84. it('is kept on screen at the width of its button, when that is wider than its rows', () => {
  85. natural = 200;
  86. const list = openAt(rect(150, 300));
  87. expect(list.style.left).toBe(`${SCREEN - 8 - 300}px`);
  88. });
  89. it('is kept inside the margin on both sides when its rows want more than the screen', () => {
  90. natural = 1000;
  91. const list = openAt(rect(320, 55));
  92. // It starts at the left margin rather than off the left edge.
  93. expect(list.style.left).toBe('8px');
  94. });
  95. it('leaves out the scrollbar', () => {
  96. Object.defineProperty(window, 'innerWidth', { configurable: true, value: SCREEN + 15 });
  97. try {
  98. const list = openAt(rect(320, 55));
  99. expect(list.style.maxWidth).toBe(`${SCREEN - 16}px`);
  100. expect(list.style.left).toBe(`${SCREEN - 8 - LIST_WIDTH}px`);
  101. } finally {
  102. Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 });
  103. }
  104. });
  105. });
  106. /** The grid row that holds a slot picker. */
  107. function rowOf(picker: HTMLElement): HTMLElement {
  108. return picker.parentElement as HTMLElement;
  109. }
  110. function expectStackedBelowSm(row: HTMLElement, wideColumns: string) {
  111. // One column for the swatch / arrow, the picker, one for the status icon.
  112. expect(row.className).toMatch(/(^|\s)grid-cols-\[16px_minmax\(0,1fr\)_\d+px\](\s|$)/);
  113. expect(row.className).toContain(`sm:grid-cols-[${wideColumns}]`);
  114. // The required filament takes the whole first line, so the arrow, the
  115. // picker and the status icon wrap onto the second.
  116. const required = row.children[1] as HTMLElement;
  117. expect(required.className).toMatch(/(^|\s)col-span-2(\s|$)/);
  118. expect(required.className).toContain('sm:col-span-1');
  119. }
  120. describe('mapping row on a narrow screen', () => {
  121. const tray = (id: number, color: string) => ({ id, tray_type: 'PLA', tray_color: color, tray_info_idx: 'GFA01' });
  122. function mount(status: Record<string, unknown>, filament: Record<string, unknown> = {}) {
  123. server.use(
  124. http.get('/api/v1/printers/:id/status', () =>
  125. HttpResponse.json({
  126. id: 1,
  127. name: 'H2C-1',
  128. connected: true,
  129. state: 'IDLE',
  130. ams: [{ id: 0, tray: [tray(0, 'DE4343'), tray(1, '2F2E30')] }],
  131. vt_tray: [],
  132. ams_extruder_map: {},
  133. ...status,
  134. } as unknown as PrinterStatus),
  135. ),
  136. http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])),
  137. );
  138. render(
  139. <FilamentMapping
  140. printerId={1}
  141. filamentReqs={{
  142. filaments: [{ slot_id: 1, type: 'PLA', color: '#DE4343', used_grams: 297.6, used_meters: 90, ...filament }],
  143. }}
  144. manualMappings={{}}
  145. onManualMappingChange={() => {}}
  146. currencySymbol="$"
  147. defaultCostPerKg={20}
  148. defaultExpanded
  149. />,
  150. );
  151. }
  152. afterEach(() => cleanup());
  153. it('puts the picker on its own line, and keeps the wide layout from sm up', async () => {
  154. mount({});
  155. const picker = await screen.findByRole('combobox', { name: /printer slot for/i });
  156. expectStackedBelowSm(rowOf(picker), '16px_minmax(70px,1fr)_auto_2fr_16px');
  157. });
  158. it('does the same on a nozzle-rack printer, where the squeeze was worst', async () => {
  159. const rackSlot = (id: number) => ({ id, nozzle_type: 'HH01', nozzle_diameter: '0.4', filament_color: '', filament_id: '', filament_type: '' });
  160. mount(
  161. { nozzle_rack: [rackSlot(16), rackSlot(17), rackSlot(1)] },
  162. { group_id: 1, group: { on_rack: true, nozzle_diameter: '0.40', volume_type: 'Standard', filament_color: '#DE4343' } },
  163. );
  164. await screen.findByLabelText('Rack position');
  165. expectStackedBelowSm(rowOf(screen.getByRole('combobox', { name: /printer slot for/i })), '16px_minmax(210px,1.4fr)_auto_2fr_16px');
  166. });
  167. });
  168. describe('override row on a narrow screen', () => {
  169. afterEach(() => cleanup());
  170. it('puts the picker on its own line, and keeps the wide layout from sm up', () => {
  171. render(
  172. <FilamentOverride
  173. filamentReqs={{ filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5 }] }}
  174. availableFilaments={[
  175. { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Basic', extruder_id: null },
  176. ]}
  177. overrides={{}}
  178. onChange={() => {}}
  179. />,
  180. );
  181. expectStackedBelowSm(rowOf(screen.getByRole('combobox')), '16px_minmax(70px,1fr)_auto_2fr_20px');
  182. });
  183. });
  184. describe('custom slot mapping of one of several printers, on a narrow screen', () => {
  185. afterEach(() => cleanup());
  186. it('puts the select on its own line, and keeps the wide layout from sm up', async () => {
  187. const printer = (id: number) => ({ id, name: `H2C-${id}`, model: 'H2C', ip_address: `192.0.2.${id}`, is_active: true }) as unknown as Printer;
  188. const result = (id: number): PrinterMappingResult => ({
  189. printerId: id,
  190. printerName: `H2C-${id}`,
  191. status: undefined,
  192. isLoading: false,
  193. loadedFilaments: [],
  194. autoMapping: undefined,
  195. finalMapping: undefined,
  196. matchStatus: 'missing',
  197. exactMatches: 0,
  198. typeOnlyMatches: 0,
  199. missingTypes: 1,
  200. totalSlots: 1,
  201. config: { useDefault: false, manualMappings: {}, autoConfigured: false },
  202. });
  203. render(
  204. <PrinterSelector
  205. printers={[printer(1), printer(2)]}
  206. selectedPrinterIds={[1, 2]}
  207. onMultiSelect={() => {}}
  208. allowMultiple
  209. printerMappingResults={[result(1), result(2)]}
  210. filamentReqs={{ filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5 }] }}
  211. onAutoConfigurePrinter={() => {}}
  212. onUpdatePrinterConfig={() => {}}
  213. />,
  214. );
  215. const selects = await screen.findAllByRole('combobox');
  216. expect(selects.length).toBe(2);
  217. for (const select of selects) {
  218. expectStackedBelowSm(rowOf(select), '16px_minmax(70px,1fr)_auto_2fr_16px');
  219. }
  220. });
  221. });