LabelTemplatePickerModal.test.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. import { describe, it, expect, vi, beforeEach } from 'vitest';
  2. import { screen, waitFor, fireEvent, within } from '@testing-library/react';
  3. import { render } from '../utils';
  4. import { LabelTemplatePickerModal } from '../../components/LabelTemplatePickerModal';
  5. import { api } from '../../api/client';
  6. vi.mock('../../api/client', () => ({
  7. api: {
  8. printSpoolLabels: vi.fn(),
  9. printSpoolmanSpoolLabels: vi.fn(),
  10. getSettings: vi.fn().mockResolvedValue({}),
  11. getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
  12. },
  13. }));
  14. const PDF_BLOB = new Blob([new Uint8Array([0x25, 0x50, 0x44, 0x46])], { type: 'application/pdf' });
  15. const SPOOLS = [
  16. { id: 1, material: 'PLA', subtype: 'Basic', brand: 'Polymaker', color_name: 'Red', rgba: 'FF0000FF' },
  17. { id: 2, material: 'PETG', subtype: null, brand: 'Sunlu', color_name: 'Blue', rgba: '0000FFFF' },
  18. { id: 3, material: 'ABS', subtype: null, brand: null, color_name: 'Black', rgba: '000000FF' },
  19. { id: 4, material: 'PLA', subtype: 'Matte', brand: 'Polymaker', color_name: 'Ivory', rgba: 'F5E6D3FF' },
  20. ];
  21. beforeEach(() => {
  22. vi.clearAllMocks();
  23. vi.mocked(api.getSettings).mockResolvedValue({} as never);
  24. vi.mocked(api.getAuthStatus).mockResolvedValue({ auth_enabled: false } as never);
  25. Object.defineProperty(window.URL, 'createObjectURL', {
  26. value: vi.fn(() => 'blob:mock'),
  27. configurable: true,
  28. });
  29. Object.defineProperty(window.URL, 'revokeObjectURL', {
  30. value: vi.fn(),
  31. configurable: true,
  32. });
  33. vi.spyOn(window, 'open').mockImplementation(() => ({}) as Window);
  34. });
  35. describe('LabelTemplatePickerModal', () => {
  36. it('does not render when closed', () => {
  37. render(
  38. <LabelTemplatePickerModal
  39. isOpen={false}
  40. onClose={vi.fn()}
  41. availableSpools={SPOOLS}
  42. initialSelectedIds={[1]}
  43. spoolmanMode={false}
  44. />,
  45. );
  46. expect(screen.queryByText(/Print spool labels/i)).not.toBeInTheDocument();
  47. });
  48. it('lists all available spools by default', () => {
  49. render(
  50. <LabelTemplatePickerModal
  51. isOpen={true}
  52. onClose={vi.fn()}
  53. availableSpools={SPOOLS}
  54. initialSelectedIds={[1]}
  55. spoolmanMode={false}
  56. />,
  57. );
  58. expect(screen.getByText(/Red · Polymaker/)).toBeInTheDocument();
  59. expect(screen.getByText(/Blue · Sunlu/)).toBeInTheDocument();
  60. expect(screen.getByText(/Black/)).toBeInTheDocument();
  61. expect(screen.getByText(/Ivory · Polymaker/)).toBeInTheDocument();
  62. });
  63. it('keeps the panel from becoming a programmatically scrollable clipping container', () => {
  64. render(
  65. <LabelTemplatePickerModal
  66. isOpen={true}
  67. onClose={vi.fn()}
  68. availableSpools={SPOOLS}
  69. initialSelectedIds={[1, 2, 3, 4]}
  70. spoolmanMode={false}
  71. />,
  72. );
  73. const panel = screen.getByTestId('label-template-picker-panel');
  74. expect(panel).toHaveClass('overflow-clip');
  75. expect(panel).not.toHaveClass('overflow-hidden');
  76. });
  77. it('shows the live selected count in the header', () => {
  78. render(
  79. <LabelTemplatePickerModal
  80. isOpen={true}
  81. onClose={vi.fn()}
  82. availableSpools={SPOOLS}
  83. initialSelectedIds={[1, 4]}
  84. spoolmanMode={false}
  85. />,
  86. );
  87. expect(screen.getByText(/2 selected/i)).toBeInTheDocument();
  88. });
  89. it('search narrows the list but preserves selection state', () => {
  90. render(
  91. <LabelTemplatePickerModal
  92. isOpen={true}
  93. onClose={vi.fn()}
  94. availableSpools={SPOOLS}
  95. initialSelectedIds={[3]} // Black ABS pre-selected
  96. spoolmanMode={false}
  97. />,
  98. );
  99. const searchInput = screen.getByPlaceholderText(/Search name, brand, or #ID/i);
  100. fireEvent.change(searchInput, { target: { value: 'polymaker' } });
  101. // Polymaker spools (Red, Ivory) visible; Sunlu/no-brand hidden
  102. expect(screen.getByText(/Red · Polymaker/)).toBeInTheDocument();
  103. expect(screen.getByText(/Ivory · Polymaker/)).toBeInTheDocument();
  104. expect(screen.queryByText(/Blue · Sunlu/)).not.toBeInTheDocument();
  105. expect(screen.queryByText(/^Black$/)).not.toBeInTheDocument();
  106. // Selection still includes the now-hidden Black ABS
  107. expect(screen.getByText(/1 selected/i)).toBeInTheDocument();
  108. });
  109. it('search by spool ID works', () => {
  110. render(
  111. <LabelTemplatePickerModal
  112. isOpen={true}
  113. onClose={vi.fn()}
  114. availableSpools={SPOOLS}
  115. initialSelectedIds={[]}
  116. spoolmanMode={false}
  117. />,
  118. );
  119. fireEvent.change(screen.getByPlaceholderText(/Search/i), { target: { value: '#2' } });
  120. expect(screen.getByText(/Blue · Sunlu/)).toBeInTheDocument();
  121. expect(screen.queryByText(/Red · Polymaker/)).not.toBeInTheDocument();
  122. });
  123. it('material chip narrows the visible list', () => {
  124. render(
  125. <LabelTemplatePickerModal
  126. isOpen={true}
  127. onClose={vi.fn()}
  128. availableSpools={SPOOLS}
  129. initialSelectedIds={[]}
  130. spoolmanMode={false}
  131. />,
  132. );
  133. // Pick the "PLA" chip
  134. fireEvent.click(screen.getByRole('button', { name: 'PLA' }));
  135. expect(screen.getByText(/Red · Polymaker/)).toBeInTheDocument();
  136. expect(screen.getByText(/Ivory · Polymaker/)).toBeInTheDocument();
  137. expect(screen.queryByText(/Blue · Sunlu/)).not.toBeInTheDocument();
  138. });
  139. it('Select all visible only adds visible spools to the selection', () => {
  140. render(
  141. <LabelTemplatePickerModal
  142. isOpen={true}
  143. onClose={vi.fn()}
  144. availableSpools={SPOOLS}
  145. initialSelectedIds={[3]} // start with Black ABS selected
  146. spoolmanMode={false}
  147. />,
  148. );
  149. // Filter to PLA, then Select all visible — should add the 2 PLA spools to
  150. // the selection without dropping Black ABS.
  151. fireEvent.click(screen.getByRole('button', { name: 'PLA' }));
  152. fireEvent.click(screen.getByText(/Select all visible/i));
  153. expect(screen.getByText(/3 selected/i)).toBeInTheDocument();
  154. });
  155. it('Clear all empties the selection regardless of filter', () => {
  156. render(
  157. <LabelTemplatePickerModal
  158. isOpen={true}
  159. onClose={vi.fn()}
  160. availableSpools={SPOOLS}
  161. initialSelectedIds={[1, 2, 3, 4]}
  162. spoolmanMode={false}
  163. />,
  164. );
  165. fireEvent.click(screen.getByRole('button', { name: 'PLA' }));
  166. fireEvent.click(screen.getByText(/Clear all/i));
  167. // Header count badge disappears once selection hits 0
  168. expect(screen.queryByText(/selected/i)).not.toBeInTheDocument();
  169. });
  170. it('template buttons disabled when nothing is selected', () => {
  171. render(
  172. <LabelTemplatePickerModal
  173. isOpen={true}
  174. onClose={vi.fn()}
  175. availableSpools={SPOOLS}
  176. initialSelectedIds={[]}
  177. spoolmanMode={false}
  178. />,
  179. );
  180. // Two AMS holder variants exist (#1426). Both must be disabled when no
  181. // spools are selected — the empty-selection guard is global, not per-template.
  182. const amsButtons = screen.getAllByText(/AMS holder/i).map((el) => el.closest('button'));
  183. expect(amsButtons).toHaveLength(2);
  184. for (const btn of amsButtons) {
  185. expect(btn).toBeDisabled();
  186. }
  187. });
  188. it('sends only the currently checked IDs to the local endpoint', async () => {
  189. vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
  190. const onClose = vi.fn();
  191. render(
  192. <LabelTemplatePickerModal
  193. isOpen={true}
  194. onClose={onClose}
  195. availableSpools={SPOOLS}
  196. initialSelectedIds={[1, 2, 3]}
  197. spoolmanMode={false}
  198. />,
  199. );
  200. fireEvent.click(screen.getByText(/Blue · Sunlu/)); // uncheck spool 2
  201. // Two "Box label …" templates exist now (40×30 and 62×29) — pin the
  202. // specific one we want to send so the assertion below stays meaningful.
  203. fireEvent.click(screen.getByText(/Box label \(62 × 29 mm\)/i));
  204. await waitFor(() => {
  205. expect(api.printSpoolLabels).toHaveBeenCalledWith({
  206. spool_ids: [1, 3],
  207. template: 'box_62x29',
  208. monochrome: false,
  209. starting_position: 1,
  210. });
  211. });
  212. await waitFor(() => expect(onClose).toHaveBeenCalled());
  213. });
  214. it('routes to the Spoolman endpoint when spoolmanMode is true', async () => {
  215. vi.mocked(api.printSpoolmanSpoolLabels).mockResolvedValue(PDF_BLOB);
  216. render(
  217. <LabelTemplatePickerModal
  218. isOpen={true}
  219. onClose={vi.fn()}
  220. availableSpools={SPOOLS}
  221. initialSelectedIds={[1]}
  222. spoolmanMode={true}
  223. />,
  224. );
  225. // Pick the larger AMS holder variant explicitly (#1426: two AMS templates
  226. // exist now — pin which one the test sends so the assertion stays meaningful).
  227. fireEvent.click(screen.getByText(/AMS holder — large \(75 × 55 mm\)/i));
  228. await waitFor(() => {
  229. expect(api.printSpoolmanSpoolLabels).toHaveBeenCalledWith({
  230. spool_ids: [1],
  231. template: 'ams_holder_75x55',
  232. monochrome: false,
  233. starting_position: 1,
  234. });
  235. });
  236. expect(api.printSpoolLabels).not.toHaveBeenCalled();
  237. });
  238. it('keeps the modal open and shows error when the API rejects', async () => {
  239. vi.mocked(api.printSpoolLabels).mockRejectedValue(new Error('boom'));
  240. const onClose = vi.fn();
  241. render(
  242. <LabelTemplatePickerModal
  243. isOpen={true}
  244. onClose={onClose}
  245. availableSpools={SPOOLS}
  246. initialSelectedIds={[1]}
  247. spoolmanMode={false}
  248. />,
  249. );
  250. fireEvent.click(screen.getByText(/Avery L7160/i));
  251. await waitFor(() => {
  252. expect(api.printSpoolLabels).toHaveBeenCalled();
  253. });
  254. expect(onClose).not.toHaveBeenCalled();
  255. });
  256. it('shows empty-state message when no spools are available at all', () => {
  257. render(
  258. <LabelTemplatePickerModal
  259. isOpen={true}
  260. onClose={vi.fn()}
  261. availableSpools={[]}
  262. initialSelectedIds={[]}
  263. spoolmanMode={false}
  264. />,
  265. );
  266. expect(screen.getByText(/No spools to show/i)).toBeInTheDocument();
  267. });
  268. it('shows no-matches message when search excludes everything', () => {
  269. render(
  270. <LabelTemplatePickerModal
  271. isOpen={true}
  272. onClose={vi.fn()}
  273. availableSpools={SPOOLS}
  274. initialSelectedIds={[]}
  275. spoolmanMode={false}
  276. />,
  277. );
  278. fireEvent.change(screen.getByPlaceholderText(/Search/i), { target: { value: 'zzz-no-match' } });
  279. expect(screen.getByText(/No spools match/i)).toBeInTheDocument();
  280. });
  281. it('packs templates into a 2-column grid so they plus Cancel fit on short viewports (#1230)', () => {
  282. // Regression for #1230: with templates stacked vertically (~310-390px) plus
  283. // header/search/action bar/footer, the modal blew past max-h-[90vh] on
  284. // Windows-11 + Brave-style viewports where browser chrome eats into 90vh.
  285. // overflow-hidden on the modal then clipped the bottom templates and the
  286. // Cancel footer with no scroll path. The fix uses sm:grid-cols-2 so the
  287. // templates render as a 2-column grid, trimming ~150px of vertical and
  288. // leaving room for the footer. The earlier min-h-0 on the spool list is
  289. // kept so it still yields any remaining slack.
  290. const { container } = render(
  291. <LabelTemplatePickerModal
  292. isOpen={true}
  293. onClose={vi.fn()}
  294. availableSpools={SPOOLS}
  295. initialSelectedIds={[]}
  296. spoolmanMode={false}
  297. />,
  298. );
  299. // All six templates must be in the DOM (#1426 added two AMS variants).
  300. // Use the dimension suffix to disambiguate same-family entries.
  301. expect(screen.getByText(/AMS holder — small \(74 × 33 mm\)/i)).toBeInTheDocument();
  302. expect(screen.getByText(/AMS holder — large \(75 × 55 mm\)/i)).toBeInTheDocument();
  303. expect(screen.getByText(/Box label \(40 × 30 mm\)/i)).toBeInTheDocument();
  304. expect(screen.getByText(/Box label \(62 × 29 mm\)/i)).toBeInTheDocument();
  305. expect(screen.getByText(/Avery L7160/i)).toBeInTheDocument();
  306. expect(screen.getByText(/Avery 5160/i)).toBeInTheDocument();
  307. expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
  308. // Templates section must be a responsive grid (single column on mobile,
  309. // two columns from sm: up) — a future refactor that drops the grid and
  310. // reintroduces stacked rows fails CI.
  311. const templatesSection = container.querySelector('div.grid.sm\\:grid-cols-2');
  312. expect(templatesSection).not.toBeNull();
  313. expect(templatesSection!.className).toContain('grid-cols-1');
  314. expect(templatesSection!.querySelectorAll('button').length).toBe(6);
  315. // Spool list still uses min-h-0 so it can yield further on very tight viewports.
  316. const spoolListScroller = container.querySelector('div.flex-1.overflow-y-auto');
  317. expect(spoolListScroller).not.toBeNull();
  318. expect(spoolListScroller!.className).toContain('min-h-0');
  319. expect(spoolListScroller!.className).not.toMatch(/min-h-\[\d/);
  320. });
  321. // #1410: an "ID | colour" sort toggle in the modal must flow through to the
  322. // PDF — the backend (labels.py) prints in the order it receives spool_ids,
  323. // so the modal's "submit in ID order" default was forcing every PDF to
  324. // appear in spool-number order regardless of user choice. Toggling to
  325. // colour mode must reorder both the visible list AND the payload so the
  326. // printed sheet groups colours together.
  327. it('sorts the submit payload by HSL hue when sort mode is "By colour" (#1410)', async () => {
  328. vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
  329. render(
  330. <LabelTemplatePickerModal
  331. isOpen={true}
  332. onClose={vi.fn()}
  333. availableSpools={SPOOLS}
  334. initialSelectedIds={[1, 2, 3, 4]} // Red / Blue / Black / Ivory all picked
  335. spoolmanMode={false}
  336. />,
  337. );
  338. // Default is ID-sorted; flip to colour.
  339. fireEvent.click(screen.getByRole('button', { name: 'By colour' }));
  340. fireEvent.click(screen.getByText(/Box label \(62 × 29 mm\)/i));
  341. await waitFor(() => {
  342. // Expected colour-sort order for the SPOOLS fixture:
  343. // Red (1) — hue 0° — chromatic
  344. // Ivory (4) — hue ≈34° — chromatic
  345. // Blue (2) — hue 240° — chromatic
  346. // Black (3) — saturation ≈0 → neutrals bucket, lightness 0 → last
  347. // Rainbow first, then neutrals (dark→light) per design choice for #1410.
  348. expect(api.printSpoolLabels).toHaveBeenCalledWith({
  349. spool_ids: [1, 4, 2, 3],
  350. template: 'box_62x29',
  351. monochrome: false,
  352. starting_position: 1,
  353. });
  354. });
  355. });
  356. it('keeps ID-order submission by default (#1410 regression guard)', async () => {
  357. // Adding the sort toggle must NOT change the default behaviour — IDs go
  358. // in ascending order unless the user explicitly clicks "By colour".
  359. vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
  360. render(
  361. <LabelTemplatePickerModal
  362. isOpen={true}
  363. onClose={vi.fn()}
  364. availableSpools={SPOOLS}
  365. initialSelectedIds={[1, 2, 3, 4]}
  366. spoolmanMode={false}
  367. />,
  368. );
  369. fireEvent.click(screen.getByText(/Box label \(40 × 30 mm\)/i));
  370. await waitFor(() => {
  371. expect(api.printSpoolLabels).toHaveBeenCalledWith({
  372. spool_ids: [1, 2, 3, 4],
  373. template: 'box_40x30',
  374. monochrome: false,
  375. starting_position: 1,
  376. });
  377. });
  378. });
  379. it('sends monochrome:true when the black & white checkbox is ticked (#1870)', async () => {
  380. vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
  381. render(
  382. <LabelTemplatePickerModal
  383. isOpen={true}
  384. onClose={vi.fn()}
  385. availableSpools={SPOOLS}
  386. initialSelectedIds={[1]}
  387. spoolmanMode={false}
  388. />,
  389. );
  390. fireEvent.click(screen.getByText(/black & white printer/i));
  391. fireEvent.click(screen.getByText(/Box label \(40 × 30 mm\)/i));
  392. await waitFor(() => {
  393. expect(api.printSpoolLabels).toHaveBeenCalledWith({
  394. spool_ids: [1],
  395. template: 'box_40x30',
  396. monochrome: true,
  397. starting_position: 1,
  398. });
  399. });
  400. });
  401. it('sends the selected starting position for an Avery sheet', async () => {
  402. vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
  403. render(
  404. <LabelTemplatePickerModal
  405. isOpen={true}
  406. onClose={vi.fn()}
  407. availableSpools={SPOOLS}
  408. initialSelectedIds={[1]}
  409. spoolmanMode={false}
  410. />,
  411. );
  412. fireEvent.change(screen.getByTestId('label-starting-position'), { target: { value: '8' } });
  413. expect(screen.getByTestId('label-starting-position-status')).toHaveTextContent(/Positions 1 through 7/i);
  414. fireEvent.click(screen.getByTestId('print-labels-avery_5160'));
  415. await waitFor(() => {
  416. expect(api.printSpoolLabels).toHaveBeenCalledWith({
  417. spool_ids: [1],
  418. template: 'avery_5160',
  419. monochrome: false,
  420. starting_position: 8,
  421. });
  422. });
  423. });
  424. it('renders a single skipped position without treating the value as a pluralization key', () => {
  425. render(
  426. <LabelTemplatePickerModal
  427. isOpen={true}
  428. onClose={vi.fn()}
  429. availableSpools={SPOOLS}
  430. initialSelectedIds={[1]}
  431. spoolmanMode={false}
  432. />,
  433. );
  434. fireEvent.change(screen.getByTestId('label-starting-position'), { target: { value: '2' } });
  435. expect(screen.getByTestId('label-starting-position-status')).toHaveTextContent(
  436. 'Positions 1 through 1 will be left blank on the first sheet.',
  437. );
  438. });
  439. it('explains each Avery template capacity when disabling it', () => {
  440. render(
  441. <LabelTemplatePickerModal
  442. isOpen={true}
  443. onClose={vi.fn()}
  444. availableSpools={SPOOLS}
  445. initialSelectedIds={[1]}
  446. spoolmanMode={false}
  447. />,
  448. );
  449. fireEvent.change(screen.getByTestId('label-starting-position'), { target: { value: '25' } });
  450. const l7160Button = screen.getByTestId('print-labels-avery_l7160');
  451. expect(l7160Button).toBeDisabled();
  452. expect(within(l7160Button).getByText(/between 1 and 21/)).toBeInTheDocument();
  453. expect(screen.getByTestId('print-labels-avery_5160')).toBeEnabled();
  454. });
  455. });