SettingsPage.test.tsx 71 KB

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