LabelTemplatePickerModal.test.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  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 { 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('shows the live selected count in the header', () => {
  64. render(
  65. <LabelTemplatePickerModal
  66. isOpen={true}
  67. onClose={vi.fn()}
  68. availableSpools={SPOOLS}
  69. initialSelectedIds={[1, 4]}
  70. spoolmanMode={false}
  71. />,
  72. );
  73. expect(screen.getByText(/2 selected/i)).toBeInTheDocument();
  74. });
  75. it('search narrows the list but preserves selection state', () => {
  76. render(
  77. <LabelTemplatePickerModal
  78. isOpen={true}
  79. onClose={vi.fn()}
  80. availableSpools={SPOOLS}
  81. initialSelectedIds={[3]} // Black ABS pre-selected
  82. spoolmanMode={false}
  83. />,
  84. );
  85. const searchInput = screen.getByPlaceholderText(/Search name, brand, or #ID/i);
  86. fireEvent.change(searchInput, { target: { value: 'polymaker' } });
  87. // Polymaker spools (Red, Ivory) visible; Sunlu/no-brand hidden
  88. expect(screen.getByText(/Red · Polymaker/)).toBeInTheDocument();
  89. expect(screen.getByText(/Ivory · Polymaker/)).toBeInTheDocument();
  90. expect(screen.queryByText(/Blue · Sunlu/)).not.toBeInTheDocument();
  91. expect(screen.queryByText(/^Black$/)).not.toBeInTheDocument();
  92. // Selection still includes the now-hidden Black ABS
  93. expect(screen.getByText(/1 selected/i)).toBeInTheDocument();
  94. });
  95. it('search by spool ID works', () => {
  96. render(
  97. <LabelTemplatePickerModal
  98. isOpen={true}
  99. onClose={vi.fn()}
  100. availableSpools={SPOOLS}
  101. initialSelectedIds={[]}
  102. spoolmanMode={false}
  103. />,
  104. );
  105. fireEvent.change(screen.getByPlaceholderText(/Search/i), { target: { value: '#2' } });
  106. expect(screen.getByText(/Blue · Sunlu/)).toBeInTheDocument();
  107. expect(screen.queryByText(/Red · Polymaker/)).not.toBeInTheDocument();
  108. });
  109. it('material chip narrows the visible list', () => {
  110. render(
  111. <LabelTemplatePickerModal
  112. isOpen={true}
  113. onClose={vi.fn()}
  114. availableSpools={SPOOLS}
  115. initialSelectedIds={[]}
  116. spoolmanMode={false}
  117. />,
  118. );
  119. // Pick the "PLA" chip
  120. fireEvent.click(screen.getByRole('button', { name: 'PLA' }));
  121. expect(screen.getByText(/Red · Polymaker/)).toBeInTheDocument();
  122. expect(screen.getByText(/Ivory · Polymaker/)).toBeInTheDocument();
  123. expect(screen.queryByText(/Blue · Sunlu/)).not.toBeInTheDocument();
  124. });
  125. it('Select all visible only adds visible spools to the selection', () => {
  126. render(
  127. <LabelTemplatePickerModal
  128. isOpen={true}
  129. onClose={vi.fn()}
  130. availableSpools={SPOOLS}
  131. initialSelectedIds={[3]} // start with Black ABS selected
  132. spoolmanMode={false}
  133. />,
  134. );
  135. // Filter to PLA, then Select all visible — should add the 2 PLA spools to
  136. // the selection without dropping Black ABS.
  137. fireEvent.click(screen.getByRole('button', { name: 'PLA' }));
  138. fireEvent.click(screen.getByText(/Select all visible/i));
  139. expect(screen.getByText(/3 selected/i)).toBeInTheDocument();
  140. });
  141. it('Clear all empties the selection regardless of filter', () => {
  142. render(
  143. <LabelTemplatePickerModal
  144. isOpen={true}
  145. onClose={vi.fn()}
  146. availableSpools={SPOOLS}
  147. initialSelectedIds={[1, 2, 3, 4]}
  148. spoolmanMode={false}
  149. />,
  150. );
  151. fireEvent.click(screen.getByRole('button', { name: 'PLA' }));
  152. fireEvent.click(screen.getByText(/Clear all/i));
  153. // Header count badge disappears once selection hits 0
  154. expect(screen.queryByText(/selected/i)).not.toBeInTheDocument();
  155. });
  156. it('template buttons disabled when nothing is selected', () => {
  157. render(
  158. <LabelTemplatePickerModal
  159. isOpen={true}
  160. onClose={vi.fn()}
  161. availableSpools={SPOOLS}
  162. initialSelectedIds={[]}
  163. spoolmanMode={false}
  164. />,
  165. );
  166. // Two AMS holder variants exist (#1426). Both must be disabled when no
  167. // spools are selected — the empty-selection guard is global, not per-template.
  168. const amsButtons = screen.getAllByText(/AMS holder/i).map((el) => el.closest('button'));
  169. expect(amsButtons).toHaveLength(2);
  170. for (const btn of amsButtons) {
  171. expect(btn).toBeDisabled();
  172. }
  173. });
  174. it('sends only the currently checked IDs to the local endpoint', async () => {
  175. vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
  176. const onClose = vi.fn();
  177. render(
  178. <LabelTemplatePickerModal
  179. isOpen={true}
  180. onClose={onClose}
  181. availableSpools={SPOOLS}
  182. initialSelectedIds={[1, 2, 3]}
  183. spoolmanMode={false}
  184. />,
  185. );
  186. fireEvent.click(screen.getByText(/Blue · Sunlu/)); // uncheck spool 2
  187. // Two "Box label …" templates exist now (40×30 and 62×29) — pin the
  188. // specific one we want to send so the assertion below stays meaningful.
  189. fireEvent.click(screen.getByText(/Box label \(62 × 29 mm\)/i));
  190. await waitFor(() => {
  191. expect(api.printSpoolLabels).toHaveBeenCalledWith({
  192. spool_ids: [1, 3],
  193. template: 'box_62x29',
  194. monochrome: false,
  195. });
  196. });
  197. await waitFor(() => expect(onClose).toHaveBeenCalled());
  198. });
  199. it('routes to the Spoolman endpoint when spoolmanMode is true', async () => {
  200. vi.mocked(api.printSpoolmanSpoolLabels).mockResolvedValue(PDF_BLOB);
  201. render(
  202. <LabelTemplatePickerModal
  203. isOpen={true}
  204. onClose={vi.fn()}
  205. availableSpools={SPOOLS}
  206. initialSelectedIds={[1]}
  207. spoolmanMode={true}
  208. />,
  209. );
  210. // Pick the larger AMS holder variant explicitly (#1426: two AMS templates
  211. // exist now — pin which one the test sends so the assertion stays meaningful).
  212. fireEvent.click(screen.getByText(/AMS holder — large \(75 × 55 mm\)/i));
  213. await waitFor(() => {
  214. expect(api.printSpoolmanSpoolLabels).toHaveBeenCalledWith({
  215. spool_ids: [1],
  216. template: 'ams_holder_75x55',
  217. monochrome: false,
  218. });
  219. });
  220. expect(api.printSpoolLabels).not.toHaveBeenCalled();
  221. });
  222. it('keeps the modal open and shows error when the API rejects', async () => {
  223. vi.mocked(api.printSpoolLabels).mockRejectedValue(new Error('boom'));
  224. const onClose = vi.fn();
  225. render(
  226. <LabelTemplatePickerModal
  227. isOpen={true}
  228. onClose={onClose}
  229. availableSpools={SPOOLS}
  230. initialSelectedIds={[1]}
  231. spoolmanMode={false}
  232. />,
  233. );
  234. fireEvent.click(screen.getByText(/Avery L7160/i));
  235. await waitFor(() => {
  236. expect(api.printSpoolLabels).toHaveBeenCalled();
  237. });
  238. expect(onClose).not.toHaveBeenCalled();
  239. });
  240. it('shows empty-state message when no spools are available at all', () => {
  241. render(
  242. <LabelTemplatePickerModal
  243. isOpen={true}
  244. onClose={vi.fn()}
  245. availableSpools={[]}
  246. initialSelectedIds={[]}
  247. spoolmanMode={false}
  248. />,
  249. );
  250. expect(screen.getByText(/No spools to show/i)).toBeInTheDocument();
  251. });
  252. it('shows no-matches message when search excludes everything', () => {
  253. render(
  254. <LabelTemplatePickerModal
  255. isOpen={true}
  256. onClose={vi.fn()}
  257. availableSpools={SPOOLS}
  258. initialSelectedIds={[]}
  259. spoolmanMode={false}
  260. />,
  261. );
  262. fireEvent.change(screen.getByPlaceholderText(/Search/i), { target: { value: 'zzz-no-match' } });
  263. expect(screen.getByText(/No spools match/i)).toBeInTheDocument();
  264. });
  265. it('packs templates into a 2-column grid so they plus Cancel fit on short viewports (#1230)', () => {
  266. // Regression for #1230: with templates stacked vertically (~310-390px) plus
  267. // header/search/action bar/footer, the modal blew past max-h-[90vh] on
  268. // Windows-11 + Brave-style viewports where browser chrome eats into 90vh.
  269. // overflow-hidden on the modal then clipped the bottom templates and the
  270. // Cancel footer with no scroll path. The fix uses sm:grid-cols-2 so the
  271. // templates render as a 2-column grid, trimming ~150px of vertical and
  272. // leaving room for the footer. The earlier min-h-0 on the spool list is
  273. // kept so it still yields any remaining slack.
  274. const { container } = render(
  275. <LabelTemplatePickerModal
  276. isOpen={true}
  277. onClose={vi.fn()}
  278. availableSpools={SPOOLS}
  279. initialSelectedIds={[]}
  280. spoolmanMode={false}
  281. />,
  282. );
  283. // All six templates must be in the DOM (#1426 added two AMS variants).
  284. // Use the dimension suffix to disambiguate same-family entries.
  285. expect(screen.getByText(/AMS holder — small \(74 × 33 mm\)/i)).toBeInTheDocument();
  286. expect(screen.getByText(/AMS holder — large \(75 × 55 mm\)/i)).toBeInTheDocument();
  287. expect(screen.getByText(/Box label \(40 × 30 mm\)/i)).toBeInTheDocument();
  288. expect(screen.getByText(/Box label \(62 × 29 mm\)/i)).toBeInTheDocument();
  289. expect(screen.getByText(/Avery L7160/i)).toBeInTheDocument();
  290. expect(screen.getByText(/Avery 5160/i)).toBeInTheDocument();
  291. expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
  292. // Templates section must be a responsive grid (single column on mobile,
  293. // two columns from sm: up) — a future refactor that drops the grid and
  294. // reintroduces stacked rows fails CI.
  295. const templatesSection = container.querySelector('div.grid.sm\\:grid-cols-2');
  296. expect(templatesSection).not.toBeNull();
  297. expect(templatesSection!.className).toContain('grid-cols-1');
  298. expect(templatesSection!.querySelectorAll('button').length).toBe(6);
  299. // Spool list still uses min-h-0 so it can yield further on very tight viewports.
  300. const spoolListScroller = container.querySelector('div.flex-1.overflow-y-auto');
  301. expect(spoolListScroller).not.toBeNull();
  302. expect(spoolListScroller!.className).toContain('min-h-0');
  303. expect(spoolListScroller!.className).not.toMatch(/min-h-\[\d/);
  304. });
  305. // #1410: an "ID | colour" sort toggle in the modal must flow through to the
  306. // PDF — the backend (labels.py) prints in the order it receives spool_ids,
  307. // so the modal's "submit in ID order" default was forcing every PDF to
  308. // appear in spool-number order regardless of user choice. Toggling to
  309. // colour mode must reorder both the visible list AND the payload so the
  310. // printed sheet groups colours together.
  311. it('sorts the submit payload by HSL hue when sort mode is "By colour" (#1410)', async () => {
  312. vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
  313. render(
  314. <LabelTemplatePickerModal
  315. isOpen={true}
  316. onClose={vi.fn()}
  317. availableSpools={SPOOLS}
  318. initialSelectedIds={[1, 2, 3, 4]} // Red / Blue / Black / Ivory all picked
  319. spoolmanMode={false}
  320. />,
  321. );
  322. // Default is ID-sorted; flip to colour.
  323. fireEvent.click(screen.getByRole('button', { name: 'By colour' }));
  324. fireEvent.click(screen.getByText(/Box label \(62 × 29 mm\)/i));
  325. await waitFor(() => {
  326. // Expected colour-sort order for the SPOOLS fixture:
  327. // Red (1) — hue 0° — chromatic
  328. // Ivory (4) — hue ≈34° — chromatic
  329. // Blue (2) — hue 240° — chromatic
  330. // Black (3) — saturation ≈0 → neutrals bucket, lightness 0 → last
  331. // Rainbow first, then neutrals (dark→light) per design choice for #1410.
  332. expect(api.printSpoolLabels).toHaveBeenCalledWith({
  333. spool_ids: [1, 4, 2, 3],
  334. template: 'box_62x29',
  335. monochrome: false,
  336. });
  337. });
  338. });
  339. it('keeps ID-order submission by default (#1410 regression guard)', async () => {
  340. // Adding the sort toggle must NOT change the default behaviour — IDs go
  341. // in ascending order unless the user explicitly clicks "By colour".
  342. vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
  343. render(
  344. <LabelTemplatePickerModal
  345. isOpen={true}
  346. onClose={vi.fn()}
  347. availableSpools={SPOOLS}
  348. initialSelectedIds={[1, 2, 3, 4]}
  349. spoolmanMode={false}
  350. />,
  351. );
  352. fireEvent.click(screen.getByText(/Box label \(40 × 30 mm\)/i));
  353. await waitFor(() => {
  354. expect(api.printSpoolLabels).toHaveBeenCalledWith({
  355. spool_ids: [1, 2, 3, 4],
  356. template: 'box_40x30',
  357. monochrome: false,
  358. });
  359. });
  360. });
  361. it('sends monochrome:true when the black & white checkbox is ticked (#1870)', async () => {
  362. vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
  363. render(
  364. <LabelTemplatePickerModal
  365. isOpen={true}
  366. onClose={vi.fn()}
  367. availableSpools={SPOOLS}
  368. initialSelectedIds={[1]}
  369. spoolmanMode={false}
  370. />,
  371. );
  372. fireEvent.click(screen.getByText(/black & white printer/i));
  373. fireEvent.click(screen.getByText(/Box label \(40 × 30 mm\)/i));
  374. await waitFor(() => {
  375. expect(api.printSpoolLabels).toHaveBeenCalledWith({
  376. spool_ids: [1],
  377. template: 'box_40x30',
  378. monochrome: true,
  379. });
  380. });
  381. });
  382. });