VirtualPrinterSettings.test.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680
  1. /**
  2. * Tests for the VirtualPrinterSettings component.
  3. *
  4. * Tests the virtual printer configuration UI including:
  5. * - Enable/disable toggle
  6. * - Access code management
  7. * - Archive mode selection
  8. * - Status display
  9. */
  10. import { describe, it, expect, vi, beforeEach } from 'vitest';
  11. import { screen, waitFor } from '@testing-library/react';
  12. import userEvent from '@testing-library/user-event';
  13. import { render } from '../utils';
  14. import { VirtualPrinterSettings } from '../../components/VirtualPrinterSettings';
  15. // Mock the API client
  16. vi.mock('../../api/client', () => ({
  17. api: {
  18. getSettings: vi.fn().mockResolvedValue({}),
  19. updateSettings: vi.fn().mockResolvedValue({}),
  20. getPrinters: vi.fn().mockResolvedValue([]),
  21. getNetworkInterfaces: vi.fn().mockResolvedValue({ interfaces: [] }),
  22. },
  23. virtualPrinterApi: {
  24. getSettings: vi.fn(),
  25. updateSettings: vi.fn(),
  26. getModels: vi.fn().mockResolvedValue({
  27. models: {
  28. 'BL-P001': 'X1C',
  29. 'C12': 'P1S',
  30. 'N7': 'P2S',
  31. },
  32. }),
  33. },
  34. }));
  35. // Import mocked module
  36. import { virtualPrinterApi } from '../../api/client';
  37. // Mock data factory
  38. const createMockSettings = (overrides = {}) => ({
  39. enabled: false,
  40. access_code_set: false,
  41. mode: 'archive' as const,
  42. model: 'BL-P001',
  43. target_printer_id: null as number | null,
  44. remote_interface_ip: null as string | null,
  45. status: {
  46. enabled: false,
  47. running: false,
  48. mode: 'archive',
  49. name: 'Bambuddy',
  50. serial: '00M00A391800001',
  51. model: 'BL-P001',
  52. model_name: 'X1C',
  53. pending_files: 0,
  54. },
  55. ...overrides,
  56. });
  57. describe('VirtualPrinterSettings', () => {
  58. beforeEach(() => {
  59. vi.clearAllMocks();
  60. // Default mock implementation
  61. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(createMockSettings());
  62. vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(createMockSettings());
  63. });
  64. describe('rendering', () => {
  65. it('renders loading state initially', () => {
  66. // Delay the API response to catch loading state
  67. vi.mocked(virtualPrinterApi.getSettings).mockImplementation(
  68. () => new Promise(() => {}) // Never resolves
  69. );
  70. render(<VirtualPrinterSettings />);
  71. // Should show loading spinner
  72. expect(document.querySelector('.animate-spin')).toBeInTheDocument();
  73. });
  74. it('renders component title', async () => {
  75. render(<VirtualPrinterSettings />);
  76. await waitFor(() => {
  77. expect(screen.getByText('Virtual Printer')).toBeInTheDocument();
  78. });
  79. });
  80. it('renders enable toggle', async () => {
  81. render(<VirtualPrinterSettings />);
  82. await waitFor(() => {
  83. expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();
  84. });
  85. });
  86. it('renders access code section', async () => {
  87. render(<VirtualPrinterSettings />);
  88. await waitFor(() => {
  89. expect(screen.getByText('Access Code')).toBeInTheDocument();
  90. });
  91. });
  92. it('renders mode section', async () => {
  93. render(<VirtualPrinterSettings />);
  94. await waitFor(() => {
  95. expect(screen.getByText('Mode')).toBeInTheDocument();
  96. });
  97. });
  98. it('renders how it works info', async () => {
  99. render(<VirtualPrinterSettings />);
  100. await waitFor(() => {
  101. expect(screen.getByText('How it works:')).toBeInTheDocument();
  102. });
  103. });
  104. });
  105. describe('status indicator', () => {
  106. it('shows Stopped when not running', async () => {
  107. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  108. createMockSettings({ status: { ...createMockSettings().status, running: false } })
  109. );
  110. render(<VirtualPrinterSettings />);
  111. await waitFor(() => {
  112. expect(screen.getByText('Stopped')).toBeInTheDocument();
  113. });
  114. });
  115. it('shows Running when active', async () => {
  116. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  117. createMockSettings({
  118. enabled: true,
  119. status: { ...createMockSettings().status, enabled: true, running: true },
  120. })
  121. );
  122. render(<VirtualPrinterSettings />);
  123. await waitFor(() => {
  124. expect(screen.getByText('Running')).toBeInTheDocument();
  125. });
  126. });
  127. it('shows status details when running', async () => {
  128. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  129. createMockSettings({
  130. enabled: true,
  131. status: {
  132. enabled: true,
  133. running: true,
  134. mode: 'archive',
  135. name: 'Bambuddy',
  136. serial: '00M00A391800001',
  137. model: 'BL-P001',
  138. model_name: 'X1C',
  139. pending_files: 0,
  140. },
  141. })
  142. );
  143. render(<VirtualPrinterSettings />);
  144. await waitFor(() => {
  145. expect(screen.getByText('Status Details')).toBeInTheDocument();
  146. expect(screen.getByText('Bambuddy')).toBeInTheDocument();
  147. expect(screen.getByText('00M00A391800001')).toBeInTheDocument();
  148. });
  149. });
  150. });
  151. describe('access code', () => {
  152. it('shows warning when access code not set', async () => {
  153. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  154. createMockSettings({ access_code_set: false })
  155. );
  156. render(<VirtualPrinterSettings />);
  157. await waitFor(() => {
  158. expect(screen.getByText('No access code set - required to enable')).toBeInTheDocument();
  159. });
  160. });
  161. it('shows success when access code is set', async () => {
  162. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  163. createMockSettings({ access_code_set: true })
  164. );
  165. render(<VirtualPrinterSettings />);
  166. await waitFor(() => {
  167. expect(screen.getByText('Access code is set')).toBeInTheDocument();
  168. });
  169. });
  170. it('shows character count while typing', async () => {
  171. const user = userEvent.setup();
  172. render(<VirtualPrinterSettings />);
  173. await waitFor(() => {
  174. expect(screen.getByText('Access Code')).toBeInTheDocument();
  175. });
  176. const input = screen.getByPlaceholderText('Enter 8-char code');
  177. await user.type(input, '1234');
  178. expect(screen.getByText('(4/8)')).toBeInTheDocument();
  179. });
  180. it('saves access code on button click', async () => {
  181. const user = userEvent.setup();
  182. vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
  183. createMockSettings({ access_code_set: true })
  184. );
  185. render(<VirtualPrinterSettings />);
  186. await waitFor(() => {
  187. expect(screen.getByText('Access Code')).toBeInTheDocument();
  188. });
  189. const input = screen.getByPlaceholderText('Enter 8-char code');
  190. await user.type(input, '12345678');
  191. const saveButton = screen.getByRole('button', { name: 'Save' });
  192. await user.click(saveButton);
  193. await waitFor(() => {
  194. expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({
  195. access_code: '12345678',
  196. });
  197. });
  198. });
  199. it('toggles password visibility', async () => {
  200. const user = userEvent.setup();
  201. render(<VirtualPrinterSettings />);
  202. await waitFor(() => {
  203. expect(screen.getByText('Access Code')).toBeInTheDocument();
  204. });
  205. const input = screen.getByPlaceholderText('Enter 8-char code');
  206. expect(input).toHaveAttribute('type', 'password');
  207. // Find and click the visibility toggle (eye icon button)
  208. const toggleButtons = screen.getAllByRole('button');
  209. const visibilityToggle = toggleButtons.find(
  210. (btn) => btn.querySelector('svg') && btn.className.includes('absolute')
  211. );
  212. if (visibilityToggle) {
  213. await user.click(visibilityToggle);
  214. expect(input).toHaveAttribute('type', 'text');
  215. }
  216. });
  217. });
  218. describe('mode selection', () => {
  219. it('renders Archive mode option', async () => {
  220. render(<VirtualPrinterSettings />);
  221. await waitFor(() => {
  222. expect(screen.getByText('Archive')).toBeInTheDocument();
  223. expect(screen.getByText('Archive files immediately')).toBeInTheDocument();
  224. });
  225. });
  226. it('renders Review mode option', async () => {
  227. render(<VirtualPrinterSettings />);
  228. await waitFor(() => {
  229. expect(screen.getByText('Review')).toBeInTheDocument();
  230. expect(screen.getByText('Review before archiving')).toBeInTheDocument();
  231. });
  232. });
  233. it('renders Queue mode option', async () => {
  234. render(<VirtualPrinterSettings />);
  235. await waitFor(() => {
  236. expect(screen.getByText('Queue')).toBeInTheDocument();
  237. expect(screen.getByText('Archive and add to queue')).toBeInTheDocument();
  238. });
  239. });
  240. it('highlights current mode (review)', async () => {
  241. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  242. createMockSettings({ mode: 'review' })
  243. );
  244. render(<VirtualPrinterSettings />);
  245. await waitFor(() => {
  246. const reviewButton = screen.getByText('Review').closest('button');
  247. expect(reviewButton?.className).toContain('border-bambu-green');
  248. });
  249. });
  250. it('highlights current mode (legacy immediate maps to archive button)', async () => {
  251. // Pre-#1429 the wire value was `immediate` but the UI button was
  252. // labeled "Archive". Backend migration rewrites stored rows, but a
  253. // stale-cached settings payload may still carry the legacy value —
  254. // the component normalises it client-side so the right button lights up.
  255. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  256. createMockSettings({ mode: 'immediate' })
  257. );
  258. render(<VirtualPrinterSettings />);
  259. await waitFor(() => {
  260. const archiveButton = screen.getByText('Archive').closest('button');
  261. expect(archiveButton?.className).toContain('border-bambu-green');
  262. });
  263. });
  264. it('highlights current mode (legacy print_queue maps to queue button)', async () => {
  265. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  266. createMockSettings({ mode: 'print_queue' })
  267. );
  268. render(<VirtualPrinterSettings />);
  269. await waitFor(() => {
  270. const queueButton = screen.getByText('Queue').closest('button');
  271. expect(queueButton?.className).toContain('border-bambu-green');
  272. });
  273. });
  274. it('changes mode to review on click', async () => {
  275. const user = userEvent.setup();
  276. vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
  277. createMockSettings({ mode: 'review' })
  278. );
  279. render(<VirtualPrinterSettings />);
  280. await waitFor(() => {
  281. expect(screen.getByText('Review')).toBeInTheDocument();
  282. });
  283. const reviewButton = screen.getByText('Review').closest('button');
  284. if (reviewButton) {
  285. await user.click(reviewButton);
  286. }
  287. await waitFor(() => {
  288. expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'review' });
  289. });
  290. });
  291. it('changes mode to queue on click', async () => {
  292. const user = userEvent.setup();
  293. vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
  294. createMockSettings({ mode: 'queue' })
  295. );
  296. render(<VirtualPrinterSettings />);
  297. await waitFor(() => {
  298. expect(screen.getByText('Queue')).toBeInTheDocument();
  299. });
  300. const queueButton = screen.getByText('Queue').closest('button');
  301. if (queueButton) {
  302. await user.click(queueButton);
  303. }
  304. await waitFor(() => {
  305. expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'queue' });
  306. });
  307. });
  308. });
  309. describe('enable/disable toggle', () => {
  310. it('cannot enable without access code', async () => {
  311. const user = userEvent.setup();
  312. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  313. createMockSettings({ enabled: false, access_code_set: false })
  314. );
  315. render(<VirtualPrinterSettings />);
  316. await waitFor(() => {
  317. expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();
  318. });
  319. // Find the toggle switch (it's a button with relative class containing the slider)
  320. const allButtons = screen.getAllByRole('button');
  321. const toggle = allButtons.find((btn) => btn.className.includes('rounded-full') && btn.className.includes('w-12'));
  322. if (toggle) {
  323. await user.click(toggle);
  324. }
  325. // Should not call update API (no access code set)
  326. expect(virtualPrinterApi.updateSettings).not.toHaveBeenCalled();
  327. });
  328. it('can enable when access code is set', async () => {
  329. const user = userEvent.setup();
  330. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  331. createMockSettings({ enabled: false, access_code_set: true })
  332. );
  333. vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
  334. createMockSettings({ enabled: true, access_code_set: true })
  335. );
  336. render(<VirtualPrinterSettings />);
  337. await waitFor(() => {
  338. expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();
  339. });
  340. // Find the toggle switch (it's a button with rounded-full and w-12 classes)
  341. const allButtons = screen.getAllByRole('button');
  342. const toggle = allButtons.find((btn) => btn.className.includes('rounded-full') && btn.className.includes('w-12'));
  343. expect(toggle).toBeDefined();
  344. if (toggle) {
  345. await user.click(toggle);
  346. }
  347. await waitFor(() => {
  348. expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith(
  349. expect.objectContaining({ enabled: true })
  350. );
  351. });
  352. });
  353. it('can disable when enabled', async () => {
  354. const user = userEvent.setup();
  355. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  356. createMockSettings({ enabled: true, access_code_set: true })
  357. );
  358. vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
  359. createMockSettings({ enabled: false, access_code_set: true })
  360. );
  361. render(<VirtualPrinterSettings />);
  362. await waitFor(() => {
  363. expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();
  364. });
  365. // Find the toggle switch
  366. const allButtons = screen.getAllByRole('button');
  367. const toggle = allButtons.find((btn) => btn.className.includes('rounded-full') && btn.className.includes('w-12'));
  368. expect(toggle).toBeDefined();
  369. if (toggle) {
  370. await user.click(toggle);
  371. }
  372. await waitFor(() => {
  373. expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith(
  374. expect.objectContaining({ enabled: false })
  375. );
  376. });
  377. });
  378. });
  379. describe('info section', () => {
  380. it('shows setup required warning', async () => {
  381. render(<VirtualPrinterSettings />);
  382. await waitFor(() => {
  383. expect(screen.getByText('Setup Required')).toBeInTheDocument();
  384. });
  385. });
  386. it('shows link to setup guide', async () => {
  387. render(<VirtualPrinterSettings />);
  388. await waitFor(() => {
  389. expect(screen.getByText('Read the setup guide before enabling')).toBeInTheDocument();
  390. });
  391. });
  392. it('shows how it works section', async () => {
  393. render(<VirtualPrinterSettings />);
  394. await waitFor(() => {
  395. expect(screen.getByText('How it works:')).toBeInTheDocument();
  396. expect(screen.getByText(/virtual printers appear in your slicer/)).toBeInTheDocument();
  397. });
  398. });
  399. });
  400. describe('proxy mode', () => {
  401. it('renders Proxy mode option', async () => {
  402. render(<VirtualPrinterSettings />);
  403. await waitFor(() => {
  404. expect(screen.getByText('Proxy')).toBeInTheDocument();
  405. expect(screen.getByText('Relay to real printer')).toBeInTheDocument();
  406. });
  407. });
  408. it('highlights proxy mode when selected', async () => {
  409. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  410. createMockSettings({ mode: 'proxy', target_printer_id: 1 })
  411. );
  412. render(<VirtualPrinterSettings />);
  413. await waitFor(() => {
  414. const proxyButton = screen.getByText('Proxy').closest('button');
  415. expect(proxyButton?.className).toContain('border-blue-500');
  416. });
  417. });
  418. it('shows proxy status details when running in proxy mode', async () => {
  419. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  420. createMockSettings({
  421. enabled: true,
  422. mode: 'proxy',
  423. target_printer_id: 1,
  424. status: {
  425. enabled: true,
  426. running: true,
  427. mode: 'proxy',
  428. name: 'Bambuddy (Proxy)',
  429. serial: '00M00A391800001',
  430. model: 'BL-P001',
  431. model_name: 'X1C',
  432. pending_files: 0,
  433. proxy: {
  434. running: true,
  435. target_host: '192.168.1.100',
  436. ftp_port: 990, // Privileged port for Bambu Studio compatibility
  437. mqtt_port: 8883,
  438. ftp_connections: 1,
  439. mqtt_connections: 2,
  440. },
  441. },
  442. })
  443. );
  444. render(<VirtualPrinterSettings />);
  445. await waitFor(() => {
  446. expect(screen.getByText('Running')).toBeInTheDocument();
  447. expect(screen.getByText('Status Details')).toBeInTheDocument();
  448. // IP address appears in multiple places (target printer and status details)
  449. expect(screen.getAllByText('192.168.1.100').length).toBeGreaterThan(0);
  450. });
  451. });
  452. it('shows target printer dropdown in proxy mode', async () => {
  453. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  454. createMockSettings({ mode: 'proxy' })
  455. );
  456. render(<VirtualPrinterSettings />);
  457. await waitFor(() => {
  458. expect(screen.getByText('Target Printer')).toBeInTheDocument();
  459. expect(screen.getByText('Select a printer...')).toBeInTheDocument();
  460. });
  461. });
  462. it('changes mode to proxy on click', async () => {
  463. const user = userEvent.setup();
  464. vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
  465. createMockSettings({ mode: 'proxy' })
  466. );
  467. render(<VirtualPrinterSettings />);
  468. await waitFor(() => {
  469. expect(screen.getByText('Proxy')).toBeInTheDocument();
  470. });
  471. const proxyButton = screen.getByText('Proxy').closest('button');
  472. if (proxyButton) {
  473. await user.click(proxyButton);
  474. }
  475. await waitFor(() => {
  476. expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'proxy' });
  477. });
  478. });
  479. });
  480. describe('network interface override', () => {
  481. it('shows interface dropdown when enabled in immediate mode', async () => {
  482. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  483. createMockSettings({ enabled: true, mode: 'archive' })
  484. );
  485. render(<VirtualPrinterSettings />);
  486. await waitFor(() => {
  487. expect(screen.getByText('Network Interface Override')).toBeInTheDocument();
  488. });
  489. });
  490. it('shows interface dropdown when enabled in review mode', async () => {
  491. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  492. createMockSettings({ enabled: true, mode: 'review' })
  493. );
  494. render(<VirtualPrinterSettings />);
  495. await waitFor(() => {
  496. expect(screen.getByText('Network Interface Override')).toBeInTheDocument();
  497. });
  498. });
  499. it('shows interface dropdown when enabled in print_queue mode', async () => {
  500. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  501. createMockSettings({ enabled: true, mode: 'queue' })
  502. );
  503. render(<VirtualPrinterSettings />);
  504. await waitFor(() => {
  505. expect(screen.getByText('Network Interface Override')).toBeInTheDocument();
  506. });
  507. });
  508. it('shows interface dropdown when enabled in proxy mode', async () => {
  509. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  510. createMockSettings({ enabled: true, mode: 'proxy', target_printer_id: 1 })
  511. );
  512. render(<VirtualPrinterSettings />);
  513. await waitFor(() => {
  514. expect(screen.getByText('Network Interface Override')).toBeInTheDocument();
  515. });
  516. });
  517. it('hides interface dropdown when disabled', async () => {
  518. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  519. createMockSettings({ enabled: false, mode: 'archive' })
  520. );
  521. render(<VirtualPrinterSettings />);
  522. await waitFor(() => {
  523. expect(screen.getByText('Mode')).toBeInTheDocument();
  524. });
  525. expect(screen.queryByText('Network Interface Override')).not.toBeInTheDocument();
  526. });
  527. it('shows configured status when interface is set', async () => {
  528. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  529. createMockSettings({ enabled: true, mode: 'archive', remote_interface_ip: '10.0.0.50' })
  530. );
  531. render(<VirtualPrinterSettings />);
  532. await waitFor(() => {
  533. expect(screen.getByText('Interface override active')).toBeInTheDocument();
  534. });
  535. });
  536. it('shows optional hint when no interface is set', async () => {
  537. vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
  538. createMockSettings({ enabled: true, mode: 'archive', remote_interface_ip: '' })
  539. );
  540. render(<VirtualPrinterSettings />);
  541. await waitFor(() => {
  542. expect(screen.getByText(/Optional.*auto-detected IP/)).toBeInTheDocument();
  543. });
  544. });
  545. });
  546. });