SettingsPage.test.tsx 52 KB

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