SettingsPage.test.tsx 75 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041
  1. /**
  2. * Tests for the SettingsPage component.
  3. */
  4. import { describe, it, expect, beforeEach, vi } from 'vitest';
  5. import { act, fireEvent, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
  6. import userEvent from '@testing-library/user-event';
  7. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  8. import { BrowserRouter } from 'react-router-dom';
  9. import { render } from '../utils';
  10. import { ThemeProvider } from '../../contexts/ThemeContext';
  11. import { ToastProvider } from '../../contexts/ToastContext';
  12. import { AuthProvider } from '../../contexts/AuthContext';
  13. import { SettingsPage } from '../../pages/SettingsPage';
  14. import { http, HttpResponse } from 'msw';
  15. import { server } from '../mocks/server';
  16. import { SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, SIDEBAR_ORDER_KEY } from '../../utils/sidebarLayout';
  17. import { setAuthToken } from '../../api/client';
  18. const mockSettings = {
  19. auto_archive: true,
  20. save_thumbnails: true,
  21. capture_finish_photo: true,
  22. default_filament_cost: 25.0,
  23. currency: 'USD',
  24. ams_humidity_good: 40,
  25. ams_humidity_fair: 60,
  26. ams_temp_good: 30,
  27. ams_temp_fair: 35,
  28. ams_temp_alarm: null,
  29. time_format: 'system',
  30. date_format: 'system',
  31. mqtt_enabled: false,
  32. mqtt_host: '',
  33. mqtt_port: 1883,
  34. spoolman_enabled: false,
  35. spoolman_url: '',
  36. ha_enabled: false,
  37. ha_url: '',
  38. ha_token: '',
  39. check_updates: false,
  40. check_printer_firmware: false,
  41. bed_cooled_threshold: 35,
  42. };
  43. describe('SettingsPage', () => {
  44. beforeEach(() => {
  45. // BrowserRouter shares window.location across tests; reset it so a tab
  46. // switch in one test (e.g. clicking "Workflow") doesn't carry into
  47. // sibling tests that expect to land on the default General tab.
  48. window.history.replaceState({}, '', '/');
  49. vi.mocked(localStorage.getItem).mockReset();
  50. vi.mocked(localStorage.setItem).mockReset();
  51. vi.mocked(localStorage.removeItem).mockReset();
  52. vi.mocked(localStorage.clear).mockReset();
  53. localStorage.clear();
  54. setAuthToken(null);
  55. server.use(
  56. http.get('/api/v1/settings/', () => {
  57. return HttpResponse.json(mockSettings);
  58. }),
  59. http.put('/api/v1/settings/', async ({ request }) => {
  60. const body = await request.json();
  61. return HttpResponse.json({ ...mockSettings, ...body });
  62. }),
  63. http.get('/api/v1/printers/', () => {
  64. return HttpResponse.json([]);
  65. }),
  66. http.get('/api/v1/smart-plugs/', () => {
  67. return HttpResponse.json([]);
  68. }),
  69. http.get('/api/v1/notifications/', () => {
  70. return HttpResponse.json([]);
  71. }),
  72. http.get('/api/v1/api-keys/', () => {
  73. return HttpResponse.json([]);
  74. }),
  75. http.get('/api/v1/mqtt/status', () => {
  76. return HttpResponse.json({ enabled: false });
  77. }),
  78. http.get('/api/v1/virtual-printer/status', () => {
  79. return HttpResponse.json({ running: false });
  80. }),
  81. http.get('/api/v1/auth/status', () => {
  82. return HttpResponse.json({ auth_enabled: false, requires_setup: false });
  83. }),
  84. http.get('/api/v1/external-links/', () => {
  85. return HttpResponse.json([]);
  86. })
  87. );
  88. });
  89. describe('rendering', () => {
  90. it('renders the page title', async () => {
  91. render(<SettingsPage />);
  92. await waitFor(() => {
  93. // Use role-based query to avoid conflicts with dropdown options
  94. expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument();
  95. });
  96. });
  97. it('shows settings tabs', async () => {
  98. render(<SettingsPage />);
  99. await waitFor(() => {
  100. // Use getAllByText since "General" appears both as tab and section heading
  101. expect(screen.getAllByText('General').length).toBeGreaterThan(0);
  102. expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
  103. expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
  104. expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
  105. expect(screen.getByText('Network')).toBeInTheDocument();
  106. expect(screen.getByText('API Keys')).toBeInTheDocument();
  107. });
  108. });
  109. });
  110. describe('finish photo plate restore (#2547)', () => {
  111. const restoreLabel = 'Restore plate for finish photo';
  112. it('offers the toggle while finish photos are enabled', async () => {
  113. render(<SettingsPage />);
  114. expect(await screen.findByText(restoreLabel)).toBeInTheDocument();
  115. });
  116. it('hides the toggle when finish photos are switched off', async () => {
  117. // It only describes how the finish photo is framed, so it is meaningless
  118. // when no finish photo is taken at all.
  119. server.use(
  120. http.get('/api/v1/settings/', () =>
  121. HttpResponse.json({ ...mockSettings, capture_finish_photo: false })
  122. )
  123. );
  124. render(<SettingsPage />);
  125. await screen.findByRole('heading', { name: 'Settings' });
  126. await waitFor(() => {
  127. expect(screen.queryByText(restoreLabel)).not.toBeInTheDocument();
  128. });
  129. });
  130. it('defaults to on when the backend has never stored the setting', async () => {
  131. // Existing installs have no row for it; the UI must not read that as off.
  132. render(<SettingsPage />);
  133. const label = await screen.findByText(restoreLabel);
  134. const row = label.closest('div')!.parentElement!;
  135. expect(within(row).getByRole('checkbox')).toBeChecked();
  136. });
  137. it('sends the new value on save', async () => {
  138. let saved: Record<string, unknown> | null = null;
  139. server.use(
  140. http.put('/api/v1/settings/', async ({ request }) => {
  141. saved = (await request.json()) as Record<string, unknown>;
  142. return HttpResponse.json({ ...mockSettings, ...saved });
  143. })
  144. );
  145. render(<SettingsPage />);
  146. const label = await screen.findByText(restoreLabel);
  147. // The page suppresses auto-save for 100ms after the settings load, so a
  148. // click landing inside that window is swallowed with no re-trigger.
  149. await new Promise((resolve) => setTimeout(resolve, 200));
  150. const row = label.closest('div')!.parentElement!;
  151. await userEvent.click(within(row).getByRole('checkbox'));
  152. // The page auto-saves on a 500ms debounce, so the default 1s waitFor
  153. // window is only just wide enough — give the request room to land.
  154. await waitFor(() => {
  155. expect(saved).not.toBeNull();
  156. }, { timeout: 3000 });
  157. expect(saved!.finish_photo_restore_plate).toBe(false);
  158. });
  159. });
  160. describe('general settings', () => {
  161. it('shows date format setting', async () => {
  162. render(<SettingsPage />);
  163. await waitFor(() => {
  164. expect(screen.getByText('Date Format')).toBeInTheDocument();
  165. });
  166. });
  167. it('shows time format setting', async () => {
  168. render(<SettingsPage />);
  169. await waitFor(() => {
  170. expect(screen.getByText('Time Format')).toBeInTheDocument();
  171. });
  172. });
  173. it('no longer offers Camera View Mode, which moved to the printer card', async () => {
  174. // The camera button on each printer card is a split control now, so the
  175. // choice is made per stream rather than once for the whole install. The
  176. // External Cameras section is still here, which is what keeps this from
  177. // passing merely because the Camera card failed to render.
  178. render(<SettingsPage />);
  179. await waitFor(() => {
  180. expect(screen.getByText('External Cameras')).toBeInTheDocument();
  181. });
  182. expect(screen.queryByText('Camera View Mode')).toBeNull();
  183. });
  184. it('shows default printer setting', async () => {
  185. render(<SettingsPage />);
  186. await waitFor(() => {
  187. expect(screen.getByText('Default Printer')).toBeInTheDocument();
  188. });
  189. });
  190. it('shows preferred slicer setting on Workflow tab', async () => {
  191. const user = userEvent.setup();
  192. render(<SettingsPage />);
  193. await waitFor(() => {
  194. expect(screen.getByText('Workflow')).toBeInTheDocument();
  195. });
  196. await user.click(screen.getByText('Workflow'));
  197. await waitFor(() => {
  198. expect(screen.getByText('Preferred Slicer')).toBeInTheDocument();
  199. });
  200. });
  201. it('shows slicer dropdown with both options on Workflow tab', async () => {
  202. const user = userEvent.setup();
  203. render(<SettingsPage />);
  204. await waitFor(() => {
  205. expect(screen.getByText('Workflow')).toBeInTheDocument();
  206. });
  207. await user.click(screen.getByText('Workflow'));
  208. await waitFor(() => {
  209. const slicerSelect = screen.getAllByDisplayValue('Bambu Studio');
  210. expect(slicerSelect.length).toBeGreaterThan(0);
  211. });
  212. });
  213. it('shows appearance section', async () => {
  214. render(<SettingsPage />);
  215. await waitFor(() => {
  216. expect(screen.getByText('Appearance')).toBeInTheDocument();
  217. });
  218. });
  219. it('shows updates section with firmware toggle', async () => {
  220. render(<SettingsPage />);
  221. await waitFor(() => {
  222. expect(screen.getByText('Updates')).toBeInTheDocument();
  223. expect(screen.getByText('Check for updates')).toBeInTheDocument();
  224. expect(screen.getByText('Check printer firmware')).toBeInTheDocument();
  225. });
  226. });
  227. it('hides a Bambuddy sidebar page from Sidebar', async () => {
  228. const user = userEvent.setup();
  229. render(<SettingsPage />);
  230. await screen.findByRole('heading', { name: 'Sidebar' });
  231. await screen.findAllByText('Visible in sidebar');
  232. vi.mocked(localStorage.setItem).mockClear();
  233. await user.click((await screen.findAllByLabelText('Hide page'))[0]);
  234. expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify(['printers']));
  235. expect(screen.getByText('Hidden from sidebar')).toBeInTheDocument();
  236. });
  237. it('shows a previously hidden Bambuddy sidebar page from Sidebar', async () => {
  238. vi.mocked(localStorage.getItem).mockImplementation((key) => {
  239. if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers']);
  240. return null;
  241. });
  242. const user = userEvent.setup();
  243. render(<SettingsPage />);
  244. await screen.findByRole('heading', { name: 'Sidebar' });
  245. await screen.findByText('Hidden from sidebar');
  246. vi.mocked(localStorage.setItem).mockClear();
  247. await user.click(await screen.findByLabelText('Show page'));
  248. expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
  249. expect(screen.getAllByText('Visible in sidebar').length).toBeGreaterThan(0);
  250. });
  251. it('does not allow Settings to be hidden from Sidebar', async () => {
  252. render(<SettingsPage />);
  253. await screen.findByRole('heading', { name: 'Sidebar' });
  254. await screen.findByText('Required in sidebar');
  255. const settingsVisibilityButton = await screen.findByLabelText('Settings cannot be hidden');
  256. expect(settingsVisibilityButton).toBeDisabled();
  257. expect(screen.getByText('Required in sidebar')).toBeInTheDocument();
  258. });
  259. it('presents external links and Bambuddy pages in saved sidebar order', async () => {
  260. vi.mocked(localStorage.getItem).mockImplementation((key) => {
  261. if (key === SIDEBAR_ORDER_KEY) return JSON.stringify(['ext-7', 'printers', 'settings']);
  262. return null;
  263. });
  264. server.use(
  265. http.get('/api/v1/external-links/', () =>
  266. HttpResponse.json([
  267. {
  268. id: 7,
  269. name: 'Docs',
  270. url: 'https://docs.example.test',
  271. icon: 'Link',
  272. open_in_new_tab: true,
  273. custom_icon: null,
  274. sort_order: 0,
  275. created_at: '2026-01-01T00:00:00Z',
  276. updated_at: '2026-01-01T00:00:00Z',
  277. },
  278. ]),
  279. ),
  280. );
  281. render(<SettingsPage />);
  282. await screen.findByRole('heading', { name: 'Sidebar' });
  283. const docs = await screen.findByText('Docs');
  284. const printers = screen.getAllByText('Printers').find(element => element.closest('[draggable="true"]'));
  285. expect(printers).toBeDefined();
  286. expect(docs.compareDocumentPosition(printers) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
  287. });
  288. it('saves mixed Sidebar order when items are dragged', async () => {
  289. server.use(
  290. http.get('/api/v1/external-links/', () =>
  291. HttpResponse.json([
  292. {
  293. id: 7,
  294. name: 'Docs',
  295. url: 'https://docs.example.test',
  296. icon: 'Link',
  297. open_in_new_tab: true,
  298. custom_icon: null,
  299. sort_order: 0,
  300. created_at: '2026-01-01T00:00:00Z',
  301. updated_at: '2026-01-01T00:00:00Z',
  302. },
  303. ]),
  304. ),
  305. );
  306. render(<SettingsPage />);
  307. await screen.findByRole('heading', { name: 'Sidebar' });
  308. const docsRow = (await screen.findByText('Docs')).closest('[draggable="true"]');
  309. const printersRow = screen.getAllByText('Printers')
  310. .find(element => element.closest('[draggable="true"]'))
  311. ?.closest('[draggable="true"]');
  312. expect(docsRow).not.toBeNull();
  313. expect(printersRow).not.toBeNull();
  314. vi.mocked(localStorage.setItem).mockClear();
  315. const dataTransfer = {
  316. effectAllowed: '',
  317. dropEffect: '',
  318. setData: vi.fn(),
  319. };
  320. fireEvent.dragStart(docsRow!, { dataTransfer });
  321. fireEvent.dragOver(printersRow!, { dataTransfer });
  322. fireEvent.drop(printersRow!, { dataTransfer });
  323. expect(localStorage.setItem).toHaveBeenCalledWith(
  324. SIDEBAR_ORDER_KEY,
  325. JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'finance', 'notifications', 'settings']),
  326. );
  327. });
  328. it('resets Sidebar to all pages first and configured links at the bottom', async () => {
  329. vi.mocked(localStorage.getItem).mockImplementation((key) => {
  330. if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers', 'stats']);
  331. if (key === SIDEBAR_ORDER_KEY) return JSON.stringify(['ext-7', 'settings', 'printers']);
  332. return null;
  333. });
  334. server.use(
  335. http.get('/api/v1/external-links/', () =>
  336. HttpResponse.json([
  337. {
  338. id: 7,
  339. name: 'Docs',
  340. url: 'https://docs.example.test',
  341. icon: 'Link',
  342. open_in_new_tab: true,
  343. custom_icon: null,
  344. sort_order: 0,
  345. created_at: '2026-01-01T00:00:00Z',
  346. updated_at: '2026-01-01T00:00:00Z',
  347. },
  348. ]),
  349. ),
  350. );
  351. const user = userEvent.setup();
  352. render(<SettingsPage />);
  353. const heading = await screen.findByRole('heading', { name: 'Sidebar' });
  354. const card = heading.closest('#card-sidebar-links');
  355. expect(card).not.toBeNull();
  356. await screen.findByText('Docs');
  357. vi.mocked(localStorage.setItem).mockClear();
  358. await user.click(within(card as HTMLElement).getByRole('button', { name: /reset/i }));
  359. expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
  360. expect(localStorage.setItem).toHaveBeenCalledWith(
  361. SIDEBAR_ORDER_KEY,
  362. JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'finance', 'notifications', 'settings', 'ext-7']),
  363. );
  364. const settingsRow = screen.getAllByText('Settings')
  365. .find(element => element.closest('[draggable="true"]'))
  366. ?.closest('[draggable="true"]');
  367. const docsRow = screen.getByText('Docs').closest('[draggable="true"]');
  368. expect(settingsRow).not.toBeNull();
  369. expect(docsRow).not.toBeNull();
  370. expect(settingsRow!.compareDocumentPosition(docsRow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
  371. expect(screen.queryByText('Hidden from sidebar')).not.toBeInTheDocument();
  372. });
  373. it('sets the current Sidebar order as the backend default for settings admins', async () => {
  374. let defaultSidebarOrderPayload: string | null = null;
  375. vi.mocked(localStorage.getItem).mockImplementation((key) => {
  376. if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['stats']);
  377. return null;
  378. });
  379. server.use(
  380. http.get('/api/v1/auth/status', () =>
  381. HttpResponse.json({ auth_enabled: true, requires_setup: false }),
  382. ),
  383. http.get('/api/v1/auth/me', () =>
  384. HttpResponse.json({
  385. id: 1,
  386. username: 'admin',
  387. role: 'admin',
  388. is_active: true,
  389. is_admin: false,
  390. groups: [{ id: 1, name: 'Administrators' }],
  391. permissions: ['settings:update'],
  392. created_at: '2026-01-01T00:00:00Z',
  393. }),
  394. ),
  395. http.get('/api/v1/settings/', () =>
  396. HttpResponse.json({ ...mockSettings, default_sidebar_order: '' }),
  397. ),
  398. http.put('/api/v1/settings/', async ({ request }) => {
  399. const body = await request.json() as { default_sidebar_order?: string };
  400. defaultSidebarOrderPayload = body.default_sidebar_order ?? null;
  401. return HttpResponse.json({ ...mockSettings, ...body });
  402. }),
  403. );
  404. setAuthToken('test-token');
  405. const user = userEvent.setup();
  406. render(<SettingsPage />);
  407. const heading = await screen.findByRole('heading', { name: 'Sidebar' });
  408. const card = heading.closest('#card-sidebar-links');
  409. expect(card).not.toBeNull();
  410. await user.click(within(card as HTMLElement).getByRole('switch', { name: 'Set Default' }));
  411. await waitFor(() => {
  412. expect(defaultSidebarOrderPayload).not.toBeNull();
  413. });
  414. expect(JSON.parse(defaultSidebarOrderPayload!)).toEqual({
  415. order: [
  416. 'printers',
  417. 'inventory',
  418. 'archives',
  419. 'queue',
  420. 'projects',
  421. 'files',
  422. 'makerworld',
  423. 'profiles',
  424. 'maintenance',
  425. 'stats',
  426. 'finance',
  427. 'notifications',
  428. 'settings',
  429. ],
  430. hiddenSystemItemIds: ['stats'],
  431. });
  432. });
  433. });
  434. describe('update CTA per deployment shape', () => {
  435. // The update card branches on the deployment shape returned by
  436. // /updates/check. Each branch is mutually exclusive — verify the right
  437. // one wins so HA addon users never see the docker-compose snippet
  438. // (which they can't run from inside an HA addon container) and Docker
  439. // users never see the in-app Install button (which would no-op).
  440. const renderWithUpdateCheck = async (
  441. checkBody: Record<string, unknown>,
  442. settingsOverrides: Record<string, unknown> = {},
  443. ) => {
  444. server.use(
  445. http.get('/api/v1/settings/', () =>
  446. HttpResponse.json({ ...mockSettings, check_updates: true, ...settingsOverrides }),
  447. ),
  448. http.get('/api/v1/updates/check', () => HttpResponse.json(checkBody)),
  449. );
  450. render(<SettingsPage />);
  451. await waitFor(() => {
  452. expect(screen.getByText('Updates')).toBeInTheDocument();
  453. });
  454. };
  455. it('shows the HA Supervisor message when running as an HA addon', async () => {
  456. await renderWithUpdateCheck({
  457. update_available: true,
  458. current_version: '0.2.4',
  459. latest_version: '0.2.5',
  460. release_name: '0.2.5',
  461. release_notes: '',
  462. release_url: 'https://example.invalid/r',
  463. published_at: '2099-01-01T00:00:00Z',
  464. is_docker: true,
  465. is_ha_addon: true,
  466. update_method: 'ha_addon',
  467. });
  468. await waitFor(() => {
  469. expect(
  470. screen.getByText(/Home Assistant Supervisor/i),
  471. ).toBeInTheDocument();
  472. });
  473. // Docker hint must NOT render — HA branch wins.
  474. expect(screen.queryByText('docker compose pull && docker compose up -d')).not.toBeInTheDocument();
  475. expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
  476. });
  477. it('shows the docker-compose snippet for Docker (non-HA) deployments', async () => {
  478. await renderWithUpdateCheck({
  479. update_available: true,
  480. current_version: '0.2.4',
  481. latest_version: '0.2.5',
  482. release_name: '0.2.5',
  483. release_notes: '',
  484. release_url: 'https://example.invalid/r',
  485. published_at: '2099-01-01T00:00:00Z',
  486. is_docker: true,
  487. is_ha_addon: false,
  488. update_method: 'docker',
  489. });
  490. await waitFor(() => {
  491. expect(screen.getByText('docker compose pull && docker compose up -d')).toBeInTheDocument();
  492. });
  493. expect(screen.queryByText(/Home Assistant Supervisor/i)).not.toBeInTheDocument();
  494. expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
  495. });
  496. // #2664: the bare command only works if the user is already standing in
  497. // the directory holding their compose file, which is exactly the thing
  498. // they came to the page not knowing.
  499. const DOCKER_CHECK = {
  500. update_available: true,
  501. current_version: '0.2.4',
  502. latest_version: '0.2.5',
  503. release_name: '0.2.5',
  504. release_notes: '',
  505. release_url: 'https://example.invalid/r',
  506. published_at: '2099-01-01T00:00:00Z',
  507. is_docker: true,
  508. is_ha_addon: false,
  509. update_method: 'docker',
  510. };
  511. it('prefixes the command with cd when the backend detected a compose directory', async () => {
  512. await renderWithUpdateCheck({ ...DOCKER_CHECK, compose_dir_detected: '/opt/bambuddy' });
  513. await waitFor(() => {
  514. expect(
  515. screen.getByText('cd /opt/bambuddy && docker compose pull && docker compose up -d'),
  516. ).toBeInTheDocument();
  517. });
  518. });
  519. it('prefers the saved directory over the detected one', async () => {
  520. await renderWithUpdateCheck(
  521. { ...DOCKER_CHECK, compose_dir_detected: '/opt/guessed' },
  522. { docker_compose_dir: '/srv/stacks/bambuddy' },
  523. );
  524. await waitFor(() => {
  525. expect(
  526. screen.getByText('cd /srv/stacks/bambuddy && docker compose pull && docker compose up -d'),
  527. ).toBeInTheDocument();
  528. });
  529. expect(screen.queryByText(/\/opt\/guessed/)).not.toBeInTheDocument();
  530. });
  531. it('quotes a directory containing a space so the cd does not split', async () => {
  532. await renderWithUpdateCheck(DOCKER_CHECK, { docker_compose_dir: '/srv/bambu buddy' });
  533. await waitFor(() => {
  534. expect(
  535. screen.getByText('cd "/srv/bambu buddy" && docker compose pull && docker compose up -d'),
  536. ).toBeInTheDocument();
  537. });
  538. });
  539. it('copies the full command including the cd', async () => {
  540. const writeText = vi.fn().mockResolvedValue(undefined);
  541. Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
  542. Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true });
  543. await renderWithUpdateCheck({ ...DOCKER_CHECK, compose_dir_detected: '/opt/bambuddy' });
  544. const copy = await screen.findByRole('button', { name: /copy update command/i });
  545. await userEvent.click(copy);
  546. expect(writeText).toHaveBeenCalledWith(
  547. 'cd /opt/bambuddy && docker compose pull && docker compose up -d',
  548. );
  549. });
  550. it('offers an editable compose directory field seeded with the detected path', async () => {
  551. await renderWithUpdateCheck({ ...DOCKER_CHECK, compose_dir_detected: '/opt/bambuddy' });
  552. const field = await screen.findByPlaceholderText('/opt/bambuddy');
  553. // Placeholder, not value — the detected path is a guess the user has
  554. // not accepted, so saving must not silently adopt it.
  555. expect(field).toHaveValue('');
  556. });
  557. it('shows the installer-download link for Windows installer installs', async () => {
  558. const downloadUrl =
  559. 'https://github.com/maziggy/bambuddy/releases/download/v0.2.5/bambuddy-0.2.5-windows-x64-setup.exe';
  560. await renderWithUpdateCheck({
  561. update_available: true,
  562. current_version: '0.2.4',
  563. latest_version: '0.2.5',
  564. release_name: '0.2.5',
  565. release_notes: '',
  566. release_url: 'https://github.com/maziggy/bambuddy/releases/tag/v0.2.5',
  567. published_at: '2099-01-01T00:00:00Z',
  568. is_docker: false,
  569. is_ha_addon: false,
  570. is_windows_installer: true,
  571. update_method: 'windows_installer',
  572. installer_download_url: downloadUrl,
  573. });
  574. const link = await screen.findByRole('link', { name: /download installer for v0\.2\.5/i });
  575. expect(link).toHaveAttribute('href', downloadUrl);
  576. expect(link).toHaveAttribute('target', '_blank');
  577. expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
  578. // The in-app update button must NOT render — the git-fetch path can't
  579. // work from an installer payload.
  580. expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
  581. expect(screen.queryByText(/Home Assistant Supervisor/i)).not.toBeInTheDocument();
  582. expect(screen.queryByText('docker compose pull && docker compose up -d')).not.toBeInTheDocument();
  583. });
  584. });
  585. describe('tabs navigation', () => {
  586. it('can switch to Network tab', async () => {
  587. const user = userEvent.setup();
  588. render(<SettingsPage />);
  589. // Wait for settings to load first
  590. await waitFor(() => {
  591. expect(screen.getByText('Date Format')).toBeInTheDocument();
  592. });
  593. await user.click(screen.getByText('Network'));
  594. await waitFor(() => {
  595. // Network tab contains MQTT Publishing section
  596. expect(screen.getByText('MQTT Publishing')).toBeInTheDocument();
  597. });
  598. });
  599. it('can switch to Smart Plugs tab', async () => {
  600. const user = userEvent.setup();
  601. render(<SettingsPage />);
  602. await waitFor(() => {
  603. expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
  604. });
  605. await user.click(screen.getByText('Smart Plugs'));
  606. await waitFor(() => {
  607. expect(screen.getByText('Add Smart Plug')).toBeInTheDocument();
  608. });
  609. });
  610. it('can switch to Notifications tab', async () => {
  611. const user = userEvent.setup();
  612. render(<SettingsPage />);
  613. await waitFor(() => {
  614. expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
  615. });
  616. // Click the tab button (not the mobile dropdown option)
  617. const notificationButtons = screen.getAllByText('Notifications');
  618. const tabButton = notificationButtons.find(el => el.tagName === 'BUTTON') || notificationButtons[0];
  619. await user.click(tabButton);
  620. await waitFor(() => {
  621. expect(screen.getByText('Add Provider')).toBeInTheDocument();
  622. });
  623. });
  624. it('can switch to Filament tab', async () => {
  625. const user = userEvent.setup();
  626. render(<SettingsPage />);
  627. await waitFor(() => {
  628. expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
  629. });
  630. await user.click(screen.getAllByText('Filament')[0]);
  631. await waitFor(() => {
  632. expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();
  633. });
  634. });
  635. // #2770: an AMS reads 15-20% while its own heater runs, so a drying
  636. // threshold set below that can never be met and auto-drying ends one
  637. // cycle only to arm the next. The default of 60 must stay quiet.
  638. const openFilamentTab = async (humidityFair: number) => {
  639. server.use(
  640. http.get('/api/v1/settings/', () =>
  641. HttpResponse.json({ ...mockSettings, ams_humidity_fair: humidityFair })
  642. )
  643. );
  644. const user = userEvent.setup();
  645. render(<SettingsPage />);
  646. await waitFor(() => {
  647. expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
  648. });
  649. await user.click(screen.getAllByText('Filament')[0]);
  650. await waitFor(() => {
  651. expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();
  652. });
  653. };
  654. it('warns when the humidity threshold is below what a drying AMS reports', async () => {
  655. await openFilamentTab(14);
  656. expect(await screen.findByText(/Auto-drying cannot reach this value/)).toBeInTheDocument();
  657. });
  658. it('stays quiet for a humidity threshold auto-drying can actually reach', async () => {
  659. await openFilamentTab(60);
  660. expect(screen.queryByText(/Auto-drying cannot reach this value/)).not.toBeInTheDocument();
  661. });
  662. // #2905: the alarm threshold is a separate value from the Fair display
  663. // band, and unset means "use Fair" rather than "never alarm". Everything
  664. // below turns on telling those two apart.
  665. const openFilamentTabWith = async (overrides: Record<string, unknown>) => {
  666. server.use(
  667. http.get('/api/v1/settings/', () => HttpResponse.json({ ...mockSettings, ...overrides }))
  668. );
  669. const user = userEvent.setup();
  670. render(<SettingsPage />);
  671. await waitFor(() => {
  672. expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
  673. });
  674. await user.click(screen.getAllByText('Filament')[0]);
  675. await waitFor(() => {
  676. expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();
  677. });
  678. };
  679. const alarmInput = () =>
  680. within(screen.getByText('Alarm above').parentElement!).getByRole('spinbutton');
  681. it('shows the fair threshold as the placeholder while the alarm threshold is unset', async () => {
  682. // The fallback has to be visible in the field itself. Blank with no hint
  683. // reads as "no alarm", which is the opposite of what unset does.
  684. await openFilamentTabWith({ ams_temp_fair: 38, ams_temp_alarm: null });
  685. const input = alarmInput();
  686. expect(input).toHaveValue(null);
  687. expect(input).toHaveAttribute('placeholder', '38');
  688. });
  689. it('sends the typed alarm threshold on save', async () => {
  690. let saved: Record<string, unknown> | null = null;
  691. server.use(
  692. http.put('/api/v1/settings/', async ({ request }) => {
  693. saved = (await request.json()) as Record<string, unknown>;
  694. return HttpResponse.json({ ...mockSettings, ...saved });
  695. })
  696. );
  697. await openFilamentTabWith({ ams_temp_alarm: null });
  698. // The page suppresses auto-save for 100ms after the settings load.
  699. await new Promise((resolve) => setTimeout(resolve, 200));
  700. await userEvent.type(alarmInput(), '45');
  701. // Assert on the value rather than merely on a save having happened: two
  702. // keystrokes can straddle the 500ms debounce on a slow runner, and the
  703. // first save would then carry 4. Waiting for 45 rides that out.
  704. await waitFor(() => {
  705. expect(saved?.ams_temp_alarm).toBe(45);
  706. }, { timeout: 3000 });
  707. });
  708. it('sends null when the alarm threshold is cleared', async () => {
  709. // The one path the backend tests cannot reach on their own: clearing has
  710. // to send an explicit null, not omit the key, or the old threshold stays.
  711. let saved: Record<string, unknown> | null = null;
  712. server.use(
  713. http.put('/api/v1/settings/', async ({ request }) => {
  714. saved = (await request.json()) as Record<string, unknown>;
  715. return HttpResponse.json({ ...mockSettings, ...saved });
  716. })
  717. );
  718. await openFilamentTabWith({ ams_temp_alarm: 45 });
  719. await new Promise((resolve) => setTimeout(resolve, 200));
  720. await userEvent.clear(alarmInput());
  721. await waitFor(() => {
  722. expect(saved).not.toBeNull();
  723. expect(saved!.ams_temp_alarm).toBeNull();
  724. }, { timeout: 3000 });
  725. });
  726. it('warns that a non-positive alarm threshold is ignored', async () => {
  727. // The backend refuses <= 0 and falls back to Fair. Saying so beats a min=
  728. // attribute the browser only enforces on submit.
  729. await openFilamentTabWith({ ams_temp_alarm: 0 });
  730. expect(await screen.findByText(/A threshold of 0 or less is ignored/)).toBeInTheDocument();
  731. });
  732. it('stays quiet for an alarm threshold the backend will honour', async () => {
  733. await openFilamentTabWith({ ams_temp_alarm: 45 });
  734. expect(screen.queryByText(/A threshold of 0 or less is ignored/)).not.toBeInTheDocument();
  735. });
  736. });
  737. describe('Workflow tab', () => {
  738. it('can switch to Workflow tab', async () => {
  739. const user = userEvent.setup();
  740. render(<SettingsPage />);
  741. await waitFor(() => {
  742. expect(screen.getByText('Workflow')).toBeInTheDocument();
  743. });
  744. await user.click(screen.getByText('Workflow'));
  745. await waitFor(() => {
  746. expect(screen.getByText('Staggered Start')).toBeInTheDocument();
  747. });
  748. });
  749. it('shows stagger settings on Workflow tab', async () => {
  750. const user = userEvent.setup();
  751. render(<SettingsPage />);
  752. await waitFor(() => {
  753. expect(screen.getByText('Workflow')).toBeInTheDocument();
  754. });
  755. await user.click(screen.getByText('Workflow'));
  756. await waitFor(() => {
  757. expect(screen.getByText('Staggered Start')).toBeInTheDocument();
  758. expect(screen.getByText('Group size')).toBeInTheDocument();
  759. expect(screen.getByText('Interval (minutes)')).toBeInTheDocument();
  760. });
  761. });
  762. it('shows auto-drying settings on Workflow tab', async () => {
  763. const user = userEvent.setup();
  764. render(<SettingsPage />);
  765. await waitFor(() => {
  766. expect(screen.getByText('Workflow')).toBeInTheDocument();
  767. });
  768. await user.click(screen.getByText('Workflow'));
  769. await waitFor(() => {
  770. expect(screen.getByText('Queue Auto-Drying')).toBeInTheDocument();
  771. });
  772. });
  773. it('shows per-filament humidity threshold editor on Workflow tab (#1605)', async () => {
  774. const user = userEvent.setup();
  775. render(<SettingsPage />);
  776. await waitFor(() => {
  777. expect(screen.getByText('Workflow')).toBeInTheDocument();
  778. });
  779. await user.click(screen.getByText('Workflow'));
  780. await waitFor(() => {
  781. expect(screen.getByText('Humidity Thresholds')).toBeInTheDocument();
  782. // Default row is unique to the humidity editor (drying presets has no
  783. // default row), so we can pin it without disambiguating from the
  784. // adjacent drying-presets table that also lists PLA/ASA/etc.
  785. expect(screen.getByText('Default (unknown types)')).toBeInTheDocument();
  786. // Filament rows render in both tables — assert by count instead of
  787. // a single getByText. 8 default filaments × 2 tables = 16 PLAs etc.
  788. expect(screen.getAllByText('PLA').length).toBeGreaterThanOrEqual(2);
  789. expect(screen.getAllByText('ASA').length).toBeGreaterThanOrEqual(2);
  790. });
  791. });
  792. it('shows default print options on Workflow tab', async () => {
  793. const user = userEvent.setup();
  794. render(<SettingsPage />);
  795. await waitFor(() => {
  796. expect(screen.getByText('Workflow')).toBeInTheDocument();
  797. });
  798. await user.click(screen.getByText('Workflow'));
  799. await waitFor(() => {
  800. expect(screen.getByText('Default Print Options')).toBeInTheDocument();
  801. expect(screen.getByText('Bed Levelling')).toBeInTheDocument();
  802. expect(screen.getByText('Flow Calibration')).toBeInTheDocument();
  803. expect(screen.getByText('Vibration Calibration')).toBeInTheDocument();
  804. expect(screen.getByText('First Layer Inspection')).toBeInTheDocument();
  805. expect(screen.getByText('Timelapse')).toBeInTheDocument();
  806. });
  807. });
  808. it('shows default print options description', async () => {
  809. const user = userEvent.setup();
  810. render(<SettingsPage />);
  811. await waitFor(() => {
  812. expect(screen.getByText('Workflow')).toBeInTheDocument();
  813. });
  814. await user.click(screen.getByText('Workflow'));
  815. await waitFor(() => {
  816. expect(screen.getByText(/overridden per print in the print dialog/)).toBeInTheDocument();
  817. });
  818. });
  819. });
  820. describe('API Keys tab', () => {
  821. it('can switch to API Keys tab', async () => {
  822. const user = userEvent.setup();
  823. render(<SettingsPage />);
  824. await waitFor(() => {
  825. expect(screen.getByText('API Keys')).toBeInTheDocument();
  826. });
  827. await user.click(screen.getByText('API Keys'));
  828. await waitFor(() => {
  829. // Button text is "Create Key"
  830. expect(screen.getByText('Create Key')).toBeInTheDocument();
  831. });
  832. });
  833. });
  834. describe('SpoolBuddy tab badge', () => {
  835. const baseDevice = {
  836. id: 1,
  837. device_id: 'sb-0001',
  838. hostname: 'sb-kitchen',
  839. ip_address: '10.0.0.1',
  840. backend_url: null,
  841. firmware_version: '1.0.0',
  842. has_nfc: true,
  843. has_scale: true,
  844. tare_offset: 0,
  845. calibration_factor: 1.0,
  846. nfc_reader_type: null,
  847. nfc_connection: null,
  848. display_brightness: 100,
  849. display_blank_timeout: 0,
  850. has_backlight: false,
  851. last_calibrated_at: null,
  852. last_seen: new Date().toISOString(),
  853. pending_command: null,
  854. nfc_ok: true,
  855. scale_ok: true,
  856. uptime_s: 100,
  857. update_status: null,
  858. update_message: null,
  859. system_stats: null,
  860. online: true,
  861. created_at: '2024-01-01T00:00:00Z',
  862. updated_at: '2024-01-01T00:00:00Z',
  863. };
  864. it('shows device count and green bullet when at least one device is online', async () => {
  865. server.use(
  866. http.get('/api/v1/spoolbuddy/devices', () => {
  867. return HttpResponse.json([
  868. { ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'sb-kitchen', online: true },
  869. { ...baseDevice, id: 2, device_id: 'sb-0002', hostname: 'sb-ghost', online: false },
  870. ]);
  871. })
  872. );
  873. render(<SettingsPage />);
  874. // Find the tab button (not the header) — it's the <button> containing the SpoolBuddy text
  875. const tabButton = await waitFor(() => {
  876. const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));
  877. expect(buttons.length).toBeGreaterThan(0);
  878. return buttons[0];
  879. });
  880. // Count pill rendered
  881. await waitFor(() => {
  882. expect(tabButton.textContent).toContain('2');
  883. });
  884. // Green status bullet (at least one device online)
  885. await waitFor(() => {
  886. expect(tabButton.querySelector('.bg-green-400')).not.toBeNull();
  887. });
  888. });
  889. it('shows gray bullet when all devices are offline', async () => {
  890. server.use(
  891. http.get('/api/v1/spoolbuddy/devices', () => {
  892. return HttpResponse.json([{ ...baseDevice, online: false }]);
  893. })
  894. );
  895. render(<SettingsPage />);
  896. const tabButton = await waitFor(() => {
  897. const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));
  898. expect(buttons.length).toBeGreaterThan(0);
  899. return buttons[0];
  900. });
  901. await waitFor(() => {
  902. expect(tabButton.querySelector('.bg-gray-500')).not.toBeNull();
  903. expect(tabButton.querySelector('.bg-green-400')).toBeNull();
  904. });
  905. });
  906. it('hides the count pill when no devices are registered', async () => {
  907. server.use(
  908. http.get('/api/v1/spoolbuddy/devices', () => HttpResponse.json([]))
  909. );
  910. render(<SettingsPage />);
  911. const tabButton = await waitFor(() => {
  912. const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));
  913. expect(buttons.length).toBeGreaterThan(0);
  914. return buttons[0];
  915. });
  916. // The only numeric content should NOT be present — tab label only
  917. await waitFor(() => {
  918. expect(tabButton.textContent).toBe('SpoolBuddy');
  919. });
  920. });
  921. });
  922. describe('API Keys tab — delete flow', () => {
  923. // Without setQueryData on success the deleted row stayed visible until a
  924. // manual reload — invalidateQueries didn't reliably trigger a UI swap on
  925. // every browser. Pin the synchronous-removal contract here.
  926. it('removes a deleted key from the list without a page reload', async () => {
  927. const initialKeys = [
  928. {
  929. id: 42,
  930. name: 'CI deploy key',
  931. key_prefix: 'bk_abcd1234',
  932. can_queue: true,
  933. can_control_printer: false,
  934. can_read_status: true,
  935. printer_ids: null,
  936. enabled: true,
  937. last_used: null,
  938. created_at: '2026-01-01T00:00:00Z',
  939. expires_at: null,
  940. },
  941. ];
  942. let deleteCallCount = 0;
  943. server.use(
  944. http.get('/api/v1/api-keys/', () => HttpResponse.json(initialKeys)),
  945. http.delete('/api/v1/api-keys/:id', ({ params }) => {
  946. deleteCallCount += 1;
  947. expect(params.id).toBe('42');
  948. return HttpResponse.json({ message: 'API key deleted' });
  949. })
  950. );
  951. const user = userEvent.setup();
  952. render(<SettingsPage />);
  953. // Switch to API Keys tab. Both desktop tab + mobile dropdown render
  954. // the label, so just grab the button form.
  955. await waitFor(() => {
  956. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  957. });
  958. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  959. expect(tabButton).toBeDefined();
  960. await user.click(tabButton!);
  961. // Key is listed
  962. await waitFor(() => {
  963. expect(screen.getByText('CI deploy key')).toBeInTheDocument();
  964. });
  965. // Click the trash button on the row
  966. const cards = screen.getByText('CI deploy key').closest('.flex.items-center.justify-between');
  967. expect(cards).not.toBeNull();
  968. const trashButton = cards!.querySelectorAll('button');
  969. await user.click(trashButton[trashButton.length - 1]);
  970. // Confirm the deletion in the modal
  971. const confirmButton = await screen.findByRole('button', { name: /delete/i });
  972. await user.click(confirmButton);
  973. // The deleted key disappears from the list immediately — no manual
  974. // reload required. setQueryData drops it before any refetch could fire.
  975. await waitFor(() => {
  976. expect(screen.queryByText('CI deploy key')).not.toBeInTheDocument();
  977. });
  978. expect(deleteCallCount).toBe(1);
  979. });
  980. });
  981. describe('API Keys tab — #1182 cloud access + ownership UI', () => {
  982. // The list now exposes two new bits of information per row:
  983. // - "Cloud" badge when can_access_cloud=true
  984. // - "Legacy" badge when user_id IS NULL (created before per-user ownership)
  985. // These tell the operator at a glance which keys can read /cloud/* data
  986. // and which keys need to be recreated to gain that capability.
  987. it('renders the Cloud badge for keys with can_access_cloud=true and the Legacy badge for ownerless keys', async () => {
  988. const keys = [
  989. {
  990. id: 1,
  991. name: 'cloud-reader',
  992. key_prefix: 'bk_cloud123',
  993. user_id: 7,
  994. can_queue: false,
  995. can_control_printer: false,
  996. can_read_status: true,
  997. can_access_cloud: true,
  998. printer_ids: null,
  999. enabled: true,
  1000. last_used: null,
  1001. created_at: '2026-04-30T00:00:00Z',
  1002. expires_at: null,
  1003. },
  1004. {
  1005. id: 2,
  1006. name: 'legacy-key',
  1007. key_prefix: 'bk_legacy01',
  1008. user_id: null,
  1009. can_queue: true,
  1010. can_control_printer: false,
  1011. can_read_status: true,
  1012. can_access_cloud: false,
  1013. printer_ids: null,
  1014. enabled: true,
  1015. last_used: null,
  1016. created_at: '2025-01-01T00:00:00Z',
  1017. expires_at: null,
  1018. },
  1019. ];
  1020. server.use(http.get('/api/v1/api-keys/', () => HttpResponse.json(keys)));
  1021. const user = userEvent.setup();
  1022. render(<SettingsPage />);
  1023. await waitFor(() => {
  1024. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  1025. });
  1026. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  1027. await user.click(tabButton!);
  1028. await waitFor(() => {
  1029. expect(screen.getByText('cloud-reader')).toBeInTheDocument();
  1030. expect(screen.getByText('legacy-key')).toBeInTheDocument();
  1031. });
  1032. // Cloud-enabled key gets the Cloud badge but NOT the Legacy badge.
  1033. const cloudRow = screen.getByText('cloud-reader').closest('.flex.items-center.justify-between');
  1034. expect(cloudRow).not.toBeNull();
  1035. expect(cloudRow!.textContent).toContain('Cloud');
  1036. expect(cloudRow!.textContent).not.toContain('Legacy');
  1037. // Ownerless key gets Legacy but NOT Cloud (can_access_cloud=false).
  1038. const legacyRow = screen.getByText('legacy-key').closest('.flex.items-center.justify-between');
  1039. expect(legacyRow).not.toBeNull();
  1040. expect(legacyRow!.textContent).toContain('Legacy');
  1041. // Strip the Cloud-flag check by limiting to badge area — the
  1042. // "Allow cloud access" text from the create form isn't visible here.
  1043. expect(legacyRow!.querySelector('.bg-purple-500\\/20')).toBeNull();
  1044. });
  1045. it('passes can_access_cloud through to the create call when the toggle is checked', async () => {
  1046. let posted: { name?: string; can_access_cloud?: boolean } | null = null;
  1047. server.use(
  1048. http.get('/api/v1/api-keys/', () => HttpResponse.json([])),
  1049. http.post('/api/v1/api-keys/', async ({ request }) => {
  1050. posted = (await request.json()) as { name?: string; can_access_cloud?: boolean };
  1051. return HttpResponse.json({
  1052. id: 99,
  1053. key: 'bk_returnedkey',
  1054. name: posted.name,
  1055. key_prefix: 'bk_returne',
  1056. user_id: 1,
  1057. can_queue: true,
  1058. can_control_printer: false,
  1059. can_read_status: true,
  1060. can_access_cloud: posted.can_access_cloud ?? false,
  1061. printer_ids: null,
  1062. enabled: true,
  1063. last_used: null,
  1064. created_at: '2026-05-01T00:00:00Z',
  1065. expires_at: null,
  1066. });
  1067. })
  1068. );
  1069. const user = userEvent.setup();
  1070. render(<SettingsPage />);
  1071. await waitFor(() => {
  1072. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  1073. });
  1074. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  1075. await user.click(tabButton!);
  1076. // Open the create form. With an empty key list the empty-state card
  1077. // shows "Create Your First Key" — click that to open the form.
  1078. const openButton = await screen.findByRole('button', { name: /Create Your First Key/i });
  1079. await user.click(openButton);
  1080. // Tick the new "Allow cloud access" checkbox. The label wraps the
  1081. // input AND a sibling description div, so getByLabelText doesn't
  1082. // resolve via implicit-label traversal — locate via text + closest
  1083. // label, then grab the checkbox from the same scope.
  1084. const cloudLabelText = await screen.findByText(/Allow cloud access/i);
  1085. const cloudLabel = cloudLabelText.closest('label');
  1086. expect(cloudLabel).not.toBeNull();
  1087. const cloudCheckbox = cloudLabel!.querySelector('input[type="checkbox"]') as HTMLInputElement;
  1088. expect(cloudCheckbox).not.toBeNull();
  1089. await user.click(cloudCheckbox);
  1090. // Submit. Two "Create Key" buttons exist once the form is open (header
  1091. // CTA + form footer); the form-footer one is the actual submit and
  1092. // calls the mutation — find it by walking up from the cloud checkbox
  1093. // we just clicked, since both share the same form container.
  1094. const submitButtons = screen.getAllByRole('button', { name: /^Create Key$/i });
  1095. // Footer submit is the one inside the same form section as the
  1096. // checkbox. The header CTA is in a separate flex row.
  1097. const formSubmit = submitButtons.find(
  1098. (b) => b.closest('div')?.contains(cloudCheckbox) || cloudLabel?.parentElement?.parentElement?.contains(b),
  1099. );
  1100. await user.click(formSubmit ?? submitButtons[submitButtons.length - 1]);
  1101. await waitFor(() => {
  1102. expect(posted).not.toBeNull();
  1103. expect(posted!.can_access_cloud).toBe(true);
  1104. });
  1105. });
  1106. });
  1107. describe('API Keys tab — #1356 energy-cost write scope', () => {
  1108. /**
  1109. * The narrowly-scoped settings-write toggle. We pin two contracts here:
  1110. *
  1111. * 1. The "Energy" badge renders for keys that have can_update_energy_cost=true.
  1112. * Without a visible signal, an operator can't tell which key in their
  1113. * list is the one their HA automation depends on.
  1114. * 2. The create form sends can_update_energy_cost=true to the backend
  1115. * when the toggle is checked. The whole point of #1356 is that the
  1116. * flag must actually be persisted — a UI that drops it silently
  1117. * would put us right back where the bug started.
  1118. */
  1119. it('renders the Energy badge for keys with can_update_energy_cost=true', async () => {
  1120. const keys = [
  1121. {
  1122. id: 1,
  1123. name: 'tariff-pusher',
  1124. key_prefix: 'bk_tariff01',
  1125. user_id: 7,
  1126. can_queue: false,
  1127. can_control_printer: false,
  1128. can_read_status: true,
  1129. can_access_cloud: false,
  1130. can_update_energy_cost: true,
  1131. printer_ids: null,
  1132. enabled: true,
  1133. last_used: null,
  1134. created_at: '2026-05-15T00:00:00Z',
  1135. expires_at: null,
  1136. },
  1137. ];
  1138. server.use(http.get('/api/v1/api-keys/', () => HttpResponse.json(keys)));
  1139. const user = userEvent.setup();
  1140. render(<SettingsPage />);
  1141. await waitFor(() => {
  1142. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  1143. });
  1144. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  1145. await user.click(tabButton!);
  1146. await waitFor(() => {
  1147. expect(screen.getByText('tariff-pusher')).toBeInTheDocument();
  1148. });
  1149. const row = screen.getByText('tariff-pusher').closest('.flex.items-center.justify-between');
  1150. expect(row).not.toBeNull();
  1151. expect(row!.textContent).toContain('Energy');
  1152. });
  1153. it('passes can_update_energy_cost through to the create call when the toggle is checked', async () => {
  1154. let posted: { name?: string; can_update_energy_cost?: boolean } | null = null;
  1155. server.use(
  1156. http.get('/api/v1/api-keys/', () => HttpResponse.json([])),
  1157. http.post('/api/v1/api-keys/', async ({ request }) => {
  1158. posted = (await request.json()) as { name?: string; can_update_energy_cost?: boolean };
  1159. return HttpResponse.json({
  1160. id: 99,
  1161. key: 'bk_returnedkey',
  1162. name: posted.name,
  1163. key_prefix: 'bk_returne',
  1164. user_id: 1,
  1165. can_queue: true,
  1166. can_control_printer: false,
  1167. can_read_status: true,
  1168. can_access_cloud: false,
  1169. can_update_energy_cost: posted.can_update_energy_cost ?? false,
  1170. printer_ids: null,
  1171. enabled: true,
  1172. last_used: null,
  1173. created_at: '2026-05-15T00:00:00Z',
  1174. expires_at: null,
  1175. });
  1176. })
  1177. );
  1178. const user = userEvent.setup();
  1179. render(<SettingsPage />);
  1180. await waitFor(() => {
  1181. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  1182. });
  1183. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  1184. await user.click(tabButton!);
  1185. const openButton = await screen.findByRole('button', { name: /Create Your First Key/i });
  1186. await user.click(openButton);
  1187. const energyLabelText = await screen.findByText(/Update electricity price/i);
  1188. const energyLabel = energyLabelText.closest('label');
  1189. expect(energyLabel).not.toBeNull();
  1190. const energyCheckbox = energyLabel!.querySelector('input[type="checkbox"]') as HTMLInputElement;
  1191. expect(energyCheckbox).not.toBeNull();
  1192. await user.click(energyCheckbox);
  1193. const submitButtons = screen.getAllByRole('button', { name: /^Create Key$/i });
  1194. const formSubmit = submitButtons.find(
  1195. (b) => b.closest('div')?.contains(energyCheckbox) || energyLabel?.parentElement?.parentElement?.contains(b),
  1196. );
  1197. await user.click(formSubmit ?? submitButtons[submitButtons.length - 1]);
  1198. await waitFor(() => {
  1199. expect(posted).not.toBeNull();
  1200. expect(posted!.can_update_energy_cost).toBe(true);
  1201. });
  1202. });
  1203. });
  1204. describe('external camera snapshot URL override (#1177)', () => {
  1205. /**
  1206. * The snapshot URL input only appears for stream camera types where the
  1207. * MJPEG warm-up problem can occur (mjpeg / rtsp / usb). Pure HTTP
  1208. * snapshot sources don't need an override since their stream URL is
  1209. * already a single-frame endpoint.
  1210. */
  1211. const mjpegPrinter = {
  1212. id: 7,
  1213. name: 'go2rtc Cam',
  1214. serial_number: 'TEST123',
  1215. ip_address: '192.168.1.100',
  1216. access_code: 'XXXX',
  1217. model: 'P1S',
  1218. location: null,
  1219. nozzle_count: 1,
  1220. is_active: true,
  1221. auto_archive: true,
  1222. external_camera_url: 'http://192.168.1.61:1984/api/stream.mjpeg?src=printer',
  1223. external_camera_type: 'mjpeg',
  1224. external_camera_enabled: true,
  1225. external_camera_snapshot_url: null,
  1226. camera_rotation: 0,
  1227. plate_detection_enabled: false,
  1228. created_at: '2026-01-01T00:00:00Z',
  1229. updated_at: '2026-01-01T00:00:00Z',
  1230. };
  1231. it('renders the snapshot URL input when camera_type is mjpeg', async () => {
  1232. server.use(
  1233. http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
  1234. );
  1235. render(<SettingsPage />);
  1236. await waitFor(() => {
  1237. expect(screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/)).toBeInTheDocument();
  1238. });
  1239. });
  1240. it('hides the snapshot URL input when camera_type is snapshot (already a single-frame source)', async () => {
  1241. server.use(
  1242. http.get('/api/v1/printers/', () =>
  1243. HttpResponse.json([{ ...mjpegPrinter, external_camera_type: 'snapshot' }]),
  1244. ),
  1245. );
  1246. render(<SettingsPage />);
  1247. // Wait for the live-stream URL placeholder to render so we know the
  1248. // camera section finished mounting before asserting absence of the
  1249. // snapshot input below.
  1250. await waitFor(() => {
  1251. expect(screen.getByPlaceholderText(/Camera URL/i)).toBeInTheDocument();
  1252. });
  1253. expect(screen.queryByPlaceholderText(/api\/frame\.jpeg\?src=printer/)).not.toBeInTheDocument();
  1254. });
  1255. it(
  1256. 'PATCHes the printer with external_camera_snapshot_url when the user types into the input',
  1257. async () => {
  1258. let receivedBody: Record<string, unknown> | null = null;
  1259. server.use(
  1260. http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
  1261. http.patch('/api/v1/printers/7', async ({ request }) => {
  1262. receivedBody = (await request.json()) as Record<string, unknown>;
  1263. return HttpResponse.json({ ...mjpegPrinter, ...receivedBody });
  1264. }),
  1265. );
  1266. render(<SettingsPage />);
  1267. const input = await waitFor(() =>
  1268. screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/),
  1269. );
  1270. const user = userEvent.setup();
  1271. await user.type(input, 'http://192.168.1.61:1984/api/frame.jpeg?src=printer');
  1272. // Save is debounced by 800ms; assert the PATCH eventually fires with
  1273. // the typed snapshot URL.
  1274. await waitFor(
  1275. () => {
  1276. expect(receivedBody).not.toBeNull();
  1277. expect(receivedBody!.external_camera_snapshot_url).toBe(
  1278. 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
  1279. );
  1280. },
  1281. { timeout: 5000 },
  1282. );
  1283. },
  1284. // Per-test timeout raised to 15s — `user.type()` of a 49-char URL plus
  1285. // the 800ms save debounce fits in 5s locally (~2.3s typical) but blows
  1286. // past it on slow GitHub Actions runners (5000ms timeout was the failure
  1287. // mode on PR #1263).
  1288. 15_000,
  1289. );
  1290. });
  1291. describe('theme mode buttons', () => {
  1292. it('renders Dark, Light, and System buttons', async () => {
  1293. render(<SettingsPage />);
  1294. await waitFor(() => {
  1295. expect(screen.getByRole('button', { name: 'Dark' })).toBeInTheDocument();
  1296. expect(screen.getByRole('button', { name: 'Light' })).toBeInTheDocument();
  1297. expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
  1298. });
  1299. });
  1300. it('highlights the active mode button with green border', async () => {
  1301. render(<SettingsPage />);
  1302. const user = userEvent.setup();
  1303. await waitFor(() => {
  1304. expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
  1305. });
  1306. await user.click(screen.getByRole('button', { name: 'System' }));
  1307. await waitFor(() => {
  1308. const systemBtn = screen.getByRole('button', { name: 'System' });
  1309. expect(systemBtn.className).toContain('border-bambu-green');
  1310. });
  1311. });
  1312. it('clicking a theme button switches mode', async () => {
  1313. localStorage.setItem('theme-mode', 'dark');
  1314. render(<SettingsPage />);
  1315. const user = userEvent.setup();
  1316. await waitFor(() => {
  1317. const darkBtn = screen.getByRole('button', { name: 'Dark' });
  1318. expect(darkBtn.className).toContain('border-bambu-green');
  1319. });
  1320. const lightBtn = screen.getByRole('button', { name: 'Light' });
  1321. await user.click(lightBtn);
  1322. await waitFor(() => {
  1323. expect(lightBtn.className).toContain('border-bambu-green');
  1324. });
  1325. });
  1326. it('shows a toast when theme button is clicked', async () => {
  1327. render(<SettingsPage />);
  1328. const user = userEvent.setup();
  1329. await waitFor(() => {
  1330. expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
  1331. });
  1332. await user.click(screen.getByRole('button', { name: 'System' }));
  1333. await waitFor(() => {
  1334. expect(screen.getByText('Settings saved')).toBeInTheDocument();
  1335. });
  1336. });
  1337. });
  1338. // --------------------------------------------------------------------
  1339. // Slicer Pipelines (#1425) — Workflow tab sub-tabs
  1340. // --------------------------------------------------------------------
  1341. describe('workflow sub-tabs (#1425)', () => {
  1342. beforeEach(() => {
  1343. // Endpoints the Pipelines panel calls (#1425).
  1344. server.use(
  1345. http.get('/api/v1/slicer-pipelines/', () => HttpResponse.json({ pipelines: [] })),
  1346. http.get('/api/v1/slicer/presets', () =>
  1347. HttpResponse.json({
  1348. orca_cloud: { printer: [], process: [], filament: [] },
  1349. cloud: { printer: [], process: [], filament: [] },
  1350. local: { printer: [], process: [], filament: [] },
  1351. standard: { printer: [], process: [], filament: [] },
  1352. cloud_status: 'ok',
  1353. orca_cloud_status: 'ok',
  1354. }),
  1355. ),
  1356. );
  1357. });
  1358. it('renders Queue & Dispatch + Pipelines sub-tabs under Workflow', async () => {
  1359. render(<SettingsPage />);
  1360. const user = userEvent.setup();
  1361. await waitFor(() => {
  1362. // Workflow tab in the sidebar — exact match to avoid colliding with
  1363. // "Print Queue" or "Queue Settings" labels elsewhere on the page.
  1364. expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument();
  1365. });
  1366. await user.click(screen.getByRole('button', { name: 'Workflow' }));
  1367. await waitFor(() => {
  1368. expect(screen.getByRole('button', { name: /Queue & Dispatch/i })).toBeInTheDocument();
  1369. expect(screen.getByRole('button', { name: /^Pipelines$/i })).toBeInTheDocument();
  1370. });
  1371. });
  1372. it('clicking Pipelines sub-tab shows the empty-state hint and updates the URL', async () => {
  1373. render(<SettingsPage />);
  1374. const user = userEvent.setup();
  1375. await waitFor(() => expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument());
  1376. await user.click(screen.getByRole('button', { name: 'Workflow' }));
  1377. await user.click(screen.getByRole('button', { name: /^Pipelines$/i }));
  1378. await waitFor(() => {
  1379. expect(screen.getByText(/No pipelines yet/i)).toBeInTheDocument();
  1380. // Deep-link URL carries both ?tab=queue and ?sub=pipelines
  1381. expect(window.location.search).toContain('tab=queue');
  1382. expect(window.location.search).toContain('sub=pipelines');
  1383. });
  1384. });
  1385. });
  1386. });
  1387. /**
  1388. * Location sensor cards on Settings -> Sensors read the live readings
  1389. * endpoint (reachable-aware) rather than the sensor row's last_state, so an
  1390. * entity Home Assistant has stopped reporting shows "Unavailable" instead of
  1391. * silently keeping its last colorized value on screen forever.
  1392. */
  1393. describe('SettingsPage — location sensor reachability', () => {
  1394. beforeEach(() => {
  1395. window.history.replaceState({}, '', '/');
  1396. });
  1397. const locationSensor = {
  1398. id: 1,
  1399. location_id: 7,
  1400. name: 'Drybox 1 Temperature',
  1401. entity_id: 'sensor.drybox_1_temperature',
  1402. kind: 'numeric',
  1403. device_class: 'temperature',
  1404. unit: '°C',
  1405. alert_state: null,
  1406. alert_above: 30,
  1407. alert_below: 20,
  1408. notify_on_alert: false,
  1409. show_on_card: true,
  1410. sort_order: 0,
  1411. last_state: '65.0',
  1412. last_changed: null,
  1413. last_checked: null,
  1414. created_at: '',
  1415. updated_at: '',
  1416. };
  1417. it('shows "Unavailable" for an unreachable sensor instead of its stale last value', async () => {
  1418. server.use(
  1419. http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json([locationSensor])),
  1420. http.get('/api/v1/location-ha-sensors/by-location/7/readings', () =>
  1421. HttpResponse.json([
  1422. {
  1423. id: 1,
  1424. name: 'Drybox 1 Temperature',
  1425. entity_id: 'sensor.drybox_1_temperature',
  1426. kind: 'numeric',
  1427. device_class: 'temperature',
  1428. unit: '°C',
  1429. state: null,
  1430. value: null,
  1431. alerting: false,
  1432. reachable: false,
  1433. alert_state: null,
  1434. alert_above: 30,
  1435. alert_below: 20,
  1436. last_changed: null,
  1437. },
  1438. ])
  1439. ),
  1440. http.get('/api/v1/inventory/locations', () =>
  1441. HttpResponse.json([{ id: 7, name: 'Drybox 1', identifier: null, spool_count: 0, created_at: '', updated_at: '' }])
  1442. )
  1443. );
  1444. const user = userEvent.setup();
  1445. render(<SettingsPage />);
  1446. await user.click(await screen.findByText('Sensors'));
  1447. await screen.findByText('sensor.drybox_1_temperature');
  1448. expect(await screen.findByText('Unavailable')).toBeInTheDocument();
  1449. expect(screen.queryByText('65.00 °C')).not.toBeInTheDocument();
  1450. });
  1451. it('shows the live value, not last_state, when the sensor is reachable', async () => {
  1452. server.use(
  1453. http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json([locationSensor])),
  1454. http.get('/api/v1/location-ha-sensors/by-location/7/readings', () =>
  1455. HttpResponse.json([
  1456. {
  1457. id: 1,
  1458. name: 'Drybox 1 Temperature',
  1459. entity_id: 'sensor.drybox_1_temperature',
  1460. kind: 'numeric',
  1461. device_class: 'temperature',
  1462. unit: '°C',
  1463. state: '24.5',
  1464. value: 24.5,
  1465. alerting: false,
  1466. reachable: true,
  1467. alert_state: null,
  1468. alert_above: 30,
  1469. alert_below: 20,
  1470. last_changed: null,
  1471. },
  1472. ])
  1473. ),
  1474. http.get('/api/v1/inventory/locations', () =>
  1475. HttpResponse.json([{ id: 7, name: 'Drybox 1', identifier: null, spool_count: 0, created_at: '', updated_at: '' }])
  1476. )
  1477. );
  1478. const user = userEvent.setup();
  1479. render(<SettingsPage />);
  1480. await user.click(await screen.findByText('Sensors'));
  1481. await screen.findByText('sensor.drybox_1_temperature');
  1482. expect(await screen.findByText('24.50 °C')).toBeInTheDocument();
  1483. expect(screen.queryByText('Unavailable')).not.toBeInTheDocument();
  1484. });
  1485. it('shows the entity id in the overview row, with the display name as its hover title', async () => {
  1486. server.use(
  1487. http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json([locationSensor])),
  1488. http.get('/api/v1/location-ha-sensors/by-location/7/readings', () => HttpResponse.json([])),
  1489. http.get('/api/v1/inventory/locations', () =>
  1490. HttpResponse.json([{ id: 7, name: 'Drybox 1', identifier: null, spool_count: 0, created_at: '', updated_at: '' }])
  1491. )
  1492. );
  1493. const user = userEvent.setup();
  1494. render(<SettingsPage />);
  1495. await user.click(await screen.findByText('Sensors'));
  1496. const entityIdText = await screen.findByText('sensor.drybox_1_temperature');
  1497. expect(entityIdText).toHaveAttribute('title', 'Drybox 1 Temperature');
  1498. expect(screen.queryByText('Drybox 1 Temperature')).not.toBeInTheDocument();
  1499. });
  1500. it('refreshes the sensor list even when a bulk delete partially fails', async () => {
  1501. let sensors = [
  1502. { ...locationSensor, id: 1, name: 'Drybox 1 Temperature' },
  1503. {
  1504. ...locationSensor,
  1505. id: 2,
  1506. name: 'Drybox 1 Humidity',
  1507. entity_id: 'sensor.drybox_1_humidity',
  1508. device_class: 'humidity',
  1509. unit: '%',
  1510. },
  1511. ];
  1512. server.use(
  1513. http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json(sensors)),
  1514. http.get('/api/v1/location-ha-sensors/by-location/7/readings', () => HttpResponse.json([])),
  1515. http.get('/api/v1/inventory/locations', () =>
  1516. HttpResponse.json([{ id: 7, name: 'Drybox 1', identifier: null, spool_count: 0, created_at: '', updated_at: '' }])
  1517. ),
  1518. // The first delete succeeds and actually removes the row; the second
  1519. // fails, simulating a partial failure partway through the sequential
  1520. // delete loop.
  1521. http.delete('/api/v1/location-ha-sensors/1', () => {
  1522. sensors = sensors.filter((s) => s.id !== 1);
  1523. return HttpResponse.json({ message: 'Sensor removed' });
  1524. }),
  1525. http.delete('/api/v1/location-ha-sensors/2', () => new HttpResponse(null, { status: 500 }))
  1526. );
  1527. const user = userEvent.setup();
  1528. render(<SettingsPage />);
  1529. await user.click(await screen.findByText('Sensors'));
  1530. await screen.findByText('sensor.drybox_1_temperature');
  1531. await screen.findByText('sensor.drybox_1_humidity');
  1532. await user.click(screen.getByRole('button', { name: 'Delete' }));
  1533. await user.click(await screen.findByRole('button', { name: 'Confirm' }));
  1534. // The sensor that actually got deleted on the backend must not linger on
  1535. // screen just because the batch as a whole reported an error.
  1536. await waitFor(() => expect(screen.queryByText('sensor.drybox_1_temperature')).not.toBeInTheDocument());
  1537. expect(screen.getByText('sensor.drybox_1_humidity')).toBeInTheDocument();
  1538. });
  1539. it('orders location cards by location, not by the order their sensors were created', async () => {
  1540. // Sensor for location 8 ("Drybox 10") appears in the array before the
  1541. // sensor for location 7 ("Drybox 2") — a naive Map-insertion-order
  1542. // render would put "Drybox 10" first. Card order must follow the
  1543. // (naturally sorted) locations list instead.
  1544. const sensors = [
  1545. {
  1546. id: 1,
  1547. location_id: 8,
  1548. name: 'Drybox 10 Temperature',
  1549. entity_id: 'sensor.drybox_10_temperature',
  1550. device_class: 'temperature',
  1551. unit: '°C',
  1552. },
  1553. {
  1554. id: 2,
  1555. location_id: 7,
  1556. name: 'Drybox 2 Temperature',
  1557. entity_id: 'sensor.drybox_2_temperature',
  1558. device_class: 'temperature',
  1559. unit: '°C',
  1560. },
  1561. ];
  1562. server.use(
  1563. http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json(sensors)),
  1564. http.get('/api/v1/location-ha-sensors/by-location/7/readings', () => HttpResponse.json([])),
  1565. http.get('/api/v1/location-ha-sensors/by-location/8/readings', () => HttpResponse.json([])),
  1566. http.get('/api/v1/inventory/locations', () =>
  1567. HttpResponse.json([
  1568. { id: 7, name: 'Drybox 2', identifier: null, spool_count: 0, created_at: '', updated_at: '' },
  1569. { id: 8, name: 'Drybox 10', identifier: null, spool_count: 0, created_at: '', updated_at: '' },
  1570. ])
  1571. )
  1572. );
  1573. const user = userEvent.setup();
  1574. const { container } = render(<SettingsPage />);
  1575. await user.click(await screen.findByText('Sensors'));
  1576. await screen.findByText('sensor.drybox_10_temperature');
  1577. const cardTitles = Array.from(container.querySelectorAll('.text-white.font-medium.truncate')).map(
  1578. (el) => el.textContent
  1579. );
  1580. const drybox2Index = cardTitles.indexOf('Drybox 2');
  1581. const drybox10Index = cardTitles.indexOf('Drybox 10');
  1582. expect(drybox2Index).toBeGreaterThanOrEqual(0);
  1583. expect(drybox10Index).toBeGreaterThanOrEqual(0);
  1584. expect(drybox2Index).toBeLessThan(drybox10Index);
  1585. });
  1586. });
  1587. /**
  1588. * Sponsor banner on Settings -> General.
  1589. *
  1590. * Below the fleet threshold it makes the community/donation ask; at or above it
  1591. * the same slot makes the commercial ask and points at business.html. A print
  1592. * farm asked to chip in $5 is a wasted impression, and a hobbyist pitched a
  1593. * support contract is an annoyed user — so both directions are pinned.
  1594. */
  1595. describe('SettingsPage — sponsor banner audience', () => {
  1596. beforeEach(() => {
  1597. // BrowserRouter shares window.location across tests and the banner only
  1598. // renders on the General tab — without this reset a prior test's ?tab=queue
  1599. // leaks in and the banner never mounts.
  1600. window.history.replaceState({}, '', '/');
  1601. });
  1602. const fleet = (count: number) =>
  1603. server.use(
  1604. http.get('/api/v1/printers/', () =>
  1605. HttpResponse.json(
  1606. Array.from({ length: count }, (_, i) => ({
  1607. id: i + 1,
  1608. name: `Printer ${i + 1}`,
  1609. serial_number: `SN${i + 1}`,
  1610. ip_address: '192.168.1.10',
  1611. model: 'X1C',
  1612. is_active: true,
  1613. })),
  1614. ),
  1615. ),
  1616. );
  1617. it('shows the community ask for a small fleet', async () => {
  1618. fleet(2);
  1619. render(<SettingsPage />);
  1620. const banner = await screen.findByRole('link', { name: /Independent & community-funded/i });
  1621. expect(banner).toHaveAttribute('href', 'https://bambuddy.cool/sponsors.html?from=app-settings');
  1622. expect(screen.queryByText(/Bambuddy for business/i)).not.toBeInTheDocument();
  1623. });
  1624. it('shows the commercial ask for a business-sized fleet', async () => {
  1625. fleet(6);
  1626. render(<SettingsPage />);
  1627. const banner = await screen.findByRole('link', { name: /Bambuddy for business/i });
  1628. expect(banner).toHaveAttribute('href', 'https://bambuddy.cool/business.html?from=app-settings');
  1629. // The donation copy is replaced, not merely supplemented.
  1630. expect(screen.queryByText(/Independent & community-funded/i)).not.toBeInTheDocument();
  1631. // The ask names the fleet back to them.
  1632. expect(screen.getByText(/6 printers/i)).toBeInTheDocument();
  1633. });
  1634. });
  1635. describe('SettingsPage — settings changed outside the page (#2716)', () => {
  1636. const restoreLabel = 'Restore plate for finish photo';
  1637. // external_url is deliberately populated: when the server has none the page
  1638. // detects one from the browser and saves it unprompted, which would show up
  1639. // as a PUT in tests that assert none was made. That behaviour has its own
  1640. // test at the end of this block.
  1641. const baseSettings = { ...mockSettings, external_url: window.location.origin };
  1642. let queryClient: QueryClient;
  1643. let puts: Record<string, unknown>[];
  1644. let served: Record<string, unknown>;
  1645. function renderPage() {
  1646. queryClient = new QueryClient({
  1647. defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } },
  1648. });
  1649. return rtlRender(
  1650. <QueryClientProvider client={queryClient}>
  1651. <BrowserRouter>
  1652. <AuthProvider>
  1653. <ThemeProvider>
  1654. <ToastProvider>
  1655. <SettingsPage />
  1656. </ToastProvider>
  1657. </ThemeProvider>
  1658. </AuthProvider>
  1659. </BrowserRouter>
  1660. </QueryClientProvider>
  1661. );
  1662. }
  1663. /** Change the settings row server-side and let the page's query observe it. */
  1664. async function changeOnServer(patch: Record<string, unknown>) {
  1665. served = { ...served, ...patch };
  1666. await act(async () => {
  1667. await queryClient.invalidateQueries({ queryKey: ['settings'] });
  1668. });
  1669. }
  1670. /** Wait out the 100ms initial-load suppression, then flip a checkbox. */
  1671. async function toggleRestorePlate() {
  1672. const label = await screen.findByText(restoreLabel);
  1673. await new Promise((resolve) => setTimeout(resolve, 200));
  1674. const row = label.closest('div')!.parentElement!;
  1675. await userEvent.click(within(row).getByRole('checkbox'));
  1676. }
  1677. beforeEach(() => {
  1678. window.history.replaceState({}, '', '/');
  1679. localStorage.clear();
  1680. setAuthToken(null);
  1681. puts = [];
  1682. served = { ...baseSettings };
  1683. server.use(
  1684. http.get('/api/v1/settings/', () => HttpResponse.json(served)),
  1685. http.put('/api/v1/settings/', async ({ request }) => {
  1686. const body = (await request.json()) as Record<string, unknown>;
  1687. puts.push(body);
  1688. served = { ...served, ...body };
  1689. return HttpResponse.json(served);
  1690. })
  1691. );
  1692. });
  1693. it('does not write its stale copy back over a server-side change', async () => {
  1694. // The defect: the page diffed the live query cache against its own copy, so
  1695. // a refetch that carried someone else's change read as a local edit and was
  1696. // reverted ~500ms later with no user interaction at all.
  1697. renderPage();
  1698. await screen.findByText(restoreLabel);
  1699. await new Promise((resolve) => setTimeout(resolve, 200));
  1700. await changeOnServer({ currency: 'EUR' });
  1701. // Well past the 500ms debounce.
  1702. await new Promise((resolve) => setTimeout(resolve, 1200));
  1703. expect(puts).toEqual([]);
  1704. });
  1705. it('adopts the server value, so a later save carries it rather than the stale one', async () => {
  1706. renderPage();
  1707. await new Promise((resolve) => setTimeout(resolve, 200));
  1708. await changeOnServer({ currency: 'EUR' });
  1709. await toggleRestorePlate();
  1710. await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
  1711. // The user's edit is saved...
  1712. expect(puts[0].finish_photo_restore_plate).toBe(false);
  1713. // ...and the field they never touched goes back as the server's value, not
  1714. // the USD the page loaded with.
  1715. expect(puts[0].currency).toBe('EUR');
  1716. });
  1717. it('never reverts a pending user edit that the server changed too', async () => {
  1718. renderPage();
  1719. await toggleRestorePlate();
  1720. // Lands while the edit is still sitting in the 500ms debounce, i.e. before
  1721. // the page has committed it. Adopting the server's value here would throw
  1722. // the edit away silently.
  1723. await changeOnServer({ finish_photo_restore_plate: true });
  1724. await waitFor(() => expect(puts.length).toBeGreaterThan(0), { timeout: 3000 });
  1725. await new Promise((resolve) => setTimeout(resolve, 1200));
  1726. // Asserted over every request rather than a particular one: whichever order
  1727. // the refetch and the debounce happen to land in, no write may carry the
  1728. // server's value back over the user's.
  1729. expect(puts.map((p) => p.finish_photo_restore_plate)).toEqual(puts.map(() => false));
  1730. });
  1731. it('saves once per edit — the baseline moves with the saved row', async () => {
  1732. // Guards the failure mode the baseline introduces if it is not advanced on
  1733. // save: every render would diff against the pre-save snapshot and re-send.
  1734. renderPage();
  1735. await toggleRestorePlate();
  1736. await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
  1737. await new Promise((resolve) => setTimeout(resolve, 1500));
  1738. expect(puts).toHaveLength(1);
  1739. });
  1740. it('still persists the external_url it detects from the browser', async () => {
  1741. // The page seeds external_url from window.location.origin when the server
  1742. // has none and relies on the auto-save to persist it. That only works
  1743. // because the baseline is the raw server row: seed the baseline from the
  1744. // adjusted copy instead and the detected URL matches it, so nothing ever
  1745. // marks it as needing a save.
  1746. served = { ...mockSettings };
  1747. renderPage();
  1748. await screen.findByText(restoreLabel);
  1749. await new Promise((resolve) => setTimeout(resolve, 200));
  1750. // A refetch carrying a field this page does not manage. It is enough to
  1751. // re-run the diff, and the only thing that differs is the detected URL.
  1752. await changeOnServer({ spoolman_url: 'http://spoolman.example' });
  1753. await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
  1754. expect(puts[0].external_url).toBe(window.location.origin);
  1755. });
  1756. });