VirtualPrinterCard.test.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. /**
  2. * Tests for the VirtualPrinterCard component.
  3. *
  4. * Tests the auto-dispatch toggle behavior:
  5. * - Visibility based on mode (print_queue only)
  6. * - Default state (on)
  7. * - API mutation on toggle click
  8. */
  9. import { describe, it, expect, vi, beforeEach } from 'vitest';
  10. import { screen, waitFor } from '@testing-library/react';
  11. import userEvent from '@testing-library/user-event';
  12. import { render } from '../utils';
  13. import { VirtualPrinterCard } from '../../components/VirtualPrinterCard';
  14. import type { VirtualPrinterConfig } from '../../api/client';
  15. // Mock the API client
  16. vi.mock('../../api/client', () => ({
  17. multiVirtualPrinterApi: {
  18. update: vi.fn().mockResolvedValue({}),
  19. remove: vi.fn().mockResolvedValue({}),
  20. getTailscaleStatus: vi.fn().mockResolvedValue({
  21. available: false,
  22. fqdn: '',
  23. hostname: '',
  24. tailnet_name: '',
  25. tailscale_ips: [],
  26. error: null,
  27. }),
  28. },
  29. api: {
  30. getSettings: vi.fn().mockResolvedValue({}),
  31. getPrinters: vi.fn().mockResolvedValue([]),
  32. getNetworkInterfaces: vi.fn().mockResolvedValue({ interfaces: [] }),
  33. },
  34. }));
  35. import { multiVirtualPrinterApi, api } from '../../api/client';
  36. const models: Record<string, string> = {
  37. 'BL-P001': 'X1C',
  38. 'C12': 'P1S',
  39. };
  40. const createMockPrinter = (overrides: Partial<VirtualPrinterConfig> = {}): VirtualPrinterConfig => ({
  41. id: 1,
  42. name: 'Test VP',
  43. enabled: false,
  44. mode: 'archive',
  45. model: 'BL-P001',
  46. model_name: 'X1C',
  47. access_code_set: false,
  48. serial: '00M00A391800001',
  49. target_printer_id: null,
  50. auto_dispatch: true,
  51. queue_force_color_match: false,
  52. bind_ip: null,
  53. remote_interface_ip: null,
  54. position: 0,
  55. status: { running: false, pending_files: 0 },
  56. ...overrides,
  57. });
  58. describe('VirtualPrinterCard - auto-dispatch toggle', () => {
  59. beforeEach(() => {
  60. vi.clearAllMocks();
  61. vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(createMockPrinter());
  62. });
  63. it('renders auto-dispatch toggle when mode is print_queue', async () => {
  64. const printer = createMockPrinter({ mode: 'queue' });
  65. render(<VirtualPrinterCard printer={printer} models={models} />);
  66. await waitFor(() => {
  67. expect(screen.getByText('Auto-dispatch')).toBeInTheDocument();
  68. });
  69. });
  70. it('does not render auto-dispatch toggle when mode is immediate', async () => {
  71. const printer = createMockPrinter({ mode: 'archive' });
  72. render(<VirtualPrinterCard printer={printer} models={models} />);
  73. // Wait for the card to render fully (check for something that should be there)
  74. await waitFor(() => {
  75. expect(screen.getByText('Test VP')).toBeInTheDocument();
  76. });
  77. expect(screen.queryByText('Auto-dispatch')).not.toBeInTheDocument();
  78. });
  79. it('does not render auto-dispatch toggle when mode is proxy', async () => {
  80. const printer = createMockPrinter({ mode: 'proxy' });
  81. render(<VirtualPrinterCard printer={printer} models={models} />);
  82. await waitFor(() => {
  83. expect(screen.getByText('Test VP')).toBeInTheDocument();
  84. });
  85. expect(screen.queryByText('Auto-dispatch')).not.toBeInTheDocument();
  86. });
  87. it('auto-dispatch toggle defaults to on', async () => {
  88. const printer = createMockPrinter({ mode: 'queue', auto_dispatch: true });
  89. render(<VirtualPrinterCard printer={printer} models={models} />);
  90. await waitFor(() => {
  91. expect(screen.getByText('Auto-dispatch')).toBeInTheDocument();
  92. });
  93. // The auto-dispatch section container has the toggle button as a sibling of the text div
  94. const title = screen.getByText('Auto-dispatch');
  95. const section = title.closest('.flex.items-center.justify-between');
  96. expect(section).toBeTruthy();
  97. const toggleButton = section!.querySelector('button');
  98. expect(toggleButton).toBeTruthy();
  99. expect(toggleButton!.className).toContain('bg-bambu-green');
  100. });
  101. it('clicking auto-dispatch toggle calls update API', async () => {
  102. const user = userEvent.setup();
  103. const printer = createMockPrinter({ mode: 'queue', auto_dispatch: true });
  104. vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(
  105. createMockPrinter({ mode: 'queue', auto_dispatch: false })
  106. );
  107. render(<VirtualPrinterCard printer={printer} models={models} />);
  108. await waitFor(() => {
  109. expect(screen.getByText('Auto-dispatch')).toBeInTheDocument();
  110. });
  111. // Find the auto-dispatch toggle via the section container
  112. const title = screen.getByText('Auto-dispatch');
  113. const section = title.closest('.flex.items-center.justify-between');
  114. expect(section).toBeTruthy();
  115. const toggleButton = section!.querySelector('button');
  116. expect(toggleButton).toBeTruthy();
  117. await user.click(toggleButton!);
  118. await waitFor(() => {
  119. expect(multiVirtualPrinterApi.update).toHaveBeenCalledWith(1, { auto_dispatch: false });
  120. });
  121. });
  122. });
  123. // #1188 — VP queue mode now pins per-slot type+color so the scheduler refuses
  124. // to dispatch onto a printer with the wrong filament loaded. The toggle is
  125. // mode-gated to print_queue (mirroring the auto-dispatch toggle), defaults
  126. // off (preserves pre-fix behaviour for upgraders), and the click both flips
  127. // the local state and POSTs the new value to the backend.
  128. describe('VirtualPrinterCard - force color match toggle (#1188)', () => {
  129. beforeEach(() => {
  130. vi.clearAllMocks();
  131. vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(createMockPrinter());
  132. });
  133. it('renders force-color-match toggle when mode is print_queue', async () => {
  134. const printer = createMockPrinter({ mode: 'queue' });
  135. render(<VirtualPrinterCard printer={printer} models={models} />);
  136. await waitFor(() => {
  137. expect(screen.getByText('Force color match')).toBeInTheDocument();
  138. });
  139. });
  140. it('does not render force-color-match toggle when mode is immediate', async () => {
  141. const printer = createMockPrinter({ mode: 'archive' });
  142. render(<VirtualPrinterCard printer={printer} models={models} />);
  143. await waitFor(() => {
  144. expect(screen.getByText('Test VP')).toBeInTheDocument();
  145. });
  146. expect(screen.queryByText('Force color match')).not.toBeInTheDocument();
  147. });
  148. it('does not render force-color-match toggle when mode is proxy', async () => {
  149. const printer = createMockPrinter({ mode: 'proxy' });
  150. render(<VirtualPrinterCard printer={printer} models={models} />);
  151. await waitFor(() => {
  152. expect(screen.getByText('Test VP')).toBeInTheDocument();
  153. });
  154. expect(screen.queryByText('Force color match')).not.toBeInTheDocument();
  155. });
  156. it('force-color-match toggle defaults off (not green) — preserves pre-fix behaviour', async () => {
  157. const printer = createMockPrinter({ mode: 'queue', queue_force_color_match: false });
  158. render(<VirtualPrinterCard printer={printer} models={models} />);
  159. await waitFor(() => {
  160. expect(screen.getByText('Force color match')).toBeInTheDocument();
  161. });
  162. const title = screen.getByText('Force color match');
  163. const section = title.closest('.flex.items-center.justify-between');
  164. expect(section).toBeTruthy();
  165. const toggleButton = section!.querySelector('button');
  166. expect(toggleButton).toBeTruthy();
  167. expect(toggleButton!.className).not.toContain('bg-bambu-green');
  168. });
  169. it('force-color-match toggle renders enabled (green) when queue_force_color_match is true', async () => {
  170. const printer = createMockPrinter({ mode: 'queue', queue_force_color_match: true });
  171. render(<VirtualPrinterCard printer={printer} models={models} />);
  172. await waitFor(() => {
  173. expect(screen.getByText('Force color match')).toBeInTheDocument();
  174. });
  175. const title = screen.getByText('Force color match');
  176. const section = title.closest('.flex.items-center.justify-between');
  177. const toggleButton = section!.querySelector('button');
  178. expect(toggleButton!.className).toContain('bg-bambu-green');
  179. });
  180. it('clicking force-color-match toggle posts queue_force_color_match in update body', async () => {
  181. const user = userEvent.setup();
  182. const printer = createMockPrinter({ mode: 'queue', queue_force_color_match: false });
  183. vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(
  184. createMockPrinter({ mode: 'queue', queue_force_color_match: true })
  185. );
  186. render(<VirtualPrinterCard printer={printer} models={models} />);
  187. await waitFor(() => {
  188. expect(screen.getByText('Force color match')).toBeInTheDocument();
  189. });
  190. const title = screen.getByText('Force color match');
  191. const section = title.closest('.flex.items-center.justify-between');
  192. const toggleButton = section!.querySelector('button');
  193. await user.click(toggleButton!);
  194. await waitFor(() => {
  195. expect(multiVirtualPrinterApi.update).toHaveBeenCalledWith(1, { queue_force_color_match: true });
  196. });
  197. });
  198. });
  199. describe('VirtualPrinterCard - tailscale toggle', () => {
  200. beforeEach(() => {
  201. vi.clearAllMocks();
  202. vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(createMockPrinter());
  203. });
  204. it('renders tailscale toggle as enabled (green) when tailscale_disabled is false', async () => {
  205. const printer = createMockPrinter({ tailscale_disabled: false });
  206. render(<VirtualPrinterCard printer={printer} models={models} />);
  207. await waitFor(() => {
  208. expect(screen.getByText('Tailscale integration')).toBeInTheDocument();
  209. });
  210. const title = screen.getByText('Tailscale integration');
  211. const section = title.closest('.flex.items-center.justify-between');
  212. expect(section).toBeTruthy();
  213. const toggleButton = section!.querySelector('button');
  214. expect(toggleButton).toBeTruthy();
  215. expect(toggleButton!.className).toContain('bg-bambu-green');
  216. });
  217. it('renders tailscale toggle as disabled (not green) when tailscale_disabled is true', async () => {
  218. const printer = createMockPrinter({ tailscale_disabled: true });
  219. render(<VirtualPrinterCard printer={printer} models={models} />);
  220. await waitFor(() => {
  221. expect(screen.getByText('Tailscale integration')).toBeInTheDocument();
  222. });
  223. const title = screen.getByText('Tailscale integration');
  224. const section = title.closest('.flex.items-center.justify-between');
  225. expect(section).toBeTruthy();
  226. const toggleButton = section!.querySelector('button');
  227. expect(toggleButton).toBeTruthy();
  228. expect(toggleButton!.className).not.toContain('bg-bambu-green');
  229. });
  230. it('clicking tailscale toggle calls update API with tailscale_disabled: true', async () => {
  231. const user = userEvent.setup();
  232. const printer = createMockPrinter({ tailscale_disabled: false });
  233. vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(
  234. createMockPrinter({ tailscale_disabled: true })
  235. );
  236. render(<VirtualPrinterCard printer={printer} models={models} />);
  237. await waitFor(() => {
  238. expect(screen.getByText('Tailscale integration')).toBeInTheDocument();
  239. });
  240. const title = screen.getByText('Tailscale integration');
  241. const section = title.closest('.flex.items-center.justify-between');
  242. expect(section).toBeTruthy();
  243. const toggleButton = section!.querySelector('button');
  244. expect(toggleButton).toBeTruthy();
  245. await user.click(toggleButton!);
  246. await waitFor(() => {
  247. expect(multiVirtualPrinterApi.update).toHaveBeenCalledWith(1, { tailscale_disabled: true });
  248. });
  249. });
  250. });
  251. describe('VirtualPrinterCard - Tailscale FQDN copy', () => {
  252. const fqdn = 'test-host.tail1234.ts.net';
  253. beforeEach(() => {
  254. vi.clearAllMocks();
  255. vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(createMockPrinter());
  256. // FQDN now comes from the host-level Tailscale status endpoint, not VP status.
  257. // Tests in this block need the toggle to be ON (tailscale_disabled=false) so the
  258. // useQuery actually fires and the FQDN row renders.
  259. vi.mocked(multiVirtualPrinterApi.getTailscaleStatus).mockResolvedValue({
  260. available: true,
  261. fqdn,
  262. hostname: 'test-host',
  263. tailnet_name: 'tail1234.ts.net',
  264. tailscale_ips: ['100.64.0.1'],
  265. error: null,
  266. });
  267. });
  268. function getCopyButton() {
  269. // The copy button is a <button> with a title attribute. Use title to locate it.
  270. const candidates = screen.getAllByRole('button');
  271. return candidates.find(btn => /copy/i.test(btn.getAttribute('title') || '')) as HTMLButtonElement;
  272. }
  273. it('uses navigator.clipboard.writeText in a secure context', async () => {
  274. const user = userEvent.setup();
  275. const writeTextMock = vi.fn().mockResolvedValue(undefined);
  276. // JSDOM defaults isSecureContext to true; confirm and stub clipboard.
  277. Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true });
  278. Object.defineProperty(navigator, 'clipboard', {
  279. value: { writeText: writeTextMock },
  280. configurable: true,
  281. });
  282. const printer = createMockPrinter({ tailscale_disabled: false });
  283. render(<VirtualPrinterCard printer={printer} models={models} />);
  284. const copyBtn = await waitFor(() => {
  285. const btn = getCopyButton();
  286. if (!btn) throw new Error('copy button not yet rendered');
  287. return btn;
  288. });
  289. await user.click(copyBtn);
  290. await waitFor(() => {
  291. expect(writeTextMock).toHaveBeenCalledWith(fqdn);
  292. });
  293. });
  294. it('falls back to execCommand("copy") when clipboard API is unavailable (HTTP)', async () => {
  295. const user = userEvent.setup();
  296. // Simulate non-secure context: no clipboard API available.
  297. Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
  298. Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
  299. const execCommandMock = vi.fn().mockReturnValue(true);
  300. document.execCommand = execCommandMock;
  301. const printer = createMockPrinter({ tailscale_disabled: false });
  302. render(<VirtualPrinterCard printer={printer} models={models} />);
  303. const copyBtn = await waitFor(() => {
  304. const btn = getCopyButton();
  305. if (!btn) throw new Error('copy button not yet rendered');
  306. return btn;
  307. });
  308. await user.click(copyBtn);
  309. await waitFor(() => {
  310. expect(execCommandMock).toHaveBeenCalledWith('copy');
  311. });
  312. // Fallback path: textarea is appended, used, then removed in `finally`.
  313. // After the click resolves, no stray textareas should remain in the DOM.
  314. expect(document.querySelectorAll('textarea').length).toBe(0);
  315. });
  316. it('always cleans up the hidden textarea even if execCommand throws', async () => {
  317. const user = userEvent.setup();
  318. Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
  319. Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
  320. document.execCommand = vi.fn().mockImplementation(() => {
  321. throw new Error('synthetic execCommand failure');
  322. });
  323. const printer = createMockPrinter({ tailscale_disabled: false });
  324. render(<VirtualPrinterCard printer={printer} models={models} />);
  325. const copyBtn = await waitFor(() => {
  326. const btn = getCopyButton();
  327. if (!btn) throw new Error('copy button not yet rendered');
  328. return btn;
  329. });
  330. await user.click(copyBtn);
  331. // The `finally` block must remove the textarea regardless of the exception.
  332. await waitFor(() => {
  333. expect(document.querySelectorAll('textarea').length).toBe(0);
  334. });
  335. });
  336. });
  337. // Non-proxy VPs with a target printer derive their access code from the
  338. // target — the live-mirror bridge forwards slicer auth to the real printer,
  339. // so the codes must match. The card surfaces the target's code read-only
  340. // (with an Eye-toggle reveal) so the user knows what to type into the slicer
  341. // but can't diverge it from the printer's. When no target is set, the field
  342. // stays editable.
  343. describe('VirtualPrinterCard - access code inherits from target', () => {
  344. const printers = [
  345. {
  346. id: 7,
  347. name: 'Workshop X1C',
  348. ip_address: '192.168.1.50',
  349. access_code: 'TGTCODE1',
  350. serial_number: '01P00A391800001',
  351. model: 'X1C',
  352. is_active: true,
  353. },
  354. ];
  355. beforeEach(() => {
  356. vi.clearAllMocks();
  357. vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(createMockPrinter());
  358. // Re-mock the printers query for this block so the card has a target
  359. // printer it can read access_code from.
  360. vi.mocked(api.getPrinters).mockResolvedValue(printers as unknown as Awaited<ReturnType<typeof api.getPrinters>>);
  361. });
  362. it('shows target printer access code read-only when target is set on a non-proxy VP', async () => {
  363. const printer = createMockPrinter({ mode: 'queue', target_printer_id: 7 });
  364. render(<VirtualPrinterCard printer={printer} models={models} />);
  365. // Wait for the inheritance badge AND the actual code value to appear —
  366. // the badge renders synchronously from local state, but the value
  367. // depends on the printers query (api.getPrinters) resolving first.
  368. const codeInput = await waitFor(() => {
  369. const input = screen.getByLabelText('Access Code') as HTMLInputElement;
  370. if (input.value !== 'TGTCODE1') throw new Error('inherited value not populated yet');
  371. return input;
  372. });
  373. expect(screen.getByText('Inherited from target')).toBeInTheDocument();
  374. // Save button must NOT exist in the readonly path — the field is
  375. // managed via the target printer's settings, not this card.
  376. expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
  377. expect(codeInput.readOnly).toBe(true);
  378. expect(codeInput.type).toBe('password');
  379. });
  380. it('toggles the access code to plaintext via the Eye button', async () => {
  381. const user = userEvent.setup();
  382. const printer = createMockPrinter({ mode: 'queue', target_printer_id: 7 });
  383. render(<VirtualPrinterCard printer={printer} models={models} />);
  384. await waitFor(() => {
  385. expect(screen.getByText('Inherited from target')).toBeInTheDocument();
  386. });
  387. const revealBtn = screen.getByRole('button', { name: /show access code/i });
  388. await user.click(revealBtn);
  389. const codeInput = screen.getByLabelText('Access Code') as HTMLInputElement;
  390. expect(codeInput.type).toBe('text');
  391. });
  392. it('keeps the editable input + Save button when no target is set', async () => {
  393. const printer = createMockPrinter({ mode: 'archive', target_printer_id: null });
  394. render(<VirtualPrinterCard printer={printer} models={models} />);
  395. await waitFor(() => {
  396. expect(screen.getByPlaceholderText('Enter 8-char code')).toBeInTheDocument();
  397. });
  398. // Inheritance badge must NOT appear when there's no target.
  399. expect(screen.queryByText('Inherited from target')).not.toBeInTheDocument();
  400. // Save button IS present in the editable path (disabled until 8 chars typed).
  401. expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
  402. });
  403. });