LabelTemplatePickerModal.test.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  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. });
  195. });
  196. await waitFor(() => expect(onClose).toHaveBeenCalled());
  197. });
  198. it('routes to the Spoolman endpoint when spoolmanMode is true', async () => {
  199. vi.mocked(api.printSpoolmanSpoolLabels).mockResolvedValue(PDF_BLOB);
  200. render(
  201. <LabelTemplatePickerModal
  202. isOpen={true}
  203. onClose={vi.fn()}
  204. availableSpools={SPOOLS}
  205. initialSelectedIds={[1]}
  206. spoolmanMode={true}
  207. />,
  208. );
  209. // Pick the larger AMS holder variant explicitly (#1426: two AMS templates
  210. // exist now — pin which one the test sends so the assertion stays meaningful).
  211. fireEvent.click(screen.getByText(/AMS holder — large \(75 × 55 mm\)/i));
  212. await waitFor(() => {
  213. expect(api.printSpoolmanSpoolLabels).toHaveBeenCalledWith({
  214. spool_ids: [1],
  215. template: 'ams_holder_75x55',
  216. });
  217. });
  218. expect(api.printSpoolLabels).not.toHaveBeenCalled();
  219. });
  220. it('keeps the modal open and shows error when the API rejects', async () => {
  221. vi.mocked(api.printSpoolLabels).mockRejectedValue(new Error('boom'));
  222. const onClose = vi.fn();
  223. render(
  224. <LabelTemplatePickerModal
  225. isOpen={true}
  226. onClose={onClose}
  227. availableSpools={SPOOLS}
  228. initialSelectedIds={[1]}
  229. spoolmanMode={false}
  230. />,
  231. );
  232. fireEvent.click(screen.getByText(/Avery L7160/i));
  233. await waitFor(() => {
  234. expect(api.printSpoolLabels).toHaveBeenCalled();
  235. });
  236. expect(onClose).not.toHaveBeenCalled();
  237. });
  238. it('shows empty-state message when no spools are available at all', () => {
  239. render(
  240. <LabelTemplatePickerModal
  241. isOpen={true}
  242. onClose={vi.fn()}
  243. availableSpools={[]}
  244. initialSelectedIds={[]}
  245. spoolmanMode={false}
  246. />,
  247. );
  248. expect(screen.getByText(/No spools to show/i)).toBeInTheDocument();
  249. });
  250. it('shows no-matches message when search excludes everything', () => {
  251. render(
  252. <LabelTemplatePickerModal
  253. isOpen={true}
  254. onClose={vi.fn()}
  255. availableSpools={SPOOLS}
  256. initialSelectedIds={[]}
  257. spoolmanMode={false}
  258. />,
  259. );
  260. fireEvent.change(screen.getByPlaceholderText(/Search/i), { target: { value: 'zzz-no-match' } });
  261. expect(screen.getByText(/No spools match/i)).toBeInTheDocument();
  262. });
  263. it('packs templates into a 2-column grid so they plus Cancel fit on short viewports (#1230)', () => {
  264. // Regression for #1230: with templates stacked vertically (~310-390px) plus
  265. // header/search/action bar/footer, the modal blew past max-h-[90vh] on
  266. // Windows-11 + Brave-style viewports where browser chrome eats into 90vh.
  267. // overflow-hidden on the modal then clipped the bottom templates and the
  268. // Cancel footer with no scroll path. The fix uses sm:grid-cols-2 so the
  269. // templates render as a 2-column grid, trimming ~150px of vertical and
  270. // leaving room for the footer. The earlier min-h-0 on the spool list is
  271. // kept so it still yields any remaining slack.
  272. const { container } = render(
  273. <LabelTemplatePickerModal
  274. isOpen={true}
  275. onClose={vi.fn()}
  276. availableSpools={SPOOLS}
  277. initialSelectedIds={[]}
  278. spoolmanMode={false}
  279. />,
  280. );
  281. // All six templates must be in the DOM (#1426 added two AMS variants).
  282. // Use the dimension suffix to disambiguate same-family entries.
  283. expect(screen.getByText(/AMS holder — small \(74 × 33 mm\)/i)).toBeInTheDocument();
  284. expect(screen.getByText(/AMS holder — large \(75 × 55 mm\)/i)).toBeInTheDocument();
  285. expect(screen.getByText(/Box label \(40 × 30 mm\)/i)).toBeInTheDocument();
  286. expect(screen.getByText(/Box label \(62 × 29 mm\)/i)).toBeInTheDocument();
  287. expect(screen.getByText(/Avery L7160/i)).toBeInTheDocument();
  288. expect(screen.getByText(/Avery 5160/i)).toBeInTheDocument();
  289. expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
  290. // Templates section must be a responsive grid (single column on mobile,
  291. // two columns from sm: up) — a future refactor that drops the grid and
  292. // reintroduces stacked rows fails CI.
  293. const templatesSection = container.querySelector('div.grid.sm\\:grid-cols-2');
  294. expect(templatesSection).not.toBeNull();
  295. expect(templatesSection!.className).toContain('grid-cols-1');
  296. expect(templatesSection!.querySelectorAll('button').length).toBe(6);
  297. // Spool list still uses min-h-0 so it can yield further on very tight viewports.
  298. const spoolListScroller = container.querySelector('div.flex-1.overflow-y-auto');
  299. expect(spoolListScroller).not.toBeNull();
  300. expect(spoolListScroller!.className).toContain('min-h-0');
  301. expect(spoolListScroller!.className).not.toMatch(/min-h-\[\d/);
  302. });
  303. // #1410: an "ID | colour" sort toggle in the modal must flow through to the
  304. // PDF — the backend (labels.py) prints in the order it receives spool_ids,
  305. // so the modal's "submit in ID order" default was forcing every PDF to
  306. // appear in spool-number order regardless of user choice. Toggling to
  307. // colour mode must reorder both the visible list AND the payload so the
  308. // printed sheet groups colours together.
  309. it('sorts the submit payload by HSL hue when sort mode is "By colour" (#1410)', async () => {
  310. vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
  311. render(
  312. <LabelTemplatePickerModal
  313. isOpen={true}
  314. onClose={vi.fn()}
  315. availableSpools={SPOOLS}
  316. initialSelectedIds={[1, 2, 3, 4]} // Red / Blue / Black / Ivory all picked
  317. spoolmanMode={false}
  318. />,
  319. );
  320. // Default is ID-sorted; flip to colour.
  321. fireEvent.click(screen.getByRole('button', { name: 'By colour' }));
  322. fireEvent.click(screen.getByText(/Box label \(62 × 29 mm\)/i));
  323. await waitFor(() => {
  324. // Expected colour-sort order for the SPOOLS fixture:
  325. // Red (1) — hue 0° — chromatic
  326. // Ivory (4) — hue ≈34° — chromatic
  327. // Blue (2) — hue 240° — chromatic
  328. // Black (3) — saturation ≈0 → neutrals bucket, lightness 0 → last
  329. // Rainbow first, then neutrals (dark→light) per design choice for #1410.
  330. expect(api.printSpoolLabels).toHaveBeenCalledWith({
  331. spool_ids: [1, 4, 2, 3],
  332. template: 'box_62x29',
  333. });
  334. });
  335. });
  336. it('keeps ID-order submission by default (#1410 regression guard)', async () => {
  337. // Adding the sort toggle must NOT change the default behaviour — IDs go
  338. // in ascending order unless the user explicitly clicks "By colour".
  339. vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
  340. render(
  341. <LabelTemplatePickerModal
  342. isOpen={true}
  343. onClose={vi.fn()}
  344. availableSpools={SPOOLS}
  345. initialSelectedIds={[1, 2, 3, 4]}
  346. spoolmanMode={false}
  347. />,
  348. );
  349. fireEvent.click(screen.getByText(/Box label \(40 × 30 mm\)/i));
  350. await waitFor(() => {
  351. expect(api.printSpoolLabels).toHaveBeenCalledWith({
  352. spool_ids: [1, 2, 3, 4],
  353. template: 'box_40x30',
  354. });
  355. });
  356. });
  357. });