SettingsPage.test.tsx 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345
  1. /**
  2. * Tests for the SettingsPage component.
  3. */
  4. import { describe, it, expect, beforeEach, vi } from 'vitest';
  5. import { fireEvent, screen, waitFor, within } from '@testing-library/react';
  6. import userEvent from '@testing-library/user-event';
  7. import { render } from '../utils';
  8. import { SettingsPage } from '../../pages/SettingsPage';
  9. import { http, HttpResponse } from 'msw';
  10. import { server } from '../mocks/server';
  11. import { SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, SIDEBAR_ORDER_KEY } from '../../utils/sidebarLayout';
  12. import { setAuthToken } from '../../api/client';
  13. const mockSettings = {
  14. auto_archive: true,
  15. save_thumbnails: true,
  16. capture_finish_photo: true,
  17. default_filament_cost: 25.0,
  18. currency: 'USD',
  19. ams_humidity_good: 40,
  20. ams_humidity_fair: 60,
  21. ams_temp_good: 30,
  22. ams_temp_fair: 35,
  23. time_format: 'system',
  24. date_format: 'system',
  25. mqtt_enabled: false,
  26. mqtt_host: '',
  27. mqtt_port: 1883,
  28. spoolman_enabled: false,
  29. spoolman_url: '',
  30. ha_enabled: false,
  31. ha_url: '',
  32. ha_token: '',
  33. check_updates: false,
  34. check_printer_firmware: false,
  35. bed_cooled_threshold: 35,
  36. };
  37. describe('SettingsPage', () => {
  38. beforeEach(() => {
  39. // BrowserRouter shares window.location across tests; reset it so a tab
  40. // switch in one test (e.g. clicking "Workflow") doesn't carry into
  41. // sibling tests that expect to land on the default General tab.
  42. window.history.replaceState({}, '', '/');
  43. vi.mocked(localStorage.getItem).mockReset();
  44. vi.mocked(localStorage.setItem).mockReset();
  45. vi.mocked(localStorage.removeItem).mockReset();
  46. vi.mocked(localStorage.clear).mockReset();
  47. localStorage.clear();
  48. setAuthToken(null);
  49. server.use(
  50. http.get('/api/v1/settings/', () => {
  51. return HttpResponse.json(mockSettings);
  52. }),
  53. http.put('/api/v1/settings/', async ({ request }) => {
  54. const body = await request.json();
  55. return HttpResponse.json({ ...mockSettings, ...body });
  56. }),
  57. http.get('/api/v1/printers/', () => {
  58. return HttpResponse.json([]);
  59. }),
  60. http.get('/api/v1/smart-plugs/', () => {
  61. return HttpResponse.json([]);
  62. }),
  63. http.get('/api/v1/notifications/', () => {
  64. return HttpResponse.json([]);
  65. }),
  66. http.get('/api/v1/api-keys/', () => {
  67. return HttpResponse.json([]);
  68. }),
  69. http.get('/api/v1/mqtt/status', () => {
  70. return HttpResponse.json({ enabled: false });
  71. }),
  72. http.get('/api/v1/virtual-printer/status', () => {
  73. return HttpResponse.json({ running: false });
  74. }),
  75. http.get('/api/v1/auth/status', () => {
  76. return HttpResponse.json({ auth_enabled: false, requires_setup: false });
  77. }),
  78. http.get('/api/v1/external-links/', () => {
  79. return HttpResponse.json([]);
  80. })
  81. );
  82. });
  83. describe('rendering', () => {
  84. it('renders the page title', async () => {
  85. render(<SettingsPage />);
  86. await waitFor(() => {
  87. // Use role-based query to avoid conflicts with dropdown options
  88. expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument();
  89. });
  90. });
  91. it('shows settings tabs', async () => {
  92. render(<SettingsPage />);
  93. await waitFor(() => {
  94. // Use getAllByText since "General" appears both as tab and section heading
  95. expect(screen.getAllByText('General').length).toBeGreaterThan(0);
  96. expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
  97. expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
  98. expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
  99. expect(screen.getByText('Network')).toBeInTheDocument();
  100. expect(screen.getByText('API Keys')).toBeInTheDocument();
  101. });
  102. });
  103. });
  104. describe('general settings', () => {
  105. it('shows date format setting', async () => {
  106. render(<SettingsPage />);
  107. await waitFor(() => {
  108. expect(screen.getByText('Date Format')).toBeInTheDocument();
  109. });
  110. });
  111. it('shows time format setting', async () => {
  112. render(<SettingsPage />);
  113. await waitFor(() => {
  114. expect(screen.getByText('Time Format')).toBeInTheDocument();
  115. });
  116. });
  117. it('shows default printer setting', async () => {
  118. render(<SettingsPage />);
  119. await waitFor(() => {
  120. expect(screen.getByText('Default Printer')).toBeInTheDocument();
  121. });
  122. });
  123. it('shows preferred slicer setting on Workflow tab', async () => {
  124. const user = userEvent.setup();
  125. render(<SettingsPage />);
  126. await waitFor(() => {
  127. expect(screen.getByText('Workflow')).toBeInTheDocument();
  128. });
  129. await user.click(screen.getByText('Workflow'));
  130. await waitFor(() => {
  131. expect(screen.getByText('Preferred Slicer')).toBeInTheDocument();
  132. });
  133. });
  134. it('shows slicer dropdown with both options on Workflow tab', async () => {
  135. const user = userEvent.setup();
  136. render(<SettingsPage />);
  137. await waitFor(() => {
  138. expect(screen.getByText('Workflow')).toBeInTheDocument();
  139. });
  140. await user.click(screen.getByText('Workflow'));
  141. await waitFor(() => {
  142. const slicerSelect = screen.getAllByDisplayValue('Bambu Studio');
  143. expect(slicerSelect.length).toBeGreaterThan(0);
  144. });
  145. });
  146. it('shows appearance section', async () => {
  147. render(<SettingsPage />);
  148. await waitFor(() => {
  149. expect(screen.getByText('Appearance')).toBeInTheDocument();
  150. });
  151. });
  152. it('shows updates section with firmware toggle', async () => {
  153. render(<SettingsPage />);
  154. await waitFor(() => {
  155. expect(screen.getByText('Updates')).toBeInTheDocument();
  156. expect(screen.getByText('Check for updates')).toBeInTheDocument();
  157. expect(screen.getByText('Check printer firmware')).toBeInTheDocument();
  158. });
  159. });
  160. it('hides a Bambuddy sidebar page from Sidebar', async () => {
  161. const user = userEvent.setup();
  162. render(<SettingsPage />);
  163. await screen.findByRole('heading', { name: 'Sidebar' });
  164. await screen.findAllByText('Visible in sidebar');
  165. vi.mocked(localStorage.setItem).mockClear();
  166. await user.click((await screen.findAllByLabelText('Hide page'))[0]);
  167. expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify(['printers']));
  168. expect(screen.getByText('Hidden from sidebar')).toBeInTheDocument();
  169. });
  170. it('shows a previously hidden Bambuddy sidebar page from Sidebar', async () => {
  171. vi.mocked(localStorage.getItem).mockImplementation((key) => {
  172. if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers']);
  173. return null;
  174. });
  175. const user = userEvent.setup();
  176. render(<SettingsPage />);
  177. await screen.findByRole('heading', { name: 'Sidebar' });
  178. await screen.findByText('Hidden from sidebar');
  179. vi.mocked(localStorage.setItem).mockClear();
  180. await user.click(await screen.findByLabelText('Show page'));
  181. expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
  182. expect(screen.getAllByText('Visible in sidebar').length).toBeGreaterThan(0);
  183. });
  184. it('does not allow Settings to be hidden from Sidebar', async () => {
  185. render(<SettingsPage />);
  186. await screen.findByRole('heading', { name: 'Sidebar' });
  187. await screen.findByText('Required in sidebar');
  188. const settingsVisibilityButton = await screen.findByLabelText('Settings cannot be hidden');
  189. expect(settingsVisibilityButton).toBeDisabled();
  190. expect(screen.getByText('Required in sidebar')).toBeInTheDocument();
  191. });
  192. it('presents external links and Bambuddy pages in saved sidebar order', async () => {
  193. vi.mocked(localStorage.getItem).mockImplementation((key) => {
  194. if (key === SIDEBAR_ORDER_KEY) return JSON.stringify(['ext-7', 'printers', 'settings']);
  195. return null;
  196. });
  197. server.use(
  198. http.get('/api/v1/external-links/', () =>
  199. HttpResponse.json([
  200. {
  201. id: 7,
  202. name: 'Docs',
  203. url: 'https://docs.example.test',
  204. icon: 'Link',
  205. open_in_new_tab: true,
  206. custom_icon: null,
  207. sort_order: 0,
  208. created_at: '2026-01-01T00:00:00Z',
  209. updated_at: '2026-01-01T00:00:00Z',
  210. },
  211. ]),
  212. ),
  213. );
  214. render(<SettingsPage />);
  215. await screen.findByRole('heading', { name: 'Sidebar' });
  216. const docs = await screen.findByText('Docs');
  217. const printers = screen.getAllByText('Printers').find(element => element.closest('[draggable="true"]'));
  218. expect(printers).toBeDefined();
  219. expect(docs.compareDocumentPosition(printers) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
  220. });
  221. it('saves mixed Sidebar order when items are dragged', async () => {
  222. server.use(
  223. http.get('/api/v1/external-links/', () =>
  224. HttpResponse.json([
  225. {
  226. id: 7,
  227. name: 'Docs',
  228. url: 'https://docs.example.test',
  229. icon: 'Link',
  230. open_in_new_tab: true,
  231. custom_icon: null,
  232. sort_order: 0,
  233. created_at: '2026-01-01T00:00:00Z',
  234. updated_at: '2026-01-01T00:00:00Z',
  235. },
  236. ]),
  237. ),
  238. );
  239. render(<SettingsPage />);
  240. await screen.findByRole('heading', { name: 'Sidebar' });
  241. const docsRow = (await screen.findByText('Docs')).closest('[draggable="true"]');
  242. const printersRow = screen.getAllByText('Printers')
  243. .find(element => element.closest('[draggable="true"]'))
  244. ?.closest('[draggable="true"]');
  245. expect(docsRow).not.toBeNull();
  246. expect(printersRow).not.toBeNull();
  247. vi.mocked(localStorage.setItem).mockClear();
  248. const dataTransfer = {
  249. effectAllowed: '',
  250. dropEffect: '',
  251. setData: vi.fn(),
  252. };
  253. fireEvent.dragStart(docsRow!, { dataTransfer });
  254. fireEvent.dragOver(printersRow!, { dataTransfer });
  255. fireEvent.drop(printersRow!, { dataTransfer });
  256. expect(localStorage.setItem).toHaveBeenCalledWith(
  257. SIDEBAR_ORDER_KEY,
  258. JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'notifications', 'settings']),
  259. );
  260. });
  261. it('resets Sidebar to all pages first and configured links at the bottom', async () => {
  262. vi.mocked(localStorage.getItem).mockImplementation((key) => {
  263. if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers', 'stats']);
  264. if (key === SIDEBAR_ORDER_KEY) return JSON.stringify(['ext-7', 'settings', 'printers']);
  265. return null;
  266. });
  267. server.use(
  268. http.get('/api/v1/external-links/', () =>
  269. HttpResponse.json([
  270. {
  271. id: 7,
  272. name: 'Docs',
  273. url: 'https://docs.example.test',
  274. icon: 'Link',
  275. open_in_new_tab: true,
  276. custom_icon: null,
  277. sort_order: 0,
  278. created_at: '2026-01-01T00:00:00Z',
  279. updated_at: '2026-01-01T00:00:00Z',
  280. },
  281. ]),
  282. ),
  283. );
  284. const user = userEvent.setup();
  285. render(<SettingsPage />);
  286. const heading = await screen.findByRole('heading', { name: 'Sidebar' });
  287. const card = heading.closest('#card-sidebar-links');
  288. expect(card).not.toBeNull();
  289. await screen.findByText('Docs');
  290. vi.mocked(localStorage.setItem).mockClear();
  291. await user.click(within(card as HTMLElement).getByRole('button', { name: /reset/i }));
  292. expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
  293. expect(localStorage.setItem).toHaveBeenCalledWith(
  294. SIDEBAR_ORDER_KEY,
  295. JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'notifications', 'settings', 'ext-7']),
  296. );
  297. const settingsRow = screen.getAllByText('Settings')
  298. .find(element => element.closest('[draggable="true"]'))
  299. ?.closest('[draggable="true"]');
  300. const docsRow = screen.getByText('Docs').closest('[draggable="true"]');
  301. expect(settingsRow).not.toBeNull();
  302. expect(docsRow).not.toBeNull();
  303. expect(settingsRow!.compareDocumentPosition(docsRow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
  304. expect(screen.queryByText('Hidden from sidebar')).not.toBeInTheDocument();
  305. });
  306. it('sets the current Sidebar order as the backend default for settings admins', async () => {
  307. let defaultSidebarOrderPayload: string | null = null;
  308. vi.mocked(localStorage.getItem).mockImplementation((key) => {
  309. if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['stats']);
  310. return null;
  311. });
  312. server.use(
  313. http.get('/api/v1/auth/status', () =>
  314. HttpResponse.json({ auth_enabled: true, requires_setup: false }),
  315. ),
  316. http.get('/api/v1/auth/me', () =>
  317. HttpResponse.json({
  318. id: 1,
  319. username: 'admin',
  320. role: 'admin',
  321. is_active: true,
  322. is_admin: false,
  323. groups: [{ id: 1, name: 'Administrators' }],
  324. permissions: ['settings:update'],
  325. created_at: '2026-01-01T00:00:00Z',
  326. }),
  327. ),
  328. http.get('/api/v1/settings/', () =>
  329. HttpResponse.json({ ...mockSettings, default_sidebar_order: '' }),
  330. ),
  331. http.put('/api/v1/settings/', async ({ request }) => {
  332. const body = await request.json() as { default_sidebar_order?: string };
  333. defaultSidebarOrderPayload = body.default_sidebar_order ?? null;
  334. return HttpResponse.json({ ...mockSettings, ...body });
  335. }),
  336. );
  337. setAuthToken('test-token');
  338. const user = userEvent.setup();
  339. render(<SettingsPage />);
  340. const heading = await screen.findByRole('heading', { name: 'Sidebar' });
  341. const card = heading.closest('#card-sidebar-links');
  342. expect(card).not.toBeNull();
  343. await user.click(within(card as HTMLElement).getByRole('switch', { name: 'Set Default' }));
  344. await waitFor(() => {
  345. expect(defaultSidebarOrderPayload).not.toBeNull();
  346. });
  347. expect(JSON.parse(defaultSidebarOrderPayload!)).toEqual({
  348. order: [
  349. 'printers',
  350. 'inventory',
  351. 'archives',
  352. 'queue',
  353. 'projects',
  354. 'files',
  355. 'makerworld',
  356. 'profiles',
  357. 'maintenance',
  358. 'stats',
  359. 'notifications',
  360. 'settings',
  361. ],
  362. hiddenSystemItemIds: ['stats'],
  363. });
  364. });
  365. });
  366. describe('update CTA per deployment shape', () => {
  367. // The update card branches on the deployment shape returned by
  368. // /updates/check. Each branch is mutually exclusive — verify the right
  369. // one wins so HA addon users never see the docker-compose snippet
  370. // (which they can't run from inside an HA addon container) and Docker
  371. // users never see the in-app Install button (which would no-op).
  372. const renderWithUpdateCheck = async (
  373. checkBody: Record<string, unknown>,
  374. ) => {
  375. server.use(
  376. http.get('/api/v1/settings/', () =>
  377. HttpResponse.json({ ...mockSettings, check_updates: true }),
  378. ),
  379. http.get('/api/v1/updates/check', () => HttpResponse.json(checkBody)),
  380. );
  381. render(<SettingsPage />);
  382. await waitFor(() => {
  383. expect(screen.getByText('Updates')).toBeInTheDocument();
  384. });
  385. };
  386. it('shows the HA Supervisor message when running as an HA addon', async () => {
  387. await renderWithUpdateCheck({
  388. update_available: true,
  389. current_version: '0.2.4',
  390. latest_version: '0.2.5',
  391. release_name: '0.2.5',
  392. release_notes: '',
  393. release_url: 'https://example.invalid/r',
  394. published_at: '2099-01-01T00:00:00Z',
  395. is_docker: true,
  396. is_ha_addon: true,
  397. update_method: 'ha_addon',
  398. });
  399. await waitFor(() => {
  400. expect(
  401. screen.getByText(/Home Assistant Supervisor/i),
  402. ).toBeInTheDocument();
  403. });
  404. // Docker hint must NOT render — HA branch wins.
  405. expect(screen.queryByText('docker compose pull && docker compose up -d')).not.toBeInTheDocument();
  406. expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
  407. });
  408. it('shows the docker-compose snippet for Docker (non-HA) deployments', async () => {
  409. await renderWithUpdateCheck({
  410. update_available: true,
  411. current_version: '0.2.4',
  412. latest_version: '0.2.5',
  413. release_name: '0.2.5',
  414. release_notes: '',
  415. release_url: 'https://example.invalid/r',
  416. published_at: '2099-01-01T00:00:00Z',
  417. is_docker: true,
  418. is_ha_addon: false,
  419. update_method: 'docker',
  420. });
  421. await waitFor(() => {
  422. expect(screen.getByText('docker compose pull && docker compose up -d')).toBeInTheDocument();
  423. });
  424. expect(screen.queryByText(/Home Assistant Supervisor/i)).not.toBeInTheDocument();
  425. expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
  426. });
  427. it('shows the installer-download link for Windows installer installs', async () => {
  428. const downloadUrl =
  429. 'https://github.com/maziggy/bambuddy/releases/download/v0.2.5/bambuddy-0.2.5-windows-x64-setup.exe';
  430. await renderWithUpdateCheck({
  431. update_available: true,
  432. current_version: '0.2.4',
  433. latest_version: '0.2.5',
  434. release_name: '0.2.5',
  435. release_notes: '',
  436. release_url: 'https://github.com/maziggy/bambuddy/releases/tag/v0.2.5',
  437. published_at: '2099-01-01T00:00:00Z',
  438. is_docker: false,
  439. is_ha_addon: false,
  440. is_windows_installer: true,
  441. update_method: 'windows_installer',
  442. installer_download_url: downloadUrl,
  443. });
  444. const link = await screen.findByRole('link', { name: /download installer for v0\.2\.5/i });
  445. expect(link).toHaveAttribute('href', downloadUrl);
  446. expect(link).toHaveAttribute('target', '_blank');
  447. expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
  448. // The in-app update button must NOT render — the git-fetch path can't
  449. // work from an installer payload.
  450. expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
  451. expect(screen.queryByText(/Home Assistant Supervisor/i)).not.toBeInTheDocument();
  452. expect(screen.queryByText('docker compose pull && docker compose up -d')).not.toBeInTheDocument();
  453. });
  454. });
  455. describe('tabs navigation', () => {
  456. it('can switch to Network tab', async () => {
  457. const user = userEvent.setup();
  458. render(<SettingsPage />);
  459. // Wait for settings to load first
  460. await waitFor(() => {
  461. expect(screen.getByText('Date Format')).toBeInTheDocument();
  462. });
  463. await user.click(screen.getByText('Network'));
  464. await waitFor(() => {
  465. // Network tab contains MQTT Publishing section
  466. expect(screen.getByText('MQTT Publishing')).toBeInTheDocument();
  467. });
  468. });
  469. it('can switch to Smart Plugs tab', async () => {
  470. const user = userEvent.setup();
  471. render(<SettingsPage />);
  472. await waitFor(() => {
  473. expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
  474. });
  475. await user.click(screen.getByText('Smart Plugs'));
  476. await waitFor(() => {
  477. expect(screen.getByText('Add Smart Plug')).toBeInTheDocument();
  478. });
  479. });
  480. it('can switch to Notifications tab', async () => {
  481. const user = userEvent.setup();
  482. render(<SettingsPage />);
  483. await waitFor(() => {
  484. expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
  485. });
  486. // Click the tab button (not the mobile dropdown option)
  487. const notificationButtons = screen.getAllByText('Notifications');
  488. const tabButton = notificationButtons.find(el => el.tagName === 'BUTTON') || notificationButtons[0];
  489. await user.click(tabButton);
  490. await waitFor(() => {
  491. expect(screen.getByText('Add Provider')).toBeInTheDocument();
  492. });
  493. });
  494. it('can switch to Filament tab', async () => {
  495. const user = userEvent.setup();
  496. render(<SettingsPage />);
  497. await waitFor(() => {
  498. expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
  499. });
  500. await user.click(screen.getAllByText('Filament')[0]);
  501. await waitFor(() => {
  502. expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();
  503. });
  504. });
  505. });
  506. describe('Workflow tab', () => {
  507. it('can switch to Workflow tab', async () => {
  508. const user = userEvent.setup();
  509. render(<SettingsPage />);
  510. await waitFor(() => {
  511. expect(screen.getByText('Workflow')).toBeInTheDocument();
  512. });
  513. await user.click(screen.getByText('Workflow'));
  514. await waitFor(() => {
  515. expect(screen.getByText('Staggered Start')).toBeInTheDocument();
  516. });
  517. });
  518. it('shows stagger settings on Workflow tab', async () => {
  519. const user = userEvent.setup();
  520. render(<SettingsPage />);
  521. await waitFor(() => {
  522. expect(screen.getByText('Workflow')).toBeInTheDocument();
  523. });
  524. await user.click(screen.getByText('Workflow'));
  525. await waitFor(() => {
  526. expect(screen.getByText('Staggered Start')).toBeInTheDocument();
  527. expect(screen.getByText('Group size')).toBeInTheDocument();
  528. expect(screen.getByText('Interval (minutes)')).toBeInTheDocument();
  529. });
  530. });
  531. it('shows auto-drying settings on Workflow tab', async () => {
  532. const user = userEvent.setup();
  533. render(<SettingsPage />);
  534. await waitFor(() => {
  535. expect(screen.getByText('Workflow')).toBeInTheDocument();
  536. });
  537. await user.click(screen.getByText('Workflow'));
  538. await waitFor(() => {
  539. expect(screen.getByText('Queue Auto-Drying')).toBeInTheDocument();
  540. });
  541. });
  542. it('shows per-filament humidity threshold editor on Workflow tab (#1605)', async () => {
  543. const user = userEvent.setup();
  544. render(<SettingsPage />);
  545. await waitFor(() => {
  546. expect(screen.getByText('Workflow')).toBeInTheDocument();
  547. });
  548. await user.click(screen.getByText('Workflow'));
  549. await waitFor(() => {
  550. expect(screen.getByText('Humidity Thresholds')).toBeInTheDocument();
  551. // Default row is unique to the humidity editor (drying presets has no
  552. // default row), so we can pin it without disambiguating from the
  553. // adjacent drying-presets table that also lists PLA/ASA/etc.
  554. expect(screen.getByText('Default (unknown types)')).toBeInTheDocument();
  555. // Filament rows render in both tables — assert by count instead of
  556. // a single getByText. 8 default filaments × 2 tables = 16 PLAs etc.
  557. expect(screen.getAllByText('PLA').length).toBeGreaterThanOrEqual(2);
  558. expect(screen.getAllByText('ASA').length).toBeGreaterThanOrEqual(2);
  559. });
  560. });
  561. it('shows default print options on Workflow tab', async () => {
  562. const user = userEvent.setup();
  563. render(<SettingsPage />);
  564. await waitFor(() => {
  565. expect(screen.getByText('Workflow')).toBeInTheDocument();
  566. });
  567. await user.click(screen.getByText('Workflow'));
  568. await waitFor(() => {
  569. expect(screen.getByText('Default Print Options')).toBeInTheDocument();
  570. expect(screen.getByText('Bed Levelling')).toBeInTheDocument();
  571. expect(screen.getByText('Flow Calibration')).toBeInTheDocument();
  572. expect(screen.getByText('Vibration Calibration')).toBeInTheDocument();
  573. expect(screen.getByText('First Layer Inspection')).toBeInTheDocument();
  574. expect(screen.getByText('Timelapse')).toBeInTheDocument();
  575. });
  576. });
  577. it('shows default print options description', async () => {
  578. const user = userEvent.setup();
  579. render(<SettingsPage />);
  580. await waitFor(() => {
  581. expect(screen.getByText('Workflow')).toBeInTheDocument();
  582. });
  583. await user.click(screen.getByText('Workflow'));
  584. await waitFor(() => {
  585. expect(screen.getByText(/overridden per print in the print dialog/)).toBeInTheDocument();
  586. });
  587. });
  588. });
  589. describe('API Keys tab', () => {
  590. it('can switch to API Keys tab', async () => {
  591. const user = userEvent.setup();
  592. render(<SettingsPage />);
  593. await waitFor(() => {
  594. expect(screen.getByText('API Keys')).toBeInTheDocument();
  595. });
  596. await user.click(screen.getByText('API Keys'));
  597. await waitFor(() => {
  598. // Button text is "Create Key"
  599. expect(screen.getByText('Create Key')).toBeInTheDocument();
  600. });
  601. });
  602. });
  603. describe('SpoolBuddy tab badge', () => {
  604. const baseDevice = {
  605. id: 1,
  606. device_id: 'sb-0001',
  607. hostname: 'sb-kitchen',
  608. ip_address: '10.0.0.1',
  609. backend_url: null,
  610. firmware_version: '1.0.0',
  611. has_nfc: true,
  612. has_scale: true,
  613. tare_offset: 0,
  614. calibration_factor: 1.0,
  615. nfc_reader_type: null,
  616. nfc_connection: null,
  617. display_brightness: 100,
  618. display_blank_timeout: 0,
  619. has_backlight: false,
  620. last_calibrated_at: null,
  621. last_seen: new Date().toISOString(),
  622. pending_command: null,
  623. nfc_ok: true,
  624. scale_ok: true,
  625. uptime_s: 100,
  626. update_status: null,
  627. update_message: null,
  628. system_stats: null,
  629. online: true,
  630. created_at: '2024-01-01T00:00:00Z',
  631. updated_at: '2024-01-01T00:00:00Z',
  632. };
  633. it('shows device count and green bullet when at least one device is online', async () => {
  634. server.use(
  635. http.get('/api/v1/spoolbuddy/devices', () => {
  636. return HttpResponse.json([
  637. { ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'sb-kitchen', online: true },
  638. { ...baseDevice, id: 2, device_id: 'sb-0002', hostname: 'sb-ghost', online: false },
  639. ]);
  640. })
  641. );
  642. render(<SettingsPage />);
  643. // Find the tab button (not the header) — it's the <button> containing the SpoolBuddy text
  644. const tabButton = await waitFor(() => {
  645. const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));
  646. expect(buttons.length).toBeGreaterThan(0);
  647. return buttons[0];
  648. });
  649. // Count pill rendered
  650. await waitFor(() => {
  651. expect(tabButton.textContent).toContain('2');
  652. });
  653. // Green status bullet (at least one device online)
  654. await waitFor(() => {
  655. expect(tabButton.querySelector('.bg-green-400')).not.toBeNull();
  656. });
  657. });
  658. it('shows gray bullet when all devices are offline', async () => {
  659. server.use(
  660. http.get('/api/v1/spoolbuddy/devices', () => {
  661. return HttpResponse.json([{ ...baseDevice, online: false }]);
  662. })
  663. );
  664. render(<SettingsPage />);
  665. const tabButton = await waitFor(() => {
  666. const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));
  667. expect(buttons.length).toBeGreaterThan(0);
  668. return buttons[0];
  669. });
  670. await waitFor(() => {
  671. expect(tabButton.querySelector('.bg-gray-500')).not.toBeNull();
  672. expect(tabButton.querySelector('.bg-green-400')).toBeNull();
  673. });
  674. });
  675. it('hides the count pill when no devices are registered', async () => {
  676. server.use(
  677. http.get('/api/v1/spoolbuddy/devices', () => HttpResponse.json([]))
  678. );
  679. render(<SettingsPage />);
  680. const tabButton = await waitFor(() => {
  681. const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));
  682. expect(buttons.length).toBeGreaterThan(0);
  683. return buttons[0];
  684. });
  685. // The only numeric content should NOT be present — tab label only
  686. await waitFor(() => {
  687. expect(tabButton.textContent).toBe('SpoolBuddy');
  688. });
  689. });
  690. });
  691. describe('API Keys tab — delete flow', () => {
  692. // Without setQueryData on success the deleted row stayed visible until a
  693. // manual reload — invalidateQueries didn't reliably trigger a UI swap on
  694. // every browser. Pin the synchronous-removal contract here.
  695. it('removes a deleted key from the list without a page reload', async () => {
  696. const initialKeys = [
  697. {
  698. id: 42,
  699. name: 'CI deploy key',
  700. key_prefix: 'bk_abcd1234',
  701. can_queue: true,
  702. can_control_printer: false,
  703. can_read_status: true,
  704. printer_ids: null,
  705. enabled: true,
  706. last_used: null,
  707. created_at: '2026-01-01T00:00:00Z',
  708. expires_at: null,
  709. },
  710. ];
  711. let deleteCallCount = 0;
  712. server.use(
  713. http.get('/api/v1/api-keys/', () => HttpResponse.json(initialKeys)),
  714. http.delete('/api/v1/api-keys/:id', ({ params }) => {
  715. deleteCallCount += 1;
  716. expect(params.id).toBe('42');
  717. return HttpResponse.json({ message: 'API key deleted' });
  718. })
  719. );
  720. const user = userEvent.setup();
  721. render(<SettingsPage />);
  722. // Switch to API Keys tab. Both desktop tab + mobile dropdown render
  723. // the label, so just grab the button form.
  724. await waitFor(() => {
  725. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  726. });
  727. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  728. expect(tabButton).toBeDefined();
  729. await user.click(tabButton!);
  730. // Key is listed
  731. await waitFor(() => {
  732. expect(screen.getByText('CI deploy key')).toBeInTheDocument();
  733. });
  734. // Click the trash button on the row
  735. const cards = screen.getByText('CI deploy key').closest('.flex.items-center.justify-between');
  736. expect(cards).not.toBeNull();
  737. const trashButton = cards!.querySelectorAll('button');
  738. await user.click(trashButton[trashButton.length - 1]);
  739. // Confirm the deletion in the modal
  740. const confirmButton = await screen.findByRole('button', { name: /delete/i });
  741. await user.click(confirmButton);
  742. // The deleted key disappears from the list immediately — no manual
  743. // reload required. setQueryData drops it before any refetch could fire.
  744. await waitFor(() => {
  745. expect(screen.queryByText('CI deploy key')).not.toBeInTheDocument();
  746. });
  747. expect(deleteCallCount).toBe(1);
  748. });
  749. });
  750. describe('API Keys tab — #1182 cloud access + ownership UI', () => {
  751. // The list now exposes two new bits of information per row:
  752. // - "Cloud" badge when can_access_cloud=true
  753. // - "Legacy" badge when user_id IS NULL (created before per-user ownership)
  754. // These tell the operator at a glance which keys can read /cloud/* data
  755. // and which keys need to be recreated to gain that capability.
  756. it('renders the Cloud badge for keys with can_access_cloud=true and the Legacy badge for ownerless keys', async () => {
  757. const keys = [
  758. {
  759. id: 1,
  760. name: 'cloud-reader',
  761. key_prefix: 'bk_cloud123',
  762. user_id: 7,
  763. can_queue: false,
  764. can_control_printer: false,
  765. can_read_status: true,
  766. can_access_cloud: true,
  767. printer_ids: null,
  768. enabled: true,
  769. last_used: null,
  770. created_at: '2026-04-30T00:00:00Z',
  771. expires_at: null,
  772. },
  773. {
  774. id: 2,
  775. name: 'legacy-key',
  776. key_prefix: 'bk_legacy01',
  777. user_id: null,
  778. can_queue: true,
  779. can_control_printer: false,
  780. can_read_status: true,
  781. can_access_cloud: false,
  782. printer_ids: null,
  783. enabled: true,
  784. last_used: null,
  785. created_at: '2025-01-01T00:00:00Z',
  786. expires_at: null,
  787. },
  788. ];
  789. server.use(http.get('/api/v1/api-keys/', () => HttpResponse.json(keys)));
  790. const user = userEvent.setup();
  791. render(<SettingsPage />);
  792. await waitFor(() => {
  793. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  794. });
  795. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  796. await user.click(tabButton!);
  797. await waitFor(() => {
  798. expect(screen.getByText('cloud-reader')).toBeInTheDocument();
  799. expect(screen.getByText('legacy-key')).toBeInTheDocument();
  800. });
  801. // Cloud-enabled key gets the Cloud badge but NOT the Legacy badge.
  802. const cloudRow = screen.getByText('cloud-reader').closest('.flex.items-center.justify-between');
  803. expect(cloudRow).not.toBeNull();
  804. expect(cloudRow!.textContent).toContain('Cloud');
  805. expect(cloudRow!.textContent).not.toContain('Legacy');
  806. // Ownerless key gets Legacy but NOT Cloud (can_access_cloud=false).
  807. const legacyRow = screen.getByText('legacy-key').closest('.flex.items-center.justify-between');
  808. expect(legacyRow).not.toBeNull();
  809. expect(legacyRow!.textContent).toContain('Legacy');
  810. // Strip the Cloud-flag check by limiting to badge area — the
  811. // "Allow cloud access" text from the create form isn't visible here.
  812. expect(legacyRow!.querySelector('.bg-purple-500\\/20')).toBeNull();
  813. });
  814. it('passes can_access_cloud through to the create call when the toggle is checked', async () => {
  815. let posted: { name?: string; can_access_cloud?: boolean } | null = null;
  816. server.use(
  817. http.get('/api/v1/api-keys/', () => HttpResponse.json([])),
  818. http.post('/api/v1/api-keys/', async ({ request }) => {
  819. posted = (await request.json()) as { name?: string; can_access_cloud?: boolean };
  820. return HttpResponse.json({
  821. id: 99,
  822. key: 'bk_returnedkey',
  823. name: posted.name,
  824. key_prefix: 'bk_returne',
  825. user_id: 1,
  826. can_queue: true,
  827. can_control_printer: false,
  828. can_read_status: true,
  829. can_access_cloud: posted.can_access_cloud ?? false,
  830. printer_ids: null,
  831. enabled: true,
  832. last_used: null,
  833. created_at: '2026-05-01T00:00:00Z',
  834. expires_at: null,
  835. });
  836. })
  837. );
  838. const user = userEvent.setup();
  839. render(<SettingsPage />);
  840. await waitFor(() => {
  841. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  842. });
  843. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  844. await user.click(tabButton!);
  845. // Open the create form. With an empty key list the empty-state card
  846. // shows "Create Your First Key" — click that to open the form.
  847. const openButton = await screen.findByRole('button', { name: /Create Your First Key/i });
  848. await user.click(openButton);
  849. // Tick the new "Allow cloud access" checkbox. The label wraps the
  850. // input AND a sibling description div, so getByLabelText doesn't
  851. // resolve via implicit-label traversal — locate via text + closest
  852. // label, then grab the checkbox from the same scope.
  853. const cloudLabelText = await screen.findByText(/Allow cloud access/i);
  854. const cloudLabel = cloudLabelText.closest('label');
  855. expect(cloudLabel).not.toBeNull();
  856. const cloudCheckbox = cloudLabel!.querySelector('input[type="checkbox"]') as HTMLInputElement;
  857. expect(cloudCheckbox).not.toBeNull();
  858. await user.click(cloudCheckbox);
  859. // Submit. Two "Create Key" buttons exist once the form is open (header
  860. // CTA + form footer); the form-footer one is the actual submit and
  861. // calls the mutation — find it by walking up from the cloud checkbox
  862. // we just clicked, since both share the same form container.
  863. const submitButtons = screen.getAllByRole('button', { name: /^Create Key$/i });
  864. // Footer submit is the one inside the same form section as the
  865. // checkbox. The header CTA is in a separate flex row.
  866. const formSubmit = submitButtons.find(
  867. (b) => b.closest('div')?.contains(cloudCheckbox) || cloudLabel?.parentElement?.parentElement?.contains(b),
  868. );
  869. await user.click(formSubmit ?? submitButtons[submitButtons.length - 1]);
  870. await waitFor(() => {
  871. expect(posted).not.toBeNull();
  872. expect(posted!.can_access_cloud).toBe(true);
  873. });
  874. });
  875. });
  876. describe('API Keys tab — #1356 energy-cost write scope', () => {
  877. /**
  878. * The narrowly-scoped settings-write toggle. We pin two contracts here:
  879. *
  880. * 1. The "Energy" badge renders for keys that have can_update_energy_cost=true.
  881. * Without a visible signal, an operator can't tell which key in their
  882. * list is the one their HA automation depends on.
  883. * 2. The create form sends can_update_energy_cost=true to the backend
  884. * when the toggle is checked. The whole point of #1356 is that the
  885. * flag must actually be persisted — a UI that drops it silently
  886. * would put us right back where the bug started.
  887. */
  888. it('renders the Energy badge for keys with can_update_energy_cost=true', async () => {
  889. const keys = [
  890. {
  891. id: 1,
  892. name: 'tariff-pusher',
  893. key_prefix: 'bk_tariff01',
  894. user_id: 7,
  895. can_queue: false,
  896. can_control_printer: false,
  897. can_read_status: true,
  898. can_access_cloud: false,
  899. can_update_energy_cost: true,
  900. printer_ids: null,
  901. enabled: true,
  902. last_used: null,
  903. created_at: '2026-05-15T00:00:00Z',
  904. expires_at: null,
  905. },
  906. ];
  907. server.use(http.get('/api/v1/api-keys/', () => HttpResponse.json(keys)));
  908. const user = userEvent.setup();
  909. render(<SettingsPage />);
  910. await waitFor(() => {
  911. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  912. });
  913. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  914. await user.click(tabButton!);
  915. await waitFor(() => {
  916. expect(screen.getByText('tariff-pusher')).toBeInTheDocument();
  917. });
  918. const row = screen.getByText('tariff-pusher').closest('.flex.items-center.justify-between');
  919. expect(row).not.toBeNull();
  920. expect(row!.textContent).toContain('Energy');
  921. });
  922. it('passes can_update_energy_cost through to the create call when the toggle is checked', async () => {
  923. let posted: { name?: string; can_update_energy_cost?: boolean } | null = null;
  924. server.use(
  925. http.get('/api/v1/api-keys/', () => HttpResponse.json([])),
  926. http.post('/api/v1/api-keys/', async ({ request }) => {
  927. posted = (await request.json()) as { name?: string; can_update_energy_cost?: boolean };
  928. return HttpResponse.json({
  929. id: 99,
  930. key: 'bk_returnedkey',
  931. name: posted.name,
  932. key_prefix: 'bk_returne',
  933. user_id: 1,
  934. can_queue: true,
  935. can_control_printer: false,
  936. can_read_status: true,
  937. can_access_cloud: false,
  938. can_update_energy_cost: posted.can_update_energy_cost ?? false,
  939. printer_ids: null,
  940. enabled: true,
  941. last_used: null,
  942. created_at: '2026-05-15T00:00:00Z',
  943. expires_at: null,
  944. });
  945. })
  946. );
  947. const user = userEvent.setup();
  948. render(<SettingsPage />);
  949. await waitFor(() => {
  950. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  951. });
  952. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  953. await user.click(tabButton!);
  954. const openButton = await screen.findByRole('button', { name: /Create Your First Key/i });
  955. await user.click(openButton);
  956. const energyLabelText = await screen.findByText(/Update electricity price/i);
  957. const energyLabel = energyLabelText.closest('label');
  958. expect(energyLabel).not.toBeNull();
  959. const energyCheckbox = energyLabel!.querySelector('input[type="checkbox"]') as HTMLInputElement;
  960. expect(energyCheckbox).not.toBeNull();
  961. await user.click(energyCheckbox);
  962. const submitButtons = screen.getAllByRole('button', { name: /^Create Key$/i });
  963. const formSubmit = submitButtons.find(
  964. (b) => b.closest('div')?.contains(energyCheckbox) || energyLabel?.parentElement?.parentElement?.contains(b),
  965. );
  966. await user.click(formSubmit ?? submitButtons[submitButtons.length - 1]);
  967. await waitFor(() => {
  968. expect(posted).not.toBeNull();
  969. expect(posted!.can_update_energy_cost).toBe(true);
  970. });
  971. });
  972. });
  973. describe('external camera snapshot URL override (#1177)', () => {
  974. /**
  975. * The snapshot URL input only appears for stream camera types where the
  976. * MJPEG warm-up problem can occur (mjpeg / rtsp / usb). Pure HTTP
  977. * snapshot sources don't need an override since their stream URL is
  978. * already a single-frame endpoint.
  979. */
  980. const mjpegPrinter = {
  981. id: 7,
  982. name: 'go2rtc Cam',
  983. serial_number: 'TEST123',
  984. ip_address: '192.168.1.100',
  985. access_code: 'XXXX',
  986. model: 'P1S',
  987. location: null,
  988. nozzle_count: 1,
  989. is_active: true,
  990. auto_archive: true,
  991. external_camera_url: 'http://192.168.1.61:1984/api/stream.mjpeg?src=printer',
  992. external_camera_type: 'mjpeg',
  993. external_camera_enabled: true,
  994. external_camera_snapshot_url: null,
  995. camera_rotation: 0,
  996. plate_detection_enabled: false,
  997. created_at: '2026-01-01T00:00:00Z',
  998. updated_at: '2026-01-01T00:00:00Z',
  999. };
  1000. it('renders the snapshot URL input when camera_type is mjpeg', async () => {
  1001. server.use(
  1002. http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
  1003. );
  1004. render(<SettingsPage />);
  1005. await waitFor(() => {
  1006. expect(screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/)).toBeInTheDocument();
  1007. });
  1008. });
  1009. it('hides the snapshot URL input when camera_type is snapshot (already a single-frame source)', async () => {
  1010. server.use(
  1011. http.get('/api/v1/printers/', () =>
  1012. HttpResponse.json([{ ...mjpegPrinter, external_camera_type: 'snapshot' }]),
  1013. ),
  1014. );
  1015. render(<SettingsPage />);
  1016. // Wait for the live-stream URL placeholder to render so we know the
  1017. // camera section finished mounting before asserting absence of the
  1018. // snapshot input below.
  1019. await waitFor(() => {
  1020. expect(screen.getByPlaceholderText(/Camera URL/i)).toBeInTheDocument();
  1021. });
  1022. expect(screen.queryByPlaceholderText(/api\/frame\.jpeg\?src=printer/)).not.toBeInTheDocument();
  1023. });
  1024. it(
  1025. 'PATCHes the printer with external_camera_snapshot_url when the user types into the input',
  1026. async () => {
  1027. let receivedBody: Record<string, unknown> | null = null;
  1028. server.use(
  1029. http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
  1030. http.patch('/api/v1/printers/7', async ({ request }) => {
  1031. receivedBody = (await request.json()) as Record<string, unknown>;
  1032. return HttpResponse.json({ ...mjpegPrinter, ...receivedBody });
  1033. }),
  1034. );
  1035. render(<SettingsPage />);
  1036. const input = await waitFor(() =>
  1037. screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/),
  1038. );
  1039. const user = userEvent.setup();
  1040. await user.type(input, 'http://192.168.1.61:1984/api/frame.jpeg?src=printer');
  1041. // Save is debounced by 800ms; assert the PATCH eventually fires with
  1042. // the typed snapshot URL.
  1043. await waitFor(
  1044. () => {
  1045. expect(receivedBody).not.toBeNull();
  1046. expect(receivedBody!.external_camera_snapshot_url).toBe(
  1047. 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
  1048. );
  1049. },
  1050. { timeout: 5000 },
  1051. );
  1052. },
  1053. // Per-test timeout raised to 15s — `user.type()` of a 49-char URL plus
  1054. // the 800ms save debounce fits in 5s locally (~2.3s typical) but blows
  1055. // past it on slow GitHub Actions runners (5000ms timeout was the failure
  1056. // mode on PR #1263).
  1057. 15_000,
  1058. );
  1059. });
  1060. describe('theme mode buttons', () => {
  1061. it('renders Dark, Light, and System buttons', async () => {
  1062. render(<SettingsPage />);
  1063. await waitFor(() => {
  1064. expect(screen.getByRole('button', { name: 'Dark' })).toBeInTheDocument();
  1065. expect(screen.getByRole('button', { name: 'Light' })).toBeInTheDocument();
  1066. expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
  1067. });
  1068. });
  1069. it('highlights the active mode button with green border', async () => {
  1070. render(<SettingsPage />);
  1071. const user = userEvent.setup();
  1072. await waitFor(() => {
  1073. expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
  1074. });
  1075. await user.click(screen.getByRole('button', { name: 'System' }));
  1076. await waitFor(() => {
  1077. const systemBtn = screen.getByRole('button', { name: 'System' });
  1078. expect(systemBtn.className).toContain('border-bambu-green');
  1079. });
  1080. });
  1081. it('clicking a theme button switches mode', async () => {
  1082. localStorage.setItem('theme-mode', 'dark');
  1083. render(<SettingsPage />);
  1084. const user = userEvent.setup();
  1085. await waitFor(() => {
  1086. const darkBtn = screen.getByRole('button', { name: 'Dark' });
  1087. expect(darkBtn.className).toContain('border-bambu-green');
  1088. });
  1089. const lightBtn = screen.getByRole('button', { name: 'Light' });
  1090. await user.click(lightBtn);
  1091. await waitFor(() => {
  1092. expect(lightBtn.className).toContain('border-bambu-green');
  1093. });
  1094. });
  1095. it('shows a toast when theme button is clicked', async () => {
  1096. render(<SettingsPage />);
  1097. const user = userEvent.setup();
  1098. await waitFor(() => {
  1099. expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
  1100. });
  1101. await user.click(screen.getByRole('button', { name: 'System' }));
  1102. await waitFor(() => {
  1103. expect(screen.getByText('Settings saved')).toBeInTheDocument();
  1104. });
  1105. });
  1106. });
  1107. // --------------------------------------------------------------------
  1108. // Slicer Pipelines (#1425) — Workflow tab sub-tabs
  1109. // --------------------------------------------------------------------
  1110. describe('workflow sub-tabs (#1425)', () => {
  1111. beforeEach(() => {
  1112. // Endpoints the Pipelines panel calls (#1425).
  1113. server.use(
  1114. http.get('/api/v1/slicer-pipelines/', () => HttpResponse.json({ pipelines: [] })),
  1115. http.get('/api/v1/slicer/presets', () =>
  1116. HttpResponse.json({
  1117. orca_cloud: { printer: [], process: [], filament: [] },
  1118. cloud: { printer: [], process: [], filament: [] },
  1119. local: { printer: [], process: [], filament: [] },
  1120. standard: { printer: [], process: [], filament: [] },
  1121. cloud_status: 'ok',
  1122. orca_cloud_status: 'ok',
  1123. }),
  1124. ),
  1125. );
  1126. });
  1127. it('renders Queue & Dispatch + Pipelines sub-tabs under Workflow', async () => {
  1128. render(<SettingsPage />);
  1129. const user = userEvent.setup();
  1130. await waitFor(() => {
  1131. // Workflow tab in the sidebar — exact match to avoid colliding with
  1132. // "Print Queue" or "Queue Settings" labels elsewhere on the page.
  1133. expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument();
  1134. });
  1135. await user.click(screen.getByRole('button', { name: 'Workflow' }));
  1136. await waitFor(() => {
  1137. expect(screen.getByRole('button', { name: /Queue & Dispatch/i })).toBeInTheDocument();
  1138. expect(screen.getByRole('button', { name: /^Pipelines$/i })).toBeInTheDocument();
  1139. });
  1140. });
  1141. it('clicking Pipelines sub-tab shows the empty-state hint and updates the URL', async () => {
  1142. render(<SettingsPage />);
  1143. const user = userEvent.setup();
  1144. await waitFor(() => expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument());
  1145. await user.click(screen.getByRole('button', { name: 'Workflow' }));
  1146. await user.click(screen.getByRole('button', { name: /^Pipelines$/i }));
  1147. await waitFor(() => {
  1148. expect(screen.getByText(/No pipelines yet/i)).toBeInTheDocument();
  1149. // Deep-link URL carries both ?tab=queue and ?sub=pipelines
  1150. expect(window.location.search).toContain('tab=queue');
  1151. expect(window.location.search).toContain('sub=pipelines');
  1152. });
  1153. });
  1154. });
  1155. });