LabelTemplatePickerModal.test.tsx 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798
  1. import { describe, it, expect, vi, beforeEach } from 'vitest';
  2. import { screen, waitFor, fireEvent } from '@testing-library/react';
  3. import { render } from '../utils';
  4. import { setColorCatalog, __resetColorCatalogForTests } from '../../utils/colors';
  5. import { LabelTemplatePickerModal } from '../../components/LabelTemplatePickerModal';
  6. import { api } from '../../api/client';
  7. vi.mock('../../api/client', async (importOriginal) => ({
  8. // The field lists are real exports the modal renders from.
  9. ...(await importOriginal<typeof import('../../api/client')>()),
  10. api: {
  11. printSpoolLabels: vi.fn(),
  12. printSpoolmanSpoolLabels: vi.fn(),
  13. previewSpoolLabel: vi.fn(),
  14. previewSpoolmanSpoolLabel: vi.fn(),
  15. getSettings: vi.fn().mockResolvedValue({}),
  16. getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
  17. },
  18. }));
  19. const PDF_BLOB = new Blob([new Uint8Array([0x25, 0x50, 0x44, 0x46])], { type: 'application/pdf' });
  20. const PNG_BLOB = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' });
  21. const ZIP_BLOB = new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], { type: 'application/zip' });
  22. // What a label carried before the fields were selectable (#2981), in print order.
  23. const DEFAULT_FIELDS = ['brand', 'material', 'hex', 'name', 'location', 'qr', 'spool_id'];
  24. /** The request the print button sends, with the options' defaults filled in. */
  25. function printRequest(overrides: Record<string, unknown>) {
  26. return {
  27. monochrome: false,
  28. starting_position: 1,
  29. fields: DEFAULT_FIELDS,
  30. format: 'pdf',
  31. dpi: 300,
  32. ...overrides,
  33. };
  34. }
  35. function pickTemplate(template: string) {
  36. fireEvent.change(screen.getByTestId('label-template-select'), { target: { value: template } });
  37. }
  38. function clickPrint() {
  39. fireEvent.click(screen.getByTestId('label-print-button'));
  40. }
  41. const SPOOLS = [
  42. { id: 1, material: 'PLA', subtype: 'Basic', brand: 'Polymaker', color_name: 'Red', rgba: 'FF0000FF' },
  43. { id: 2, material: 'PETG', subtype: null, brand: 'Sunlu', color_name: 'Blue', rgba: '0000FFFF' },
  44. { id: 3, material: 'ABS', subtype: null, brand: null, color_name: 'Black', rgba: '000000FF' },
  45. { id: 4, material: 'PLA', subtype: 'Matte', brand: 'Polymaker', color_name: 'Ivory', rgba: 'F5E6D3FF' },
  46. ];
  47. beforeEach(() => {
  48. vi.clearAllMocks();
  49. // setup.ts stubs localStorage with bare mocks; give it a store so the
  50. // remembered options survive a reopen, as they do in a browser.
  51. const store = new Map<string, string>();
  52. vi.mocked(localStorage.getItem).mockImplementation((key) => store.get(key) ?? null);
  53. vi.mocked(localStorage.setItem).mockImplementation((key, value) => void store.set(key, String(value)));
  54. vi.mocked(api.previewSpoolLabel).mockResolvedValue(PNG_BLOB);
  55. vi.mocked(api.previewSpoolmanSpoolLabel).mockResolvedValue(PNG_BLOB);
  56. vi.mocked(api.getSettings).mockResolvedValue({} as never);
  57. vi.mocked(api.getAuthStatus).mockResolvedValue({ auth_enabled: false } as never);
  58. Object.defineProperty(window.URL, 'createObjectURL', {
  59. value: vi.fn(() => 'blob:mock'),
  60. configurable: true,
  61. });
  62. Object.defineProperty(window.URL, 'revokeObjectURL', {
  63. value: vi.fn(),
  64. configurable: true,
  65. });
  66. vi.spyOn(window, 'open').mockImplementation(() => ({}) as Window);
  67. });
  68. describe('a colour name that only the catalog knows (#3090)', () => {
  69. // The list labels a spool by its colour, and most Bambu spools carry no
  70. // colour name — the name comes from resolving the swatch's hex against the
  71. // catalog, which is fetched once at startup. The filter that builds this
  72. // list is memoised, so it has to be told the catalog arrived; otherwise a
  73. // search typed first keeps the empty result it computed without one.
  74. const nameless = [{ id: 9, material: 'PLA', subtype: 'Silk+', brand: 'Bambu Lab', color_name: null, rgba: 'D02727FF' }];
  75. beforeEach(() => {
  76. __resetColorCatalogForTests();
  77. });
  78. const openModal = () =>
  79. render(
  80. <LabelTemplatePickerModal
  81. isOpen
  82. onClose={vi.fn()}
  83. availableSpools={nameless}
  84. initialSelectedIds={[]}
  85. spoolmanMode={false}
  86. />,
  87. );
  88. it('labels the spool from the catalog instead of falling back to its material', () => {
  89. setColorCatalog({ d02727: 'Candy Red' });
  90. openModal();
  91. expect(screen.getByText(/Candy Red/)).toBeInTheDocument();
  92. });
  93. it('re-filters when the catalog arrives after the query was typed', async () => {
  94. openModal();
  95. fireEvent.change(screen.getByPlaceholderText(/Search/i), { target: { value: 'candy' } });
  96. expect(screen.queryByText(/Candy Red/)).not.toBeInTheDocument();
  97. setColorCatalog({ d02727: 'Candy Red' });
  98. await waitFor(() => expect(screen.getByText(/Candy Red/)).toBeInTheDocument());
  99. });
  100. });
  101. describe('LabelTemplatePickerModal', () => {
  102. it('does not render when closed', () => {
  103. render(
  104. <LabelTemplatePickerModal
  105. isOpen={false}
  106. onClose={vi.fn()}
  107. availableSpools={SPOOLS}
  108. initialSelectedIds={[1]}
  109. spoolmanMode={false}
  110. />,
  111. );
  112. expect(screen.queryByText(/Print spool labels/i)).not.toBeInTheDocument();
  113. });
  114. it('lists all available spools by default', () => {
  115. render(
  116. <LabelTemplatePickerModal
  117. isOpen={true}
  118. onClose={vi.fn()}
  119. availableSpools={SPOOLS}
  120. initialSelectedIds={[1]}
  121. spoolmanMode={false}
  122. />,
  123. );
  124. expect(screen.getByText(/Red · Polymaker/)).toBeInTheDocument();
  125. expect(screen.getByText(/Blue · Sunlu/)).toBeInTheDocument();
  126. expect(screen.getByText(/Black/)).toBeInTheDocument();
  127. expect(screen.getByText(/Ivory · Polymaker/)).toBeInTheDocument();
  128. });
  129. it('keeps the panel from becoming a programmatically scrollable clipping container', () => {
  130. render(
  131. <LabelTemplatePickerModal
  132. isOpen={true}
  133. onClose={vi.fn()}
  134. availableSpools={SPOOLS}
  135. initialSelectedIds={[1, 2, 3, 4]}
  136. spoolmanMode={false}
  137. />,
  138. );
  139. const panel = screen.getByTestId('label-template-picker-panel');
  140. expect(panel).toHaveClass('overflow-clip');
  141. expect(panel).not.toHaveClass('overflow-hidden');
  142. });
  143. it('shows the live selected count in the header', () => {
  144. render(
  145. <LabelTemplatePickerModal
  146. isOpen={true}
  147. onClose={vi.fn()}
  148. availableSpools={SPOOLS}
  149. initialSelectedIds={[1, 4]}
  150. spoolmanMode={false}
  151. />,
  152. );
  153. expect(screen.getByText(/2 selected/i)).toBeInTheDocument();
  154. });
  155. it('search narrows the list but preserves selection state', () => {
  156. render(
  157. <LabelTemplatePickerModal
  158. isOpen={true}
  159. onClose={vi.fn()}
  160. availableSpools={SPOOLS}
  161. initialSelectedIds={[3]} // Black ABS pre-selected
  162. spoolmanMode={false}
  163. />,
  164. );
  165. const searchInput = screen.getByPlaceholderText(/Search name, brand, or #ID/i);
  166. fireEvent.change(searchInput, { target: { value: 'polymaker' } });
  167. // Polymaker spools (Red, Ivory) visible; Sunlu/no-brand hidden
  168. expect(screen.getByText(/Red · Polymaker/)).toBeInTheDocument();
  169. expect(screen.getByText(/Ivory · Polymaker/)).toBeInTheDocument();
  170. expect(screen.queryByText(/Blue · Sunlu/)).not.toBeInTheDocument();
  171. expect(screen.queryByText(/^Black$/)).not.toBeInTheDocument();
  172. // Selection still includes the now-hidden Black ABS
  173. expect(screen.getByText(/1 selected/i)).toBeInTheDocument();
  174. });
  175. it('search by spool ID works', () => {
  176. render(
  177. <LabelTemplatePickerModal
  178. isOpen={true}
  179. onClose={vi.fn()}
  180. availableSpools={SPOOLS}
  181. initialSelectedIds={[]}
  182. spoolmanMode={false}
  183. />,
  184. );
  185. fireEvent.change(screen.getByPlaceholderText(/Search/i), { target: { value: '#2' } });
  186. expect(screen.getByText(/Blue · Sunlu/)).toBeInTheDocument();
  187. expect(screen.queryByText(/Red · Polymaker/)).not.toBeInTheDocument();
  188. });
  189. it('material chip narrows the visible list', () => {
  190. render(
  191. <LabelTemplatePickerModal
  192. isOpen={true}
  193. onClose={vi.fn()}
  194. availableSpools={SPOOLS}
  195. initialSelectedIds={[]}
  196. spoolmanMode={false}
  197. />,
  198. );
  199. // Pick the "PLA" chip
  200. fireEvent.click(screen.getByRole('button', { name: 'PLA' }));
  201. expect(screen.getByText(/Red · Polymaker/)).toBeInTheDocument();
  202. expect(screen.getByText(/Ivory · Polymaker/)).toBeInTheDocument();
  203. expect(screen.queryByText(/Blue · Sunlu/)).not.toBeInTheDocument();
  204. });
  205. it('Select all visible only adds visible spools to the selection', () => {
  206. render(
  207. <LabelTemplatePickerModal
  208. isOpen={true}
  209. onClose={vi.fn()}
  210. availableSpools={SPOOLS}
  211. initialSelectedIds={[3]} // start with Black ABS selected
  212. spoolmanMode={false}
  213. />,
  214. );
  215. // Filter to PLA, then Select all visible — should add the 2 PLA spools to
  216. // the selection without dropping Black ABS.
  217. fireEvent.click(screen.getByRole('button', { name: 'PLA' }));
  218. fireEvent.click(screen.getByText(/Select all visible/i));
  219. expect(screen.getByText(/3 selected/i)).toBeInTheDocument();
  220. });
  221. it('Clear all empties the selection regardless of filter', () => {
  222. render(
  223. <LabelTemplatePickerModal
  224. isOpen={true}
  225. onClose={vi.fn()}
  226. availableSpools={SPOOLS}
  227. initialSelectedIds={[1, 2, 3, 4]}
  228. spoolmanMode={false}
  229. />,
  230. );
  231. fireEvent.click(screen.getByRole('button', { name: 'PLA' }));
  232. fireEvent.click(screen.getByText(/Clear all/i));
  233. // Header count badge disappears once selection hits 0
  234. expect(screen.queryByText(/selected/i)).not.toBeInTheDocument();
  235. });
  236. it('disables the print button when nothing is selected', () => {
  237. render(
  238. <LabelTemplatePickerModal
  239. isOpen={true}
  240. onClose={vi.fn()}
  241. availableSpools={SPOOLS}
  242. initialSelectedIds={[]}
  243. spoolmanMode={false}
  244. />,
  245. );
  246. expect(screen.getByTestId('label-print-button')).toBeDisabled();
  247. });
  248. it('sends only the currently checked IDs to the local endpoint', async () => {
  249. vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
  250. const onClose = vi.fn();
  251. render(
  252. <LabelTemplatePickerModal
  253. isOpen={true}
  254. onClose={onClose}
  255. availableSpools={SPOOLS}
  256. initialSelectedIds={[1, 2, 3]}
  257. spoolmanMode={false}
  258. />,
  259. );
  260. fireEvent.click(screen.getByText(/Blue · Sunlu/)); // uncheck spool 2
  261. pickTemplate('box_62x29');
  262. clickPrint();
  263. await waitFor(() => {
  264. expect(api.printSpoolLabels).toHaveBeenCalledWith(
  265. printRequest({ spool_ids: [1, 3], template: 'box_62x29' }),
  266. );
  267. });
  268. await waitFor(() => expect(onClose).toHaveBeenCalled());
  269. });
  270. it('routes to the Spoolman endpoint when spoolmanMode is true', async () => {
  271. vi.mocked(api.printSpoolmanSpoolLabels).mockResolvedValue(PDF_BLOB);
  272. render(
  273. <LabelTemplatePickerModal
  274. isOpen={true}
  275. onClose={vi.fn()}
  276. availableSpools={SPOOLS}
  277. initialSelectedIds={[1]}
  278. spoolmanMode={true}
  279. />,
  280. );
  281. pickTemplate('ams_holder_75x55');
  282. clickPrint();
  283. await waitFor(() => {
  284. expect(api.printSpoolmanSpoolLabels).toHaveBeenCalledWith(
  285. printRequest({ spool_ids: [1], template: 'ams_holder_75x55' }),
  286. );
  287. });
  288. expect(api.printSpoolLabels).not.toHaveBeenCalled();
  289. });
  290. it('keeps the modal open and shows error when the API rejects', async () => {
  291. vi.mocked(api.printSpoolLabels).mockRejectedValue(new Error('boom'));
  292. const onClose = vi.fn();
  293. render(
  294. <LabelTemplatePickerModal
  295. isOpen={true}
  296. onClose={onClose}
  297. availableSpools={SPOOLS}
  298. initialSelectedIds={[1]}
  299. spoolmanMode={false}
  300. />,
  301. );
  302. pickTemplate('avery_l7160');
  303. clickPrint();
  304. await waitFor(() => {
  305. expect(api.printSpoolLabels).toHaveBeenCalled();
  306. });
  307. expect(onClose).not.toHaveBeenCalled();
  308. });
  309. it('shows empty-state message when no spools are available at all', () => {
  310. render(
  311. <LabelTemplatePickerModal
  312. isOpen={true}
  313. onClose={vi.fn()}
  314. availableSpools={[]}
  315. initialSelectedIds={[]}
  316. spoolmanMode={false}
  317. />,
  318. );
  319. expect(screen.getByText(/No spools to show/i)).toBeInTheDocument();
  320. });
  321. it('shows no-matches message when search excludes everything', () => {
  322. render(
  323. <LabelTemplatePickerModal
  324. isOpen={true}
  325. onClose={vi.fn()}
  326. availableSpools={SPOOLS}
  327. initialSelectedIds={[]}
  328. spoolmanMode={false}
  329. />,
  330. );
  331. fireEvent.change(screen.getByPlaceholderText(/Search/i), { target: { value: 'zzz-no-match' } });
  332. expect(screen.getByText(/No spools match/i)).toBeInTheDocument();
  333. });
  334. it('offers every template and keeps the footer reachable (#1230)', () => {
  335. // #1230: the modal outgrew max-h-[90vh] and clipped its footer. The body
  336. // between header and footer is the part that scrolls, the spool list can
  337. // shrink (min-h-0), and the template choice is a single select.
  338. const { container } = render(
  339. <LabelTemplatePickerModal
  340. isOpen={true}
  341. onClose={vi.fn()}
  342. availableSpools={SPOOLS}
  343. initialSelectedIds={[]}
  344. spoolmanMode={false}
  345. />,
  346. );
  347. const options = [...(screen.getByTestId('label-template-select') as HTMLSelectElement).options];
  348. expect(options.map((o) => o.value)).toEqual([
  349. 'ams_holder_74x33',
  350. 'ams_holder_75x55',
  351. 'box_40x30',
  352. 'box_62x29',
  353. 'avery_l7160',
  354. 'avery_5160',
  355. ]);
  356. expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
  357. expect(screen.getByTestId('label-template-picker-panel')).toHaveClass('max-h-[90vh]');
  358. const spoolListScroller = container.querySelector('div.flex-1.overflow-y-auto');
  359. expect(spoolListScroller).not.toBeNull();
  360. expect(spoolListScroller!.className).toContain('min-h-0');
  361. expect(spoolListScroller!.className).not.toMatch(/min-h-\[\d/);
  362. });
  363. // #1410: an "ID | colour" sort toggle in the modal must flow through to the
  364. // PDF — the backend (labels.py) prints in the order it receives spool_ids,
  365. // so the modal's "submit in ID order" default was forcing every PDF to
  366. // appear in spool-number order regardless of user choice. Toggling to
  367. // colour mode must reorder both the visible list AND the payload so the
  368. // printed sheet groups colours together.
  369. it('sorts the submit payload by HSL hue when sort mode is "By colour" (#1410)', async () => {
  370. vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
  371. render(
  372. <LabelTemplatePickerModal
  373. isOpen={true}
  374. onClose={vi.fn()}
  375. availableSpools={SPOOLS}
  376. initialSelectedIds={[1, 2, 3, 4]} // Red / Blue / Black / Ivory all picked
  377. spoolmanMode={false}
  378. />,
  379. );
  380. // Default is ID-sorted; flip to colour.
  381. fireEvent.click(screen.getByRole('button', { name: 'By colour' }));
  382. pickTemplate('box_62x29');
  383. clickPrint();
  384. await waitFor(() => {
  385. // Expected colour-sort order for the SPOOLS fixture:
  386. // Red (1) — hue 0° — chromatic
  387. // Ivory (4) — hue ≈34° — chromatic
  388. // Blue (2) — hue 240° — chromatic
  389. // Black (3) — saturation ≈0 → neutrals bucket, lightness 0 → last
  390. // Rainbow first, then neutrals (dark→light) per design choice for #1410.
  391. expect(api.printSpoolLabels).toHaveBeenCalledWith(
  392. printRequest({ spool_ids: [1, 4, 2, 3], template: 'box_62x29' }),
  393. );
  394. });
  395. });
  396. it('keeps ID-order submission by default (#1410 regression guard)', async () => {
  397. // Adding the sort toggle must NOT change the default behaviour — IDs go
  398. // in ascending order unless the user explicitly clicks "By colour".
  399. vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
  400. render(
  401. <LabelTemplatePickerModal
  402. isOpen={true}
  403. onClose={vi.fn()}
  404. availableSpools={SPOOLS}
  405. initialSelectedIds={[1, 2, 3, 4]}
  406. spoolmanMode={false}
  407. />,
  408. );
  409. pickTemplate('box_40x30');
  410. clickPrint();
  411. await waitFor(() => {
  412. expect(api.printSpoolLabels).toHaveBeenCalledWith(
  413. printRequest({ spool_ids: [1, 2, 3, 4], template: 'box_40x30' }),
  414. );
  415. });
  416. });
  417. it('sends monochrome:true when the black & white checkbox is ticked (#1870)', async () => {
  418. vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
  419. render(
  420. <LabelTemplatePickerModal
  421. isOpen={true}
  422. onClose={vi.fn()}
  423. availableSpools={SPOOLS}
  424. initialSelectedIds={[1]}
  425. spoolmanMode={false}
  426. />,
  427. );
  428. fireEvent.click(screen.getByText(/black & white printer/i));
  429. pickTemplate('box_40x30');
  430. clickPrint();
  431. await waitFor(() => {
  432. expect(api.printSpoolLabels).toHaveBeenCalledWith(
  433. printRequest({ spool_ids: [1], template: 'box_40x30', monochrome: true }),
  434. );
  435. });
  436. });
  437. it('sends the selected starting position for an Avery sheet', async () => {
  438. vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
  439. render(
  440. <LabelTemplatePickerModal
  441. isOpen={true}
  442. onClose={vi.fn()}
  443. availableSpools={SPOOLS}
  444. initialSelectedIds={[1]}
  445. spoolmanMode={false}
  446. />,
  447. );
  448. pickTemplate('avery_5160');
  449. fireEvent.change(screen.getByTestId('label-starting-position'), { target: { value: '8' } });
  450. expect(screen.getByTestId('label-starting-position-status')).toHaveTextContent(/Positions 1 through 7/i);
  451. clickPrint();
  452. await waitFor(() => {
  453. expect(api.printSpoolLabels).toHaveBeenCalledWith(
  454. printRequest({ spool_ids: [1], template: 'avery_5160', starting_position: 8 }),
  455. );
  456. });
  457. });
  458. it('renders a single skipped position without treating the value as a pluralization key', () => {
  459. render(
  460. <LabelTemplatePickerModal
  461. isOpen={true}
  462. onClose={vi.fn()}
  463. availableSpools={SPOOLS}
  464. initialSelectedIds={[1]}
  465. spoolmanMode={false}
  466. />,
  467. );
  468. pickTemplate('avery_l7160');
  469. fireEvent.change(screen.getByTestId('label-starting-position'), { target: { value: '2' } });
  470. expect(screen.getByTestId('label-starting-position-status')).toHaveTextContent(
  471. 'Positions 1 through 1 will be left blank on the first sheet.',
  472. );
  473. });
  474. it("checks the starting position against the chosen sheet's own capacity", () => {
  475. render(
  476. <LabelTemplatePickerModal
  477. isOpen={true}
  478. onClose={vi.fn()}
  479. availableSpools={SPOOLS}
  480. initialSelectedIds={[1]}
  481. spoolmanMode={false}
  482. />,
  483. );
  484. pickTemplate('avery_l7160');
  485. fireEvent.change(screen.getByTestId('label-starting-position'), { target: { value: '25' } });
  486. expect(screen.getByTestId('label-starting-position-status')).toHaveTextContent(/from 1 to 21/);
  487. expect(screen.getByTestId('label-print-button')).toBeDisabled();
  488. // 25 is on the first sheet of an Avery 5160 (30 per page).
  489. pickTemplate('avery_5160');
  490. expect(screen.getByTestId('label-print-button')).toBeEnabled();
  491. });
  492. it('offers a starting position only for sheet templates', () => {
  493. render(
  494. <LabelTemplatePickerModal
  495. isOpen={true}
  496. onClose={vi.fn()}
  497. availableSpools={SPOOLS}
  498. initialSelectedIds={[1]}
  499. spoolmanMode={false}
  500. />,
  501. );
  502. pickTemplate('box_40x30');
  503. expect(screen.queryByTestId('label-starting-position')).not.toBeInTheDocument();
  504. pickTemplate('avery_5160');
  505. expect(screen.getByTestId('label-starting-position')).toBeInTheDocument();
  506. });
  507. });
  508. describe('choosing what the label shows (#2981)', () => {
  509. const openModal = (props: Partial<Parameters<typeof LabelTemplatePickerModal>[0]> = {}) =>
  510. render(
  511. <LabelTemplatePickerModal
  512. isOpen={true}
  513. onClose={vi.fn()}
  514. availableSpools={SPOOLS}
  515. initialSelectedIds={[1]}
  516. spoolmanMode={false}
  517. {...props}
  518. />,
  519. );
  520. it('starts from the lines labels always carried', () => {
  521. openModal();
  522. for (const field of DEFAULT_FIELDS) {
  523. expect(screen.getByTestId(`label-field-${field}`)).toBeChecked();
  524. }
  525. for (const field of ['material_number', 'temps', 'weight', 'note', 'added']) {
  526. expect(screen.getByTestId(`label-field-${field}`)).not.toBeChecked();
  527. }
  528. });
  529. it('sends the chosen lines in print order', async () => {
  530. vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
  531. openModal();
  532. pickTemplate('box_40x30');
  533. fireEvent.click(screen.getByTestId('label-field-temps'));
  534. fireEvent.click(screen.getByTestId('label-field-qr'));
  535. fireEvent.click(screen.getByTestId('label-field-brand'));
  536. clickPrint();
  537. await waitFor(() => {
  538. expect(api.printSpoolLabels).toHaveBeenCalledWith(
  539. printRequest({
  540. spool_ids: [1],
  541. template: 'box_40x30',
  542. fields: ['material', 'hex', 'name', 'location', 'temps', 'spool_id'],
  543. }),
  544. );
  545. });
  546. });
  547. it('puts the defaults back on reset', () => {
  548. openModal();
  549. fireEvent.click(screen.getByTestId('label-field-brand'));
  550. fireEvent.click(screen.getByTestId('label-field-weight'));
  551. fireEvent.click(screen.getByRole('button', { name: /Reset to default/i }));
  552. expect(screen.getByTestId('label-field-brand')).toBeChecked();
  553. expect(screen.getByTestId('label-field-weight')).not.toBeChecked();
  554. });
  555. it('remembers the lines per template, and the rest of the options, for next time', () => {
  556. const { unmount } = openModal();
  557. pickTemplate('box_40x30');
  558. fireEvent.click(screen.getByTestId('label-field-weight'));
  559. fireEvent.click(screen.getByText(/black & white printer/i));
  560. fireEvent.click(screen.getByTestId('label-format-png'));
  561. unmount();
  562. openModal();
  563. expect(screen.getByTestId('label-template-select')).toHaveValue('box_40x30');
  564. expect(screen.getByTestId('label-field-weight')).toBeChecked();
  565. expect(screen.getByRole('checkbox', { name: /black & white printer/i })).toBeChecked();
  566. expect(screen.getByTestId('label-format-png')).toHaveAttribute('aria-pressed', 'true');
  567. // A sheet holds a different amount, so its lines are chosen separately.
  568. pickTemplate('avery_l7160');
  569. expect(screen.getByTestId('label-field-weight')).not.toBeChecked();
  570. });
  571. it('ignores remembered options it does not recognise', () => {
  572. localStorage.setItem(
  573. 'bambuddy-label-print-options',
  574. JSON.stringify({ template: 'gone', fields: { box_40x30: ['brand', 'bogus'] }, format: 'tiff', dpi: 1 }),
  575. );
  576. openModal();
  577. expect(screen.getByTestId('label-template-select')).toHaveValue('ams_holder_74x33');
  578. expect(screen.getByTestId('label-format-pdf')).toHaveAttribute('aria-pressed', 'true');
  579. pickTemplate('box_40x30');
  580. expect(screen.getByTestId('label-field-brand')).toBeChecked();
  581. expect(screen.getByTestId('label-field-material')).not.toBeChecked();
  582. });
  583. });
  584. describe('the label preview (#2981)', () => {
  585. it('renders the first spool that will print, with the chosen options', async () => {
  586. render(
  587. <LabelTemplatePickerModal
  588. isOpen={true}
  589. onClose={vi.fn()}
  590. availableSpools={SPOOLS}
  591. initialSelectedIds={[4, 2]}
  592. spoolmanMode={false}
  593. />,
  594. );
  595. pickTemplate('box_62x29');
  596. fireEvent.click(screen.getByTestId('label-field-temps'));
  597. await waitFor(() => {
  598. expect(api.previewSpoolLabel).toHaveBeenLastCalledWith(
  599. {
  600. spool_id: 2,
  601. template: 'box_62x29',
  602. monochrome: false,
  603. fields: [...DEFAULT_FIELDS.slice(0, 5), 'temps', 'qr', 'spool_id'],
  604. },
  605. expect.any(AbortSignal),
  606. );
  607. });
  608. expect(await screen.findByAltText('Label preview')).toHaveAttribute('src', 'blob:mock');
  609. expect(screen.getByText('Preview of #2')).toBeInTheDocument();
  610. });
  611. it('asks for a spool when none is selected', () => {
  612. render(
  613. <LabelTemplatePickerModal
  614. isOpen={true}
  615. onClose={vi.fn()}
  616. availableSpools={SPOOLS}
  617. initialSelectedIds={[]}
  618. spoolmanMode={false}
  619. />,
  620. );
  621. expect(screen.getByText(/Select a spool to see its label/i)).toBeInTheDocument();
  622. expect(api.previewSpoolLabel).not.toHaveBeenCalled();
  623. });
  624. it('uses the Spoolman preview in Spoolman mode', async () => {
  625. render(
  626. <LabelTemplatePickerModal
  627. isOpen={true}
  628. onClose={vi.fn()}
  629. availableSpools={SPOOLS}
  630. initialSelectedIds={[1]}
  631. spoolmanMode={true}
  632. />,
  633. );
  634. await waitFor(() => expect(api.previewSpoolmanSpoolLabel).toHaveBeenCalled());
  635. expect(api.previewSpoolLabel).not.toHaveBeenCalled();
  636. });
  637. it('says why when the preview fails', async () => {
  638. vi.mocked(api.previewSpoolLabel).mockRejectedValue(new Error('Spoolman not reachable'));
  639. render(
  640. <LabelTemplatePickerModal
  641. isOpen={true}
  642. onClose={vi.fn()}
  643. availableSpools={SPOOLS}
  644. initialSelectedIds={[1]}
  645. spoolmanMode={false}
  646. />,
  647. );
  648. expect(await screen.findByText(/Could not render the preview: Spoolman not reachable/)).toBeInTheDocument();
  649. });
  650. });
  651. describe('PNG output (#2981)', () => {
  652. function captureDownloads() {
  653. const names: string[] = [];
  654. vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
  655. names.push(this.download);
  656. });
  657. return names;
  658. }
  659. it('sends the format and resolution, and saves the image instead of opening a tab', async () => {
  660. vi.mocked(api.printSpoolLabels).mockResolvedValue(PNG_BLOB);
  661. const downloads = captureDownloads();
  662. render(
  663. <LabelTemplatePickerModal
  664. isOpen={true}
  665. onClose={vi.fn()}
  666. availableSpools={SPOOLS}
  667. initialSelectedIds={[1]}
  668. spoolmanMode={false}
  669. />,
  670. );
  671. pickTemplate('box_40x30');
  672. fireEvent.click(screen.getByTestId('label-format-png'));
  673. fireEvent.change(screen.getByTestId('label-dpi-select'), { target: { value: '203' } });
  674. clickPrint();
  675. await waitFor(() => {
  676. expect(api.printSpoolLabels).toHaveBeenCalledWith(
  677. printRequest({ spool_ids: [1], template: 'box_40x30', format: 'png', dpi: 203 }),
  678. );
  679. });
  680. await waitFor(() => expect(downloads).toEqual(['bambuddy-labels-box_40x30.png']));
  681. expect(window.open).not.toHaveBeenCalled();
  682. });
  683. it('names several labels as a ZIP', async () => {
  684. vi.mocked(api.printSpoolLabels).mockResolvedValue(ZIP_BLOB);
  685. const downloads = captureDownloads();
  686. render(
  687. <LabelTemplatePickerModal
  688. isOpen={true}
  689. onClose={vi.fn()}
  690. availableSpools={SPOOLS}
  691. initialSelectedIds={[1, 2]}
  692. spoolmanMode={false}
  693. />,
  694. );
  695. pickTemplate('box_40x30');
  696. fireEvent.click(screen.getByTestId('label-format-png'));
  697. clickPrint();
  698. await waitFor(() => expect(downloads).toEqual(['bambuddy-labels-box_40x30.zip']));
  699. });
  700. it('only offers a resolution for PNG', () => {
  701. render(
  702. <LabelTemplatePickerModal
  703. isOpen={true}
  704. onClose={vi.fn()}
  705. availableSpools={SPOOLS}
  706. initialSelectedIds={[1]}
  707. spoolmanMode={false}
  708. />,
  709. );
  710. expect(screen.queryByTestId('label-dpi-select')).not.toBeInTheDocument();
  711. fireEvent.click(screen.getByTestId('label-format-png'));
  712. expect(screen.getByTestId('label-dpi-select')).toHaveValue('300');
  713. });
  714. });