SettingsPage.test.tsx 86 KB

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