| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316 |
- /**
- * Tests for the SettingsPage component.
- */
- import { describe, it, expect, beforeEach, vi } from 'vitest';
- import { act, fireEvent, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
- import userEvent from '@testing-library/user-event';
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
- import { BrowserRouter } from 'react-router-dom';
- import { render } from '../utils';
- import { ThemeProvider } from '../../contexts/ThemeContext';
- import { ToastProvider } from '../../contexts/ToastContext';
- import { AuthProvider } from '../../contexts/AuthContext';
- import { SettingsPage } from '../../pages/SettingsPage';
- import { http, HttpResponse } from 'msw';
- import { server } from '../mocks/server';
- import { SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, SIDEBAR_ORDER_KEY } from '../../utils/sidebarLayout';
- import { setAuthToken } from '../../api/client';
- const mockSettings = {
- auto_archive: true,
- save_thumbnails: true,
- capture_finish_photo: true,
- default_filament_cost: 25.0,
- currency: 'USD',
- ams_humidity_good: 40,
- ams_humidity_fair: 60,
- ams_temp_good: 30,
- ams_temp_fair: 35,
- ams_temp_alarm: null,
- time_format: 'system',
- date_format: 'system',
- mqtt_enabled: false,
- mqtt_host: '',
- mqtt_port: 1883,
- spoolman_enabled: false,
- spoolman_url: '',
- ha_enabled: false,
- ha_url: '',
- ha_token: '',
- check_updates: false,
- check_printer_firmware: false,
- bed_cooled_threshold: 35,
- };
- describe('SettingsPage', () => {
- beforeEach(() => {
- // BrowserRouter shares window.location across tests; reset it so a tab
- // switch in one test (e.g. clicking "Workflow") doesn't carry into
- // sibling tests that expect to land on the default General tab.
- window.history.replaceState({}, '', '/');
- vi.mocked(localStorage.getItem).mockReset();
- vi.mocked(localStorage.setItem).mockReset();
- vi.mocked(localStorage.removeItem).mockReset();
- vi.mocked(localStorage.clear).mockReset();
- localStorage.clear();
- setAuthToken(null);
- server.use(
- http.get('/api/v1/settings/', () => {
- return HttpResponse.json(mockSettings);
- }),
- http.put('/api/v1/settings/', async ({ request }) => {
- const body = await request.json();
- return HttpResponse.json({ ...mockSettings, ...body });
- }),
- http.get('/api/v1/printers/', () => {
- return HttpResponse.json([]);
- }),
- http.get('/api/v1/smart-plugs/', () => {
- return HttpResponse.json([]);
- }),
- http.get('/api/v1/notifications/', () => {
- return HttpResponse.json([]);
- }),
- http.get('/api/v1/api-keys/', () => {
- return HttpResponse.json([]);
- }),
- http.get('/api/v1/mqtt/status', () => {
- return HttpResponse.json({ enabled: false });
- }),
- http.get('/api/v1/virtual-printer/status', () => {
- return HttpResponse.json({ running: false });
- }),
- http.get('/api/v1/auth/status', () => {
- return HttpResponse.json({ auth_enabled: false, requires_setup: false });
- }),
- http.get('/api/v1/external-links/', () => {
- return HttpResponse.json([]);
- })
- );
- });
- describe('rendering', () => {
- it('renders the page title', async () => {
- render(<SettingsPage />);
- await waitFor(() => {
- // Use role-based query to avoid conflicts with dropdown options
- expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument();
- });
- });
- it('shows settings tabs', async () => {
- render(<SettingsPage />);
- await waitFor(() => {
- // Use getAllByText since "General" appears both as tab and section heading
- expect(screen.getAllByText('General').length).toBeGreaterThan(0);
- expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
- expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
- expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
- expect(screen.getByText('Network')).toBeInTheDocument();
- expect(screen.getByText('API Keys')).toBeInTheDocument();
- });
- });
- });
- describe('finish photo plate restore (#2547)', () => {
- const restoreLabel = 'Restore plate for finish photo';
- it('offers the toggle while finish photos are enabled', async () => {
- render(<SettingsPage />);
- expect(await screen.findByText(restoreLabel)).toBeInTheDocument();
- });
- it('hides the toggle when finish photos are switched off', async () => {
- // It only describes how the finish photo is framed, so it is meaningless
- // when no finish photo is taken at all.
- server.use(
- http.get('/api/v1/settings/', () =>
- HttpResponse.json({ ...mockSettings, capture_finish_photo: false })
- )
- );
- render(<SettingsPage />);
- await screen.findByRole('heading', { name: 'Settings' });
- await waitFor(() => {
- expect(screen.queryByText(restoreLabel)).not.toBeInTheDocument();
- });
- });
- it('defaults to on when the backend has never stored the setting', async () => {
- // Existing installs have no row for it; the UI must not read that as off.
- render(<SettingsPage />);
- const label = await screen.findByText(restoreLabel);
- const row = label.closest('div')!.parentElement!;
- expect(within(row).getByRole('checkbox')).toBeChecked();
- });
- it('sends the new value on save', async () => {
- let saved: Record<string, unknown> | null = null;
- server.use(
- http.put('/api/v1/settings/', async ({ request }) => {
- saved = (await request.json()) as Record<string, unknown>;
- return HttpResponse.json({ ...mockSettings, ...saved });
- })
- );
- render(<SettingsPage />);
- const label = await screen.findByText(restoreLabel);
- // The page suppresses auto-save for 100ms after the settings load, so a
- // click landing inside that window is swallowed with no re-trigger.
- await new Promise((resolve) => setTimeout(resolve, 200));
- const row = label.closest('div')!.parentElement!;
- await userEvent.click(within(row).getByRole('checkbox'));
- // The page auto-saves on a 500ms debounce, so the default 1s waitFor
- // window is only just wide enough — give the request room to land.
- await waitFor(() => {
- expect(saved).not.toBeNull();
- }, { timeout: 3000 });
- expect(saved!.finish_photo_restore_plate).toBe(false);
- });
- });
- describe('general settings', () => {
- it('shows date format setting', async () => {
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByText('Date Format')).toBeInTheDocument();
- });
- });
- it('shows time format setting', async () => {
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByText('Time Format')).toBeInTheDocument();
- });
- });
- it('no longer offers Camera View Mode, which moved to the printer card', async () => {
- // The camera button on each printer card is a split control now, so the
- // choice is made per stream rather than once for the whole install. The
- // External Cameras section is still here, which is what keeps this from
- // passing merely because the Camera card failed to render.
- window.history.replaceState({}, '', '/?tab=camera');
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByText('External Cameras')).toBeInTheDocument();
- });
- expect(screen.queryByText('Camera View Mode')).toBeNull();
- });
- it('shows default printer setting', async () => {
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByText('Default Printer')).toBeInTheDocument();
- });
- });
- it('shows preferred slicer setting on Workflow tab', async () => {
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByText('Workflow')).toBeInTheDocument();
- });
- await user.click(screen.getByText('Workflow'));
- await waitFor(() => {
- expect(screen.getByText('Preferred Slicer')).toBeInTheDocument();
- });
- });
- it('shows slicer dropdown with both options on Workflow tab', async () => {
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByText('Workflow')).toBeInTheDocument();
- });
- await user.click(screen.getByText('Workflow'));
- await waitFor(() => {
- const slicerSelect = screen.getAllByDisplayValue('Bambu Studio');
- expect(slicerSelect.length).toBeGreaterThan(0);
- });
- });
- it('shows appearance section', async () => {
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByText('Appearance')).toBeInTheDocument();
- });
- });
- it('shows updates section with firmware toggle', async () => {
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByText('Updates')).toBeInTheDocument();
- expect(screen.getByText('Check for updates')).toBeInTheDocument();
- expect(screen.getByText('Check printer firmware')).toBeInTheDocument();
- });
- });
- it('hides a Bambuddy sidebar page from Sidebar', async () => {
- const user = userEvent.setup();
- render(<SettingsPage />);
- await screen.findByRole('heading', { name: 'Sidebar' });
- await screen.findAllByText('Visible in sidebar');
- vi.mocked(localStorage.setItem).mockClear();
- await user.click((await screen.findAllByLabelText('Hide page'))[0]);
- expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify(['printers']));
- expect(screen.getByText('Hidden from sidebar')).toBeInTheDocument();
- });
- it('shows a previously hidden Bambuddy sidebar page from Sidebar', async () => {
- vi.mocked(localStorage.getItem).mockImplementation((key) => {
- if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers']);
- return null;
- });
- const user = userEvent.setup();
- render(<SettingsPage />);
- await screen.findByRole('heading', { name: 'Sidebar' });
- await screen.findByText('Hidden from sidebar');
- vi.mocked(localStorage.setItem).mockClear();
- await user.click(await screen.findByLabelText('Show page'));
- expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
- expect(screen.getAllByText('Visible in sidebar').length).toBeGreaterThan(0);
- });
- it('does not allow Settings to be hidden from Sidebar', async () => {
- render(<SettingsPage />);
- await screen.findByRole('heading', { name: 'Sidebar' });
- await screen.findByText('Required in sidebar');
- const settingsVisibilityButton = await screen.findByLabelText('Settings cannot be hidden');
- expect(settingsVisibilityButton).toBeDisabled();
- expect(screen.getByText('Required in sidebar')).toBeInTheDocument();
- });
- it('presents external links and Bambuddy pages in saved sidebar order', async () => {
- vi.mocked(localStorage.getItem).mockImplementation((key) => {
- if (key === SIDEBAR_ORDER_KEY) return JSON.stringify(['ext-7', 'printers', 'settings']);
- return null;
- });
- server.use(
- http.get('/api/v1/external-links/', () =>
- HttpResponse.json([
- {
- id: 7,
- name: 'Docs',
- url: 'https://docs.example.test',
- icon: 'Link',
- open_in_new_tab: true,
- custom_icon: null,
- sort_order: 0,
- created_at: '2026-01-01T00:00:00Z',
- updated_at: '2026-01-01T00:00:00Z',
- },
- ]),
- ),
- );
- render(<SettingsPage />);
- await screen.findByRole('heading', { name: 'Sidebar' });
- const docs = await screen.findByText('Docs');
- const printers = screen.getAllByText('Printers').find(element => element.closest('[draggable="true"]'));
- expect(printers).toBeDefined();
- expect(docs.compareDocumentPosition(printers) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
- });
- it('saves mixed Sidebar order when items are dragged', async () => {
- server.use(
- http.get('/api/v1/external-links/', () =>
- HttpResponse.json([
- {
- id: 7,
- name: 'Docs',
- url: 'https://docs.example.test',
- icon: 'Link',
- open_in_new_tab: true,
- custom_icon: null,
- sort_order: 0,
- created_at: '2026-01-01T00:00:00Z',
- updated_at: '2026-01-01T00:00:00Z',
- },
- ]),
- ),
- );
- render(<SettingsPage />);
- await screen.findByRole('heading', { name: 'Sidebar' });
- const docsRow = (await screen.findByText('Docs')).closest('[draggable="true"]');
- const printersRow = screen.getAllByText('Printers')
- .find(element => element.closest('[draggable="true"]'))
- ?.closest('[draggable="true"]');
- expect(docsRow).not.toBeNull();
- expect(printersRow).not.toBeNull();
- vi.mocked(localStorage.setItem).mockClear();
- const dataTransfer = {
- effectAllowed: '',
- dropEffect: '',
- setData: vi.fn(),
- };
- fireEvent.dragStart(docsRow!, { dataTransfer });
- fireEvent.dragOver(printersRow!, { dataTransfer });
- fireEvent.drop(printersRow!, { dataTransfer });
- expect(localStorage.setItem).toHaveBeenCalledWith(
- SIDEBAR_ORDER_KEY,
- JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'finance', 'notifications', 'settings']),
- );
- });
- it('resets Sidebar to all pages first and configured links at the bottom', async () => {
- vi.mocked(localStorage.getItem).mockImplementation((key) => {
- if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers', 'stats']);
- if (key === SIDEBAR_ORDER_KEY) return JSON.stringify(['ext-7', 'settings', 'printers']);
- return null;
- });
- server.use(
- http.get('/api/v1/external-links/', () =>
- HttpResponse.json([
- {
- id: 7,
- name: 'Docs',
- url: 'https://docs.example.test',
- icon: 'Link',
- open_in_new_tab: true,
- custom_icon: null,
- sort_order: 0,
- created_at: '2026-01-01T00:00:00Z',
- updated_at: '2026-01-01T00:00:00Z',
- },
- ]),
- ),
- );
- const user = userEvent.setup();
- render(<SettingsPage />);
- const heading = await screen.findByRole('heading', { name: 'Sidebar' });
- const card = heading.closest('#card-sidebar-links');
- expect(card).not.toBeNull();
- await screen.findByText('Docs');
- vi.mocked(localStorage.setItem).mockClear();
- await user.click(within(card as HTMLElement).getByRole('button', { name: /reset/i }));
- expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
- expect(localStorage.setItem).toHaveBeenCalledWith(
- SIDEBAR_ORDER_KEY,
- JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'finance', 'notifications', 'settings', 'ext-7']),
- );
- const settingsRow = screen.getAllByText('Settings')
- .find(element => element.closest('[draggable="true"]'))
- ?.closest('[draggable="true"]');
- const docsRow = screen.getByText('Docs').closest('[draggable="true"]');
- expect(settingsRow).not.toBeNull();
- expect(docsRow).not.toBeNull();
- expect(settingsRow!.compareDocumentPosition(docsRow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
- expect(screen.queryByText('Hidden from sidebar')).not.toBeInTheDocument();
- });
- it('sets the current Sidebar order as the backend default for settings admins', async () => {
- let defaultSidebarOrderPayload: string | null = null;
- vi.mocked(localStorage.getItem).mockImplementation((key) => {
- if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['stats']);
- return null;
- });
- server.use(
- http.get('/api/v1/auth/status', () =>
- HttpResponse.json({ auth_enabled: true, requires_setup: false }),
- ),
- http.get('/api/v1/auth/me', () =>
- HttpResponse.json({
- id: 1,
- username: 'admin',
- role: 'admin',
- is_active: true,
- is_admin: false,
- groups: [{ id: 1, name: 'Administrators' }],
- permissions: ['settings:update'],
- created_at: '2026-01-01T00:00:00Z',
- }),
- ),
- http.get('/api/v1/settings/', () =>
- HttpResponse.json({ ...mockSettings, default_sidebar_order: '' }),
- ),
- http.put('/api/v1/settings/', async ({ request }) => {
- const body = await request.json() as { default_sidebar_order?: string };
- defaultSidebarOrderPayload = body.default_sidebar_order ?? null;
- return HttpResponse.json({ ...mockSettings, ...body });
- }),
- );
- setAuthToken('test-token');
- const user = userEvent.setup();
- render(<SettingsPage />);
- const heading = await screen.findByRole('heading', { name: 'Sidebar' });
- const card = heading.closest('#card-sidebar-links');
- expect(card).not.toBeNull();
- await user.click(within(card as HTMLElement).getByRole('switch', { name: 'Set Default' }));
- await waitFor(() => {
- expect(defaultSidebarOrderPayload).not.toBeNull();
- });
- expect(JSON.parse(defaultSidebarOrderPayload!)).toEqual({
- order: [
- 'printers',
- 'inventory',
- 'archives',
- 'queue',
- 'projects',
- 'files',
- 'makerworld',
- 'profiles',
- 'maintenance',
- 'stats',
- 'finance',
- 'notifications',
- 'settings',
- ],
- hiddenSystemItemIds: ['stats'],
- });
- });
- });
- describe('update CTA per deployment shape', () => {
- // The update card branches on the deployment shape returned by
- // /updates/check. Each branch is mutually exclusive — verify the right
- // one wins so HA addon users never see the docker-compose snippet
- // (which they can't run from inside an HA addon container) and Docker
- // users never see the in-app Install button (which would no-op).
- const renderWithUpdateCheck = async (
- checkBody: Record<string, unknown>,
- settingsOverrides: Record<string, unknown> = {},
- ) => {
- server.use(
- http.get('/api/v1/settings/', () =>
- HttpResponse.json({ ...mockSettings, check_updates: true, ...settingsOverrides }),
- ),
- http.get('/api/v1/updates/check', () => HttpResponse.json(checkBody)),
- );
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByText('Updates')).toBeInTheDocument();
- });
- };
- it('shows the HA Supervisor message when running as an HA addon', async () => {
- await renderWithUpdateCheck({
- update_available: true,
- current_version: '0.2.4',
- latest_version: '0.2.5',
- release_name: '0.2.5',
- release_notes: '',
- release_url: 'https://example.invalid/r',
- published_at: '2099-01-01T00:00:00Z',
- is_docker: true,
- is_ha_addon: true,
- update_method: 'ha_addon',
- });
- await waitFor(() => {
- expect(
- screen.getByText(/Home Assistant Supervisor/i),
- ).toBeInTheDocument();
- });
- // Docker hint must NOT render — HA branch wins.
- expect(screen.queryByText('docker compose pull && docker compose up -d')).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
- });
- it('shows the docker-compose snippet for Docker (non-HA) deployments', async () => {
- await renderWithUpdateCheck({
- update_available: true,
- current_version: '0.2.4',
- latest_version: '0.2.5',
- release_name: '0.2.5',
- release_notes: '',
- release_url: 'https://example.invalid/r',
- published_at: '2099-01-01T00:00:00Z',
- is_docker: true,
- is_ha_addon: false,
- update_method: 'docker',
- });
- await waitFor(() => {
- expect(screen.getByText('docker compose pull && docker compose up -d')).toBeInTheDocument();
- });
- expect(screen.queryByText(/Home Assistant Supervisor/i)).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
- });
- // #2664: the bare command only works if the user is already standing in
- // the directory holding their compose file, which is exactly the thing
- // they came to the page not knowing.
- const DOCKER_CHECK = {
- update_available: true,
- current_version: '0.2.4',
- latest_version: '0.2.5',
- release_name: '0.2.5',
- release_notes: '',
- release_url: 'https://example.invalid/r',
- published_at: '2099-01-01T00:00:00Z',
- is_docker: true,
- is_ha_addon: false,
- update_method: 'docker',
- };
- it('prefixes the command with cd when the backend detected a compose directory', async () => {
- await renderWithUpdateCheck({ ...DOCKER_CHECK, compose_dir_detected: '/opt/bambuddy' });
- await waitFor(() => {
- expect(
- screen.getByText('cd /opt/bambuddy && docker compose pull && docker compose up -d'),
- ).toBeInTheDocument();
- });
- });
- it('prefers the saved directory over the detected one', async () => {
- await renderWithUpdateCheck(
- { ...DOCKER_CHECK, compose_dir_detected: '/opt/guessed' },
- { docker_compose_dir: '/srv/stacks/bambuddy' },
- );
- await waitFor(() => {
- expect(
- screen.getByText('cd /srv/stacks/bambuddy && docker compose pull && docker compose up -d'),
- ).toBeInTheDocument();
- });
- expect(screen.queryByText(/\/opt\/guessed/)).not.toBeInTheDocument();
- });
- it('quotes a directory containing a space so the cd does not split', async () => {
- await renderWithUpdateCheck(DOCKER_CHECK, { docker_compose_dir: '/srv/bambu buddy' });
- await waitFor(() => {
- expect(
- screen.getByText('cd "/srv/bambu buddy" && docker compose pull && docker compose up -d'),
- ).toBeInTheDocument();
- });
- });
- it('copies the full command including the cd', async () => {
- const writeText = vi.fn().mockResolvedValue(undefined);
- Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
- Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true });
- await renderWithUpdateCheck({ ...DOCKER_CHECK, compose_dir_detected: '/opt/bambuddy' });
- const copy = await screen.findByRole('button', { name: /copy update command/i });
- await userEvent.click(copy);
- expect(writeText).toHaveBeenCalledWith(
- 'cd /opt/bambuddy && docker compose pull && docker compose up -d',
- );
- });
- it('offers an editable compose directory field seeded with the detected path', async () => {
- await renderWithUpdateCheck({ ...DOCKER_CHECK, compose_dir_detected: '/opt/bambuddy' });
- const field = await screen.findByPlaceholderText('/opt/bambuddy');
- // Placeholder, not value — the detected path is a guess the user has
- // not accepted, so saving must not silently adopt it.
- expect(field).toHaveValue('');
- });
- it('shows the installer-download link for Windows installer installs', async () => {
- const downloadUrl =
- 'https://github.com/maziggy/bambuddy/releases/download/v0.2.5/bambuddy-0.2.5-windows-x64-setup.exe';
- await renderWithUpdateCheck({
- update_available: true,
- current_version: '0.2.4',
- latest_version: '0.2.5',
- release_name: '0.2.5',
- release_notes: '',
- release_url: 'https://github.com/maziggy/bambuddy/releases/tag/v0.2.5',
- published_at: '2099-01-01T00:00:00Z',
- is_docker: false,
- is_ha_addon: false,
- is_windows_installer: true,
- update_method: 'windows_installer',
- installer_download_url: downloadUrl,
- });
- const link = await screen.findByRole('link', { name: /download installer for v0\.2\.5/i });
- expect(link).toHaveAttribute('href', downloadUrl);
- expect(link).toHaveAttribute('target', '_blank');
- expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
- // The in-app update button must NOT render — the git-fetch path can't
- // work from an installer payload.
- expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
- expect(screen.queryByText(/Home Assistant Supervisor/i)).not.toBeInTheDocument();
- expect(screen.queryByText('docker compose pull && docker compose up -d')).not.toBeInTheDocument();
- });
- });
- describe('tabs navigation', () => {
- it('can switch to Network tab', async () => {
- const user = userEvent.setup();
- render(<SettingsPage />);
- // Wait for settings to load first
- await waitFor(() => {
- expect(screen.getByText('Date Format')).toBeInTheDocument();
- });
- await user.click(screen.getByText('Network'));
- await waitFor(() => {
- // Network tab contains MQTT Publishing section
- expect(screen.getByText('MQTT Publishing')).toBeInTheDocument();
- });
- });
- it('can switch to Smart Plugs tab', async () => {
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
- });
- await user.click(screen.getByText('Smart Plugs'));
- await waitFor(() => {
- expect(screen.getByText('Add Smart Plug')).toBeInTheDocument();
- });
- });
- it('can switch to Notifications tab', async () => {
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
- });
- // Click the tab button (not the mobile dropdown option)
- const notificationButtons = screen.getAllByText('Notifications');
- const tabButton = notificationButtons.find(el => el.tagName === 'BUTTON') || notificationButtons[0];
- await user.click(tabButton);
- await waitFor(() => {
- expect(screen.getByText('Add Provider')).toBeInTheDocument();
- });
- });
- it('can switch to Filament tab', async () => {
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
- });
- await user.click(screen.getAllByText('Filament')[0]);
- await waitFor(() => {
- expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();
- });
- });
- // #2770: an AMS reads 15-20% while its own heater runs, so a drying
- // threshold set below that can never be met and auto-drying ends one
- // cycle only to arm the next. The default of 60 must stay quiet.
- const openFilamentTab = async (humidityFair: number) => {
- server.use(
- http.get('/api/v1/settings/', () =>
- HttpResponse.json({ ...mockSettings, ams_humidity_fair: humidityFair })
- )
- );
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
- });
- await user.click(screen.getAllByText('Filament')[0]);
- await waitFor(() => {
- expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();
- });
- };
- it('warns when the humidity threshold is below what a drying AMS reports', async () => {
- await openFilamentTab(14);
- expect(await screen.findByText(/Auto-drying cannot reach this value/)).toBeInTheDocument();
- });
- it('stays quiet for a humidity threshold auto-drying can actually reach', async () => {
- await openFilamentTab(60);
- expect(screen.queryByText(/Auto-drying cannot reach this value/)).not.toBeInTheDocument();
- });
- // #2905: the alarm threshold is a separate value from the Fair display
- // band, and unset means "use Fair" rather than "never alarm". Everything
- // below turns on telling those two apart.
- const openFilamentTabWith = async (overrides: Record<string, unknown>) => {
- server.use(
- http.get('/api/v1/settings/', () => HttpResponse.json({ ...mockSettings, ...overrides }))
- );
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
- });
- await user.click(screen.getAllByText('Filament')[0]);
- await waitFor(() => {
- expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();
- });
- };
- const alarmInput = () =>
- within(screen.getByText('Alarm above').parentElement!).getByRole('spinbutton');
- it('shows the fair threshold as the placeholder while the alarm threshold is unset', async () => {
- // The fallback has to be visible in the field itself. Blank with no hint
- // reads as "no alarm", which is the opposite of what unset does.
- await openFilamentTabWith({ ams_temp_fair: 38, ams_temp_alarm: null });
- const input = alarmInput();
- expect(input).toHaveValue(null);
- expect(input).toHaveAttribute('placeholder', '38');
- });
- it('sends the typed alarm threshold on save', async () => {
- let saved: Record<string, unknown> | null = null;
- server.use(
- http.put('/api/v1/settings/', async ({ request }) => {
- saved = (await request.json()) as Record<string, unknown>;
- return HttpResponse.json({ ...mockSettings, ...saved });
- })
- );
- await openFilamentTabWith({ ams_temp_alarm: null });
- // The page suppresses auto-save for 100ms after the settings load.
- await new Promise((resolve) => setTimeout(resolve, 200));
- await userEvent.type(alarmInput(), '45');
- // Assert on the value rather than merely on a save having happened: two
- // keystrokes can straddle the 500ms debounce on a slow runner, and the
- // first save would then carry 4. Waiting for 45 rides that out.
- await waitFor(() => {
- expect(saved?.ams_temp_alarm).toBe(45);
- }, { timeout: 3000 });
- });
- it('sends null when the alarm threshold is cleared', async () => {
- // The one path the backend tests cannot reach on their own: clearing has
- // to send an explicit null, not omit the key, or the old threshold stays.
- let saved: Record<string, unknown> | null = null;
- server.use(
- http.put('/api/v1/settings/', async ({ request }) => {
- saved = (await request.json()) as Record<string, unknown>;
- return HttpResponse.json({ ...mockSettings, ...saved });
- })
- );
- await openFilamentTabWith({ ams_temp_alarm: 45 });
- await new Promise((resolve) => setTimeout(resolve, 200));
- await userEvent.clear(alarmInput());
- await waitFor(() => {
- expect(saved).not.toBeNull();
- expect(saved!.ams_temp_alarm).toBeNull();
- }, { timeout: 3000 });
- });
- it('warns that a non-positive alarm threshold is ignored', async () => {
- // The backend refuses <= 0 and falls back to Fair. Saying so beats a min=
- // attribute the browser only enforces on submit.
- await openFilamentTabWith({ ams_temp_alarm: 0 });
- expect(await screen.findByText(/A threshold of 0 or less is ignored/)).toBeInTheDocument();
- });
- it('stays quiet for an alarm threshold the backend will honour', async () => {
- await openFilamentTabWith({ ams_temp_alarm: 45 });
- expect(screen.queryByText(/A threshold of 0 or less is ignored/)).not.toBeInTheDocument();
- });
- });
- describe('Workflow tab', () => {
- it('can switch to Workflow tab', async () => {
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByText('Workflow')).toBeInTheDocument();
- });
- await user.click(screen.getByText('Workflow'));
- await waitFor(() => {
- expect(screen.getByText('Staggered Start')).toBeInTheDocument();
- });
- });
- it('shows stagger settings on Workflow tab', async () => {
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByText('Workflow')).toBeInTheDocument();
- });
- await user.click(screen.getByText('Workflow'));
- await waitFor(() => {
- expect(screen.getByText('Staggered Start')).toBeInTheDocument();
- expect(screen.getByText('Group size')).toBeInTheDocument();
- expect(screen.getByText('Interval (minutes)')).toBeInTheDocument();
- });
- });
- it('shows auto-drying settings on Workflow tab', async () => {
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByText('Workflow')).toBeInTheDocument();
- });
- await user.click(screen.getByText('Workflow'));
- await waitFor(() => {
- expect(screen.getByText('Queue Auto-Drying')).toBeInTheDocument();
- });
- });
- it('shows per-filament humidity threshold editor on Workflow tab (#1605)', async () => {
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByText('Workflow')).toBeInTheDocument();
- });
- await user.click(screen.getByText('Workflow'));
- await waitFor(() => {
- expect(screen.getByText('Humidity Thresholds')).toBeInTheDocument();
- // Default row is unique to the humidity editor (drying presets has no
- // default row), so we can pin it without disambiguating from the
- // adjacent drying-presets table that also lists PLA/ASA/etc.
- expect(screen.getByText('Default (unknown types)')).toBeInTheDocument();
- // Filament rows render in both tables — assert by count instead of
- // a single getByText. 8 default filaments × 2 tables = 16 PLAs etc.
- expect(screen.getAllByText('PLA').length).toBeGreaterThanOrEqual(2);
- expect(screen.getAllByText('ASA').length).toBeGreaterThanOrEqual(2);
- });
- });
- it('shows default print options on Workflow tab', async () => {
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByText('Workflow')).toBeInTheDocument();
- });
- await user.click(screen.getByText('Workflow'));
- await waitFor(() => {
- expect(screen.getByText('Default Print Options')).toBeInTheDocument();
- expect(screen.getByText('Bed Levelling')).toBeInTheDocument();
- expect(screen.getByText('Flow Calibration')).toBeInTheDocument();
- expect(screen.getByText('Vibration Calibration')).toBeInTheDocument();
- expect(screen.getByText('First Layer Inspection')).toBeInTheDocument();
- expect(screen.getByText('Timelapse')).toBeInTheDocument();
- });
- });
- it('shows default print options description', async () => {
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByText('Workflow')).toBeInTheDocument();
- });
- await user.click(screen.getByText('Workflow'));
- await waitFor(() => {
- expect(screen.getByText(/overridden per print in the print dialog/)).toBeInTheDocument();
- });
- });
- });
- describe('sustained-minutes input drafts while typing (#2518)', () => {
- const openWorkflowTab = async (user: ReturnType<typeof userEvent.setup>) => {
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByText('Workflow')).toBeInTheDocument();
- });
- await user.click(screen.getByText('Workflow'));
- await waitFor(() => {
- expect(screen.getByText('Queue Auto-Drying')).toBeInTheDocument();
- });
- // 15 is unique to this input in the mock settings below
- return screen.getByDisplayValue('15') as HTMLInputElement;
- };
- beforeEach(() => {
- server.use(
- http.get('/api/v1/settings/', () => {
- return HttpResponse.json({
- ...mockSettings,
- ambient_drying_enabled: true,
- ambient_drying_sustained_minutes: 15,
- });
- })
- );
- });
- it('seeds a new sustained wait at 15 minutes when enabled from off', async () => {
- const user = userEvent.setup();
- let saved: Record<string, unknown> | null = null;
- server.use(
- http.get('/api/v1/settings/', () =>
- HttpResponse.json({
- ...mockSettings,
- ambient_drying_enabled: true,
- ambient_drying_sustained_minutes: 0,
- })
- ),
- http.put('/api/v1/settings/', async ({ request }) => {
- saved = (await request.json()) as Record<string, unknown>;
- return HttpResponse.json({ ...mockSettings, ...saved });
- })
- );
- render(<SettingsPage />);
- await user.click(await screen.findByText('Workflow'));
- const label = await screen.findByText('Require sustained humidity');
- const row = label.closest('div')!.parentElement!;
- const toggle = within(row).getByRole('checkbox');
- expect(toggle).not.toBeChecked();
- // Wait out the page's initial-load save suppression before toggling.
- await new Promise((resolve) => setTimeout(resolve, 200));
- await user.click(toggle);
- expect(toggle).toBeChecked();
- expect(screen.getByDisplayValue('15')).toBeInTheDocument();
- await waitFor(() => {
- expect(saved).not.toBeNull();
- }, { timeout: 3000 });
- expect(saved!.ambient_drying_sustained_minutes).toBe(15);
- });
- it('clearing the field does not snap it to a value mid-edit', async () => {
- const user = userEvent.setup();
- const input = await openWorkflowTab(user);
- await user.clear(input);
- // The old per-keystroke clamp rewrote '' to 10 immediately.
- expect(input.value).toBe('');
- });
- it('intermediate below-minimum digits are not rewritten while typing', async () => {
- const user = userEvent.setup();
- const input = await openWorkflowTab(user);
- await user.clear(input);
- await user.type(input, '2');
- // The old clamp turned the '2' (on the way to '25') into 5.
- expect(input.value).toBe('2');
- await user.type(input, '5');
- expect(input.value).toBe('25');
- await user.tab();
- await waitFor(() => {
- expect(input.value).toBe('25');
- });
- });
- it('blur with an empty field reverts to the saved value instead of inventing one', async () => {
- const user = userEvent.setup();
- const input = await openWorkflowTab(user);
- await user.clear(input);
- await user.tab();
- await waitFor(() => {
- expect(input.value).toBe('15');
- });
- });
- it('still clamps an out-of-range value on blur', async () => {
- const user = userEvent.setup();
- const input = await openWorkflowTab(user);
- await user.clear(input);
- await user.type(input, '500');
- await user.tab();
- await waitFor(() => {
- expect(input.value).toBe('240');
- });
- });
- });
- describe('API Keys tab', () => {
- it('can switch to API Keys tab', async () => {
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByText('API Keys')).toBeInTheDocument();
- });
- await user.click(screen.getByText('API Keys'));
- await waitFor(() => {
- // Button text is "Create Key"
- expect(screen.getByText('Create Key')).toBeInTheDocument();
- });
- });
- });
- describe('SpoolBuddy tab badge', () => {
- const baseDevice = {
- id: 1,
- device_id: 'sb-0001',
- hostname: 'sb-kitchen',
- ip_address: '10.0.0.1',
- backend_url: null,
- firmware_version: '1.0.0',
- has_nfc: true,
- has_scale: true,
- tare_offset: 0,
- calibration_factor: 1.0,
- nfc_reader_type: null,
- nfc_connection: null,
- display_brightness: 100,
- display_blank_timeout: 0,
- has_backlight: false,
- last_calibrated_at: null,
- last_seen: new Date().toISOString(),
- pending_command: null,
- nfc_ok: true,
- scale_ok: true,
- uptime_s: 100,
- update_status: null,
- update_message: null,
- system_stats: null,
- online: true,
- created_at: '2024-01-01T00:00:00Z',
- updated_at: '2024-01-01T00:00:00Z',
- };
- it('shows device count and green bullet when at least one device is online', async () => {
- server.use(
- http.get('/api/v1/spoolbuddy/devices', () => {
- return HttpResponse.json([
- { ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'sb-kitchen', online: true },
- { ...baseDevice, id: 2, device_id: 'sb-0002', hostname: 'sb-ghost', online: false },
- ]);
- })
- );
- render(<SettingsPage />);
- // Find the tab button (not the header) — it's the <button> containing the SpoolBuddy text
- const tabButton = await waitFor(() => {
- const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));
- expect(buttons.length).toBeGreaterThan(0);
- return buttons[0];
- });
- // Count pill rendered
- await waitFor(() => {
- expect(tabButton.textContent).toContain('2');
- });
- // Green status bullet (at least one device online)
- await waitFor(() => {
- expect(tabButton.querySelector('.bg-green-400')).not.toBeNull();
- });
- });
- it('shows gray bullet when all devices are offline', async () => {
- server.use(
- http.get('/api/v1/spoolbuddy/devices', () => {
- return HttpResponse.json([{ ...baseDevice, online: false }]);
- })
- );
- render(<SettingsPage />);
- const tabButton = await waitFor(() => {
- const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));
- expect(buttons.length).toBeGreaterThan(0);
- return buttons[0];
- });
- await waitFor(() => {
- expect(tabButton.querySelector('.bg-gray-500')).not.toBeNull();
- expect(tabButton.querySelector('.bg-green-400')).toBeNull();
- });
- });
- it('hides the count pill when no devices are registered', async () => {
- server.use(
- http.get('/api/v1/spoolbuddy/devices', () => HttpResponse.json([]))
- );
- render(<SettingsPage />);
- const tabButton = await waitFor(() => {
- const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));
- expect(buttons.length).toBeGreaterThan(0);
- return buttons[0];
- });
- // The only numeric content should NOT be present — tab label only
- await waitFor(() => {
- expect(tabButton.textContent).toBe('SpoolBuddy');
- });
- });
- });
- describe('API Keys tab — delete flow', () => {
- // Without setQueryData on success the deleted row stayed visible until a
- // manual reload — invalidateQueries didn't reliably trigger a UI swap on
- // every browser. Pin the synchronous-removal contract here.
- it('removes a deleted key from the list without a page reload', async () => {
- const initialKeys = [
- {
- id: 42,
- name: 'CI deploy key',
- key_prefix: 'bk_abcd1234',
- can_queue: true,
- can_control_printer: false,
- can_read_status: true,
- printer_ids: null,
- enabled: true,
- last_used: null,
- created_at: '2026-01-01T00:00:00Z',
- expires_at: null,
- },
- ];
- let deleteCallCount = 0;
- server.use(
- http.get('/api/v1/api-keys/', () => HttpResponse.json(initialKeys)),
- http.delete('/api/v1/api-keys/:id', ({ params }) => {
- deleteCallCount += 1;
- expect(params.id).toBe('42');
- return HttpResponse.json({ message: 'API key deleted' });
- })
- );
- const user = userEvent.setup();
- render(<SettingsPage />);
- // Switch to API Keys tab. Both desktop tab + mobile dropdown render
- // the label, so just grab the button form.
- await waitFor(() => {
- expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
- });
- const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
- expect(tabButton).toBeDefined();
- await user.click(tabButton!);
- // Key is listed
- await waitFor(() => {
- expect(screen.getByText('CI deploy key')).toBeInTheDocument();
- });
- // Click the trash button on the row
- const cards = screen.getByText('CI deploy key').closest('.flex.items-center.justify-between');
- expect(cards).not.toBeNull();
- const trashButton = cards!.querySelectorAll('button');
- await user.click(trashButton[trashButton.length - 1]);
- // Confirm the deletion in the modal
- const confirmButton = await screen.findByRole('button', { name: /delete/i });
- await user.click(confirmButton);
- // The deleted key disappears from the list immediately — no manual
- // reload required. setQueryData drops it before any refetch could fire.
- await waitFor(() => {
- expect(screen.queryByText('CI deploy key')).not.toBeInTheDocument();
- });
- expect(deleteCallCount).toBe(1);
- });
- });
- describe('API Keys tab — #1182 cloud access + ownership UI', () => {
- // The list now exposes two new bits of information per row:
- // - "Cloud" badge when can_access_cloud=true
- // - "Legacy" badge when user_id IS NULL (created before per-user ownership)
- // These tell the operator at a glance which keys can read /cloud/* data
- // and which keys need to be recreated to gain that capability.
- it('renders the Cloud badge for keys with can_access_cloud=true and the Legacy badge for ownerless keys', async () => {
- const keys = [
- {
- id: 1,
- name: 'cloud-reader',
- key_prefix: 'bk_cloud123',
- user_id: 7,
- can_queue: false,
- can_control_printer: false,
- can_read_status: true,
- can_access_cloud: true,
- printer_ids: null,
- enabled: true,
- last_used: null,
- created_at: '2026-04-30T00:00:00Z',
- expires_at: null,
- },
- {
- id: 2,
- name: 'legacy-key',
- key_prefix: 'bk_legacy01',
- user_id: null,
- can_queue: true,
- can_control_printer: false,
- can_read_status: true,
- can_access_cloud: false,
- printer_ids: null,
- enabled: true,
- last_used: null,
- created_at: '2025-01-01T00:00:00Z',
- expires_at: null,
- },
- ];
- server.use(http.get('/api/v1/api-keys/', () => HttpResponse.json(keys)));
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
- });
- const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
- await user.click(tabButton!);
- await waitFor(() => {
- expect(screen.getByText('cloud-reader')).toBeInTheDocument();
- expect(screen.getByText('legacy-key')).toBeInTheDocument();
- });
- // Cloud-enabled key gets the Cloud badge but NOT the Legacy badge.
- const cloudRow = screen.getByText('cloud-reader').closest('.flex.items-center.justify-between');
- expect(cloudRow).not.toBeNull();
- expect(cloudRow!.textContent).toContain('Cloud');
- expect(cloudRow!.textContent).not.toContain('Legacy');
- // Ownerless key gets Legacy but NOT Cloud (can_access_cloud=false).
- const legacyRow = screen.getByText('legacy-key').closest('.flex.items-center.justify-between');
- expect(legacyRow).not.toBeNull();
- expect(legacyRow!.textContent).toContain('Legacy');
- // Strip the Cloud-flag check by limiting to badge area — the
- // "Allow cloud access" text from the create form isn't visible here.
- expect(legacyRow!.querySelector('.bg-purple-500\\/20')).toBeNull();
- });
- it('passes can_access_cloud through to the create call when the toggle is checked', async () => {
- let posted: { name?: string; can_access_cloud?: boolean } | null = null;
- server.use(
- http.get('/api/v1/api-keys/', () => HttpResponse.json([])),
- http.post('/api/v1/api-keys/', async ({ request }) => {
- posted = (await request.json()) as { name?: string; can_access_cloud?: boolean };
- return HttpResponse.json({
- id: 99,
- key: 'bk_returnedkey',
- name: posted.name,
- key_prefix: 'bk_returne',
- user_id: 1,
- can_queue: true,
- can_control_printer: false,
- can_read_status: true,
- can_access_cloud: posted.can_access_cloud ?? false,
- printer_ids: null,
- enabled: true,
- last_used: null,
- created_at: '2026-05-01T00:00:00Z',
- expires_at: null,
- });
- })
- );
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
- });
- const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
- await user.click(tabButton!);
- // Open the create form. With an empty key list the empty-state card
- // shows "Create Your First Key" — click that to open the form.
- const openButton = await screen.findByRole('button', { name: /Create Your First Key/i });
- await user.click(openButton);
- // Tick the new "Allow cloud access" checkbox. The label wraps the
- // input AND a sibling description div, so getByLabelText doesn't
- // resolve via implicit-label traversal — locate via text + closest
- // label, then grab the checkbox from the same scope.
- const cloudLabelText = await screen.findByText(/Allow cloud access/i);
- const cloudLabel = cloudLabelText.closest('label');
- expect(cloudLabel).not.toBeNull();
- const cloudCheckbox = cloudLabel!.querySelector('input[type="checkbox"]') as HTMLInputElement;
- expect(cloudCheckbox).not.toBeNull();
- await user.click(cloudCheckbox);
- // Submit. Two "Create Key" buttons exist once the form is open (header
- // CTA + form footer); the form-footer one is the actual submit and
- // calls the mutation — find it by walking up from the cloud checkbox
- // we just clicked, since both share the same form container.
- const submitButtons = screen.getAllByRole('button', { name: /^Create Key$/i });
- // Footer submit is the one inside the same form section as the
- // checkbox. The header CTA is in a separate flex row.
- const formSubmit = submitButtons.find(
- (b) => b.closest('div')?.contains(cloudCheckbox) || cloudLabel?.parentElement?.parentElement?.contains(b),
- );
- await user.click(formSubmit ?? submitButtons[submitButtons.length - 1]);
- await waitFor(() => {
- expect(posted).not.toBeNull();
- expect(posted!.can_access_cloud).toBe(true);
- });
- });
- });
- describe('API Keys tab — #1356 energy-cost write scope', () => {
- /**
- * The narrowly-scoped settings-write toggle. We pin two contracts here:
- *
- * 1. The "Energy" badge renders for keys that have can_update_energy_cost=true.
- * Without a visible signal, an operator can't tell which key in their
- * list is the one their HA automation depends on.
- * 2. The create form sends can_update_energy_cost=true to the backend
- * when the toggle is checked. The whole point of #1356 is that the
- * flag must actually be persisted — a UI that drops it silently
- * would put us right back where the bug started.
- */
- it('renders the Energy badge for keys with can_update_energy_cost=true', async () => {
- const keys = [
- {
- id: 1,
- name: 'tariff-pusher',
- key_prefix: 'bk_tariff01',
- user_id: 7,
- can_queue: false,
- can_control_printer: false,
- can_read_status: true,
- can_access_cloud: false,
- can_update_energy_cost: true,
- printer_ids: null,
- enabled: true,
- last_used: null,
- created_at: '2026-05-15T00:00:00Z',
- expires_at: null,
- },
- ];
- server.use(http.get('/api/v1/api-keys/', () => HttpResponse.json(keys)));
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
- });
- const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
- await user.click(tabButton!);
- await waitFor(() => {
- expect(screen.getByText('tariff-pusher')).toBeInTheDocument();
- });
- const row = screen.getByText('tariff-pusher').closest('.flex.items-center.justify-between');
- expect(row).not.toBeNull();
- expect(row!.textContent).toContain('Energy');
- });
- it('passes can_update_energy_cost through to the create call when the toggle is checked', async () => {
- let posted: { name?: string; can_update_energy_cost?: boolean } | null = null;
- server.use(
- http.get('/api/v1/api-keys/', () => HttpResponse.json([])),
- http.post('/api/v1/api-keys/', async ({ request }) => {
- posted = (await request.json()) as { name?: string; can_update_energy_cost?: boolean };
- return HttpResponse.json({
- id: 99,
- key: 'bk_returnedkey',
- name: posted.name,
- key_prefix: 'bk_returne',
- user_id: 1,
- can_queue: true,
- can_control_printer: false,
- can_read_status: true,
- can_access_cloud: false,
- can_update_energy_cost: posted.can_update_energy_cost ?? false,
- printer_ids: null,
- enabled: true,
- last_used: null,
- created_at: '2026-05-15T00:00:00Z',
- expires_at: null,
- });
- })
- );
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
- });
- const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
- await user.click(tabButton!);
- const openButton = await screen.findByRole('button', { name: /Create Your First Key/i });
- await user.click(openButton);
- const energyLabelText = await screen.findByText(/Update electricity price/i);
- const energyLabel = energyLabelText.closest('label');
- expect(energyLabel).not.toBeNull();
- const energyCheckbox = energyLabel!.querySelector('input[type="checkbox"]') as HTMLInputElement;
- expect(energyCheckbox).not.toBeNull();
- await user.click(energyCheckbox);
- const submitButtons = screen.getAllByRole('button', { name: /^Create Key$/i });
- const formSubmit = submitButtons.find(
- (b) => b.closest('div')?.contains(energyCheckbox) || energyLabel?.parentElement?.parentElement?.contains(b),
- );
- await user.click(formSubmit ?? submitButtons[submitButtons.length - 1]);
- await waitFor(() => {
- expect(posted).not.toBeNull();
- expect(posted!.can_update_energy_cost).toBe(true);
- });
- });
- });
- describe('Camera tab and menu order', () => {
- const tabLabels = () => Array.from(document.querySelectorAll('nav button')).map((button) => button.textContent?.trim() ?? '');
- it('lists General first and the other tabs alphabetically', async () => {
- render(<SettingsPage />);
- await waitFor(() => expect(screen.getByRole('button', { name: 'Camera' })).toBeInTheDocument());
- const labels = tabLabels().map((label) => label.replace(/\d+$/, ''));
- expect(labels[0]).toBe('General');
- const rest = labels.slice(1);
- expect(rest).toEqual([...rest].sort((a, b) => a.localeCompare(b, 'en')));
- expect(rest).toContain('Camera');
- });
- it('holds external cameras, camera tokens and the streaming overlay', async () => {
- window.history.replaceState({}, '', '/?tab=camera');
- render(<SettingsPage />);
- await waitFor(() => expect(screen.getByText('External Cameras')).toBeInTheDocument());
- expect(document.getElementById('card-camera-tokens')).not.toBeNull();
- expect(document.getElementById('card-stream-overlay')).not.toBeNull();
- });
- it('no longer shows camera settings on General or API Keys', async () => {
- const user = userEvent.setup();
- render(<SettingsPage />);
- await waitFor(() => expect(screen.getByText('Default Printer')).toBeInTheDocument());
- expect(screen.queryByText('External Cameras')).toBeNull();
- await user.click(screen.getByRole('button', { name: /^API Keys/ }));
- await waitFor(() => expect(document.getElementById('card-createapi')).not.toBeNull());
- expect(document.getElementById('card-camera-tokens')).toBeNull();
- expect(document.getElementById('card-stream-overlay')).toBeNull();
- });
- const signInWith = (permissions: string[]) => {
- server.use(
- http.get('/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
- http.get('/api/v1/auth/me', () => HttpResponse.json({
- id: 2, username: 'viewer', role: 'user', is_active: true, is_admin: false,
- groups: [{ id: 2, name: 'Viewers' }], permissions, created_at: '2026-01-01T00:00:00Z',
- })),
- );
- setAuthToken('test-token');
- };
- it('hides API Keys from a user who may see nothing on it, but keeps Camera', async () => {
- signInWith(['settings:read', 'camera:view']);
- render(<SettingsPage />);
- await waitFor(() => expect(screen.getByRole('button', { name: 'Camera' })).toBeInTheDocument());
- expect(screen.queryByRole('button', { name: /^API Keys/ })).toBeNull();
- });
- it('sends an API Keys link to the Camera tab for a user who cannot see API Keys', async () => {
- signInWith(['settings:read', 'camera:view']);
- window.history.replaceState({}, '', '/?tab=apikeys');
- render(<SettingsPage />);
- await waitFor(() => expect(screen.getByText('External Cameras')).toBeInTheDocument());
- });
- it.each([['api_keys:read'], ['settings:update']])('shows API Keys to a user with %s', async (permission) => {
- signInWith(['settings:read', permission]);
- render(<SettingsPage />);
- await waitFor(() => expect(screen.getByRole('button', { name: /^API Keys/ })).toBeInTheDocument());
- });
- it.each(['#card-camera-tokens', '#card-stream-overlay'])('opens the Camera tab for an old API Keys link to %s', async (hash) => {
- window.history.replaceState({}, '', `/?tab=apikeys${hash}`);
- render(<SettingsPage />);
- await waitFor(() => expect(screen.getByText('External Cameras')).toBeInTheDocument());
- });
- });
- describe('external camera snapshot URL override (#1177)', () => {
- /**
- * The snapshot URL input only appears for stream camera types where the
- * MJPEG warm-up problem can occur (mjpeg / rtsp / usb). Pure HTTP
- * snapshot sources don't need an override since their stream URL is
- * already a single-frame endpoint.
- */
- const mjpegPrinter = {
- id: 7,
- name: 'go2rtc Cam',
- serial_number: 'TEST123',
- ip_address: '192.168.1.100',
- access_code: 'XXXX',
- model: 'P1S',
- location: null,
- nozzle_count: 1,
- is_active: true,
- auto_archive: true,
- external_camera_url: 'http://192.168.1.61:1984/api/stream.mjpeg?src=printer',
- external_camera_type: 'mjpeg',
- external_camera_enabled: true,
- external_camera_snapshot_url: null,
- camera_rotation: 0,
- plate_detection_enabled: false,
- created_at: '2026-01-01T00:00:00Z',
- updated_at: '2026-01-01T00:00:00Z',
- };
- // External cameras live on the Camera tab.
- beforeEach(() => {
- window.history.replaceState({}, '', '/?tab=camera');
- });
- it('renders the snapshot URL input when camera_type is mjpeg', async () => {
- server.use(
- http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
- );
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/)).toBeInTheDocument();
- });
- });
- it('hides the snapshot URL input when camera_type is snapshot (already a single-frame source)', async () => {
- server.use(
- http.get('/api/v1/printers/', () =>
- HttpResponse.json([{ ...mjpegPrinter, external_camera_type: 'snapshot' }]),
- ),
- );
- render(<SettingsPage />);
- // Wait for the live-stream URL placeholder to render so we know the
- // camera section finished mounting before asserting absence of the
- // snapshot input below.
- await waitFor(() => {
- expect(screen.getByPlaceholderText(/Camera URL/i)).toBeInTheDocument();
- });
- expect(screen.queryByPlaceholderText(/api\/frame\.jpeg\?src=printer/)).not.toBeInTheDocument();
- });
- it(
- 'PATCHes the printer with external_camera_snapshot_url when the user types into the input',
- async () => {
- let receivedBody: Record<string, unknown> | null = null;
- server.use(
- http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
- http.patch('/api/v1/printers/7', async ({ request }) => {
- receivedBody = (await request.json()) as Record<string, unknown>;
- return HttpResponse.json({ ...mjpegPrinter, ...receivedBody });
- }),
- );
- render(<SettingsPage />);
- const input = await waitFor(() =>
- screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/),
- );
- const user = userEvent.setup();
- await user.type(input, 'http://192.168.1.61:1984/api/frame.jpeg?src=printer');
- // Save is debounced by 800ms; assert the PATCH eventually fires with
- // the typed snapshot URL.
- await waitFor(
- () => {
- expect(receivedBody).not.toBeNull();
- expect(receivedBody!.external_camera_snapshot_url).toBe(
- 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
- );
- },
- { timeout: 5000 },
- );
- },
- // Per-test timeout raised to 15s — `user.type()` of a 49-char URL plus
- // the 800ms save debounce fits in 5s locally (~2.3s typical) but blows
- // past it on slow GitHub Actions runners (5000ms timeout was the failure
- // mode on PR #1263).
- 15_000,
- );
- });
- describe('theme mode buttons', () => {
- it('renders Dark, Light, and System buttons', async () => {
- render(<SettingsPage />);
- await waitFor(() => {
- expect(screen.getByRole('button', { name: 'Dark' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Light' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
- });
- });
- it('highlights the active mode button with green border', async () => {
- render(<SettingsPage />);
- const user = userEvent.setup();
- await waitFor(() => {
- expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
- });
- await user.click(screen.getByRole('button', { name: 'System' }));
- await waitFor(() => {
- const systemBtn = screen.getByRole('button', { name: 'System' });
- expect(systemBtn.className).toContain('border-bambu-green');
- });
- });
- it('clicking a theme button switches mode', async () => {
- localStorage.setItem('theme-mode', 'dark');
- render(<SettingsPage />);
- const user = userEvent.setup();
- await waitFor(() => {
- const darkBtn = screen.getByRole('button', { name: 'Dark' });
- expect(darkBtn.className).toContain('border-bambu-green');
- });
- const lightBtn = screen.getByRole('button', { name: 'Light' });
- await user.click(lightBtn);
- await waitFor(() => {
- expect(lightBtn.className).toContain('border-bambu-green');
- });
- });
- it('shows a toast when theme button is clicked', async () => {
- render(<SettingsPage />);
- const user = userEvent.setup();
- await waitFor(() => {
- expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
- });
- await user.click(screen.getByRole('button', { name: 'System' }));
- await waitFor(() => {
- expect(screen.getByText('Settings saved')).toBeInTheDocument();
- });
- });
- });
- // --------------------------------------------------------------------
- // Slicer Pipelines (#1425) — Workflow tab sub-tabs
- // --------------------------------------------------------------------
- describe('workflow sub-tabs (#1425)', () => {
- beforeEach(() => {
- // Endpoints the Pipelines panel calls (#1425).
- server.use(
- http.get('/api/v1/slicer-pipelines/', () => HttpResponse.json({ pipelines: [] })),
- http.get('/api/v1/slicer/presets', () =>
- HttpResponse.json({
- orca_cloud: { printer: [], process: [], filament: [] },
- cloud: { printer: [], process: [], filament: [] },
- local: { printer: [], process: [], filament: [] },
- standard: { printer: [], process: [], filament: [] },
- cloud_status: 'ok',
- orca_cloud_status: 'ok',
- }),
- ),
- );
- });
- it('renders Queue & Dispatch + Pipelines sub-tabs under Workflow', async () => {
- render(<SettingsPage />);
- const user = userEvent.setup();
- await waitFor(() => {
- // Workflow tab in the sidebar — exact match to avoid colliding with
- // "Print Queue" or "Queue Settings" labels elsewhere on the page.
- expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument();
- });
- await user.click(screen.getByRole('button', { name: 'Workflow' }));
- await waitFor(() => {
- expect(screen.getByRole('button', { name: /Queue & Dispatch/i })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /^Pipelines$/i })).toBeInTheDocument();
- });
- });
- it('clicking Pipelines sub-tab shows the empty-state hint and updates the URL', async () => {
- render(<SettingsPage />);
- const user = userEvent.setup();
- await waitFor(() => expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument());
- await user.click(screen.getByRole('button', { name: 'Workflow' }));
- await user.click(screen.getByRole('button', { name: /^Pipelines$/i }));
- await waitFor(() => {
- expect(screen.getByText(/No pipelines yet/i)).toBeInTheDocument();
- // Deep-link URL carries both ?tab=queue and ?sub=pipelines
- expect(window.location.search).toContain('tab=queue');
- expect(window.location.search).toContain('sub=pipelines');
- });
- });
- });
- // --------------------------------------------------------------------
- // Ask for the outcome of prints Bambuddy did not start (#1898)
- // --------------------------------------------------------------------
- describe('outcome prompt for external prints (#1898)', () => {
- const externalLabel = 'Also ask for prints started outside Bambuddy';
- const openDefaultPrintOptions = async () => {
- render(<SettingsPage />);
- const user = userEvent.setup();
- await waitFor(() => expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument());
- await user.click(screen.getByRole('button', { name: 'Workflow' }));
- return user;
- };
- it('offers the toggle alongside the per-job outcome default', async () => {
- await openDefaultPrintOptions();
- expect(await screen.findByText(externalLabel)).toBeInTheDocument();
- expect(screen.getByText('Ask for Outcome')).toBeInTheDocument();
- });
- it('shows it off when the backend has no value for it', async () => {
- // Default false: an install that never touches it keeps today's
- // behaviour, where only queued prints are asked about.
- await openDefaultPrintOptions();
- const label = await screen.findByText(externalLabel);
- const row = label.closest('div')!.parentElement!;
- expect(within(row).getByRole('checkbox')).not.toBeChecked();
- });
- it('sends the new value on save', async () => {
- let saved: Record<string, unknown> | null = null;
- server.use(
- http.put('/api/v1/settings/', async ({ request }) => {
- saved = (await request.json()) as Record<string, unknown>;
- return HttpResponse.json({ ...mockSettings, ...saved });
- })
- );
- const user = await openDefaultPrintOptions();
- const label = await screen.findByText(externalLabel);
- // The page suppresses auto-save for 100ms after the settings load.
- await new Promise((resolve) => setTimeout(resolve, 200));
- const row = label.closest('div')!.parentElement!;
- await user.click(within(row).getByRole('checkbox'));
- await waitFor(() => {
- expect(saved).not.toBeNull();
- }, { timeout: 3000 });
- expect(saved!.confirm_outcome_external_prints).toBe(true);
- });
- it('reflects a value the backend already has', async () => {
- server.use(
- http.get('/api/v1/settings/', () =>
- HttpResponse.json({ ...mockSettings, confirm_outcome_external_prints: true })
- )
- );
- await openDefaultPrintOptions();
- const label = await screen.findByText(externalLabel);
- const row = label.closest('div')!.parentElement!;
- expect(within(row).getByRole('checkbox')).toBeChecked();
- });
- });
- // --------------------------------------------------------------------
- // The plate-clear default answers outcome prompts on its own (#1898), so
- // its help text has to name that consequence — the farm case was a user
- // who had it on and could not work out why Telegram said "already used".
- // --------------------------------------------------------------------
- describe('plate-clear outcome default help text (#1898)', () => {
- it('warns that a later Telegram or link answer only shows the result', async () => {
- render(<SettingsPage />);
- const user = userEvent.setup();
- await waitFor(() => expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument());
- await user.click(screen.getByRole('button', { name: 'Workflow' }));
- const label = await screen.findByText('Count unanswered outcomes as good on plate release');
- const row = label.closest('div')!;
- expect(row).toHaveTextContent(
- /a Telegram or link answer after that only shows the recorded result/i,
- );
- });
- });
- });
- /**
- * Location sensor cards on Settings -> Sensors read the live readings
- * endpoint (reachable-aware) rather than the sensor row's last_state, so an
- * entity Home Assistant has stopped reporting shows "Unavailable" instead of
- * silently keeping its last colorized value on screen forever.
- */
- describe('SettingsPage — location sensor reachability', () => {
- beforeEach(() => {
- window.history.replaceState({}, '', '/');
- });
- const locationSensor = {
- id: 1,
- location_id: 7,
- name: 'Drybox 1 Temperature',
- entity_id: 'sensor.drybox_1_temperature',
- kind: 'numeric',
- device_class: 'temperature',
- unit: '°C',
- alert_state: null,
- alert_above: 30,
- alert_below: 20,
- notify_on_alert: false,
- show_on_card: true,
- sort_order: 0,
- last_state: '65.0',
- last_changed: null,
- last_checked: null,
- created_at: '',
- updated_at: '',
- };
- it('shows "Unavailable" for an unreachable sensor instead of its stale last value', async () => {
- server.use(
- http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json([locationSensor])),
- http.get('/api/v1/location-ha-sensors/by-location/7/readings', () =>
- HttpResponse.json([
- {
- id: 1,
- name: 'Drybox 1 Temperature',
- entity_id: 'sensor.drybox_1_temperature',
- kind: 'numeric',
- device_class: 'temperature',
- unit: '°C',
- state: null,
- value: null,
- alerting: false,
- reachable: false,
- alert_state: null,
- alert_above: 30,
- alert_below: 20,
- last_changed: null,
- },
- ])
- ),
- http.get('/api/v1/inventory/locations', () =>
- HttpResponse.json([{ id: 7, name: 'Drybox 1', identifier: null, spool_count: 0, created_at: '', updated_at: '' }])
- )
- );
- const user = userEvent.setup();
- render(<SettingsPage />);
- await user.click(await screen.findByText('Sensors'));
- await screen.findByText('sensor.drybox_1_temperature');
- expect(await screen.findByText('Unavailable')).toBeInTheDocument();
- expect(screen.queryByText('65.00 °C')).not.toBeInTheDocument();
- });
- it('shows the live value, not last_state, when the sensor is reachable', async () => {
- server.use(
- http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json([locationSensor])),
- http.get('/api/v1/location-ha-sensors/by-location/7/readings', () =>
- HttpResponse.json([
- {
- id: 1,
- name: 'Drybox 1 Temperature',
- entity_id: 'sensor.drybox_1_temperature',
- kind: 'numeric',
- device_class: 'temperature',
- unit: '°C',
- state: '24.5',
- value: 24.5,
- alerting: false,
- reachable: true,
- alert_state: null,
- alert_above: 30,
- alert_below: 20,
- last_changed: null,
- },
- ])
- ),
- http.get('/api/v1/inventory/locations', () =>
- HttpResponse.json([{ id: 7, name: 'Drybox 1', identifier: null, spool_count: 0, created_at: '', updated_at: '' }])
- )
- );
- const user = userEvent.setup();
- render(<SettingsPage />);
- await user.click(await screen.findByText('Sensors'));
- await screen.findByText('sensor.drybox_1_temperature');
- expect(await screen.findByText('24.50 °C')).toBeInTheDocument();
- expect(screen.queryByText('Unavailable')).not.toBeInTheDocument();
- });
- it('shows the entity id in the overview row, with the display name as its hover title', async () => {
- server.use(
- http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json([locationSensor])),
- http.get('/api/v1/location-ha-sensors/by-location/7/readings', () => HttpResponse.json([])),
- http.get('/api/v1/inventory/locations', () =>
- HttpResponse.json([{ id: 7, name: 'Drybox 1', identifier: null, spool_count: 0, created_at: '', updated_at: '' }])
- )
- );
- const user = userEvent.setup();
- render(<SettingsPage />);
- await user.click(await screen.findByText('Sensors'));
- const entityIdText = await screen.findByText('sensor.drybox_1_temperature');
- expect(entityIdText).toHaveAttribute('title', 'Drybox 1 Temperature');
- expect(screen.queryByText('Drybox 1 Temperature')).not.toBeInTheDocument();
- });
- it('refreshes the sensor list even when a bulk delete partially fails', async () => {
- let sensors = [
- { ...locationSensor, id: 1, name: 'Drybox 1 Temperature' },
- {
- ...locationSensor,
- id: 2,
- name: 'Drybox 1 Humidity',
- entity_id: 'sensor.drybox_1_humidity',
- device_class: 'humidity',
- unit: '%',
- },
- ];
- server.use(
- http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json(sensors)),
- http.get('/api/v1/location-ha-sensors/by-location/7/readings', () => HttpResponse.json([])),
- http.get('/api/v1/inventory/locations', () =>
- HttpResponse.json([{ id: 7, name: 'Drybox 1', identifier: null, spool_count: 0, created_at: '', updated_at: '' }])
- ),
- // The first delete succeeds and actually removes the row; the second
- // fails, simulating a partial failure partway through the sequential
- // delete loop.
- http.delete('/api/v1/location-ha-sensors/1', () => {
- sensors = sensors.filter((s) => s.id !== 1);
- return HttpResponse.json({ message: 'Sensor removed' });
- }),
- http.delete('/api/v1/location-ha-sensors/2', () => new HttpResponse(null, { status: 500 }))
- );
- const user = userEvent.setup();
- render(<SettingsPage />);
- await user.click(await screen.findByText('Sensors'));
- await screen.findByText('sensor.drybox_1_temperature');
- await screen.findByText('sensor.drybox_1_humidity');
- await user.click(screen.getByRole('button', { name: 'Delete' }));
- await user.click(await screen.findByRole('button', { name: 'Confirm' }));
- // The sensor that actually got deleted on the backend must not linger on
- // screen just because the batch as a whole reported an error.
- await waitFor(() => expect(screen.queryByText('sensor.drybox_1_temperature')).not.toBeInTheDocument());
- expect(screen.getByText('sensor.drybox_1_humidity')).toBeInTheDocument();
- });
- it('orders location cards by location, not by the order their sensors were created', async () => {
- // Sensor for location 8 ("Drybox 10") appears in the array before the
- // sensor for location 7 ("Drybox 2") — a naive Map-insertion-order
- // render would put "Drybox 10" first. Card order must follow the
- // (naturally sorted) locations list instead.
- const sensors = [
- {
- id: 1,
- location_id: 8,
- name: 'Drybox 10 Temperature',
- entity_id: 'sensor.drybox_10_temperature',
- device_class: 'temperature',
- unit: '°C',
- },
- {
- id: 2,
- location_id: 7,
- name: 'Drybox 2 Temperature',
- entity_id: 'sensor.drybox_2_temperature',
- device_class: 'temperature',
- unit: '°C',
- },
- ];
- server.use(
- http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json(sensors)),
- http.get('/api/v1/location-ha-sensors/by-location/7/readings', () => HttpResponse.json([])),
- http.get('/api/v1/location-ha-sensors/by-location/8/readings', () => HttpResponse.json([])),
- http.get('/api/v1/inventory/locations', () =>
- HttpResponse.json([
- { id: 7, name: 'Drybox 2', identifier: null, spool_count: 0, created_at: '', updated_at: '' },
- { id: 8, name: 'Drybox 10', identifier: null, spool_count: 0, created_at: '', updated_at: '' },
- ])
- )
- );
- const user = userEvent.setup();
- const { container } = render(<SettingsPage />);
- await user.click(await screen.findByText('Sensors'));
- await screen.findByText('sensor.drybox_10_temperature');
- const cardTitles = Array.from(container.querySelectorAll('.text-white.font-medium.truncate')).map(
- (el) => el.textContent
- );
- const drybox2Index = cardTitles.indexOf('Drybox 2');
- const drybox10Index = cardTitles.indexOf('Drybox 10');
- expect(drybox2Index).toBeGreaterThanOrEqual(0);
- expect(drybox10Index).toBeGreaterThanOrEqual(0);
- expect(drybox2Index).toBeLessThan(drybox10Index);
- });
- });
- /**
- * Sponsor banner on Settings -> General.
- *
- * Below the fleet threshold it makes the community/donation ask; at or above it
- * the same slot makes the commercial ask and points at business.html. A print
- * farm asked to chip in $5 is a wasted impression, and a hobbyist pitched a
- * support contract is an annoyed user — so both directions are pinned.
- */
- describe('SettingsPage — sponsor banner audience', () => {
- beforeEach(() => {
- // BrowserRouter shares window.location across tests and the banner only
- // renders on the General tab — without this reset a prior test's ?tab=queue
- // leaks in and the banner never mounts.
- window.history.replaceState({}, '', '/');
- });
- const fleet = (count: number) =>
- server.use(
- http.get('/api/v1/printers/', () =>
- HttpResponse.json(
- Array.from({ length: count }, (_, i) => ({
- id: i + 1,
- name: `Printer ${i + 1}`,
- serial_number: `SN${i + 1}`,
- ip_address: '192.168.1.10',
- model: 'X1C',
- is_active: true,
- })),
- ),
- ),
- );
- it('shows the community ask for a small fleet', async () => {
- fleet(2);
- render(<SettingsPage />);
- const banner = await screen.findByRole('link', { name: /Independent & community-funded/i });
- expect(banner).toHaveAttribute('href', 'https://bambuddy.cool/sponsors.html?from=app-settings');
- expect(screen.queryByText(/Bambuddy for business/i)).not.toBeInTheDocument();
- });
- it('shows the commercial ask for a business-sized fleet', async () => {
- fleet(6);
- render(<SettingsPage />);
- const banner = await screen.findByRole('link', { name: /Bambuddy for business/i });
- expect(banner).toHaveAttribute('href', 'https://bambuddy.cool/business.html?from=app-settings');
- // The donation copy is replaced, not merely supplemented.
- expect(screen.queryByText(/Independent & community-funded/i)).not.toBeInTheDocument();
- // The ask names the fleet back to them.
- expect(screen.getByText(/6 printers/i)).toBeInTheDocument();
- });
- });
- describe('SettingsPage — settings changed outside the page (#2716)', () => {
- const restoreLabel = 'Restore plate for finish photo';
- // external_url is deliberately populated: when the server has none the page
- // detects one from the browser and saves it unprompted, which would show up
- // as a PUT in tests that assert none was made. That behaviour has its own
- // test at the end of this block.
- const baseSettings = { ...mockSettings, external_url: window.location.origin };
- let queryClient: QueryClient;
- let puts: Record<string, unknown>[];
- let served: Record<string, unknown>;
- function renderPage() {
- queryClient = new QueryClient({
- defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } },
- });
- return rtlRender(
- <QueryClientProvider client={queryClient}>
- <BrowserRouter>
- <AuthProvider>
- <ThemeProvider>
- <ToastProvider>
- <SettingsPage />
- </ToastProvider>
- </ThemeProvider>
- </AuthProvider>
- </BrowserRouter>
- </QueryClientProvider>
- );
- }
- /** Change the settings row server-side and let the page's query observe it. */
- async function changeOnServer(patch: Record<string, unknown>) {
- served = { ...served, ...patch };
- await act(async () => {
- await queryClient.invalidateQueries({ queryKey: ['settings'] });
- });
- }
- /** Wait out the 100ms initial-load suppression, then flip a checkbox. */
- async function toggleRestorePlate() {
- const label = await screen.findByText(restoreLabel);
- await new Promise((resolve) => setTimeout(resolve, 200));
- const row = label.closest('div')!.parentElement!;
- await userEvent.click(within(row).getByRole('checkbox'));
- }
- beforeEach(() => {
- window.history.replaceState({}, '', '/');
- localStorage.clear();
- setAuthToken(null);
- puts = [];
- served = { ...baseSettings };
- server.use(
- http.get('/api/v1/settings/', () => HttpResponse.json(served)),
- http.put('/api/v1/settings/', async ({ request }) => {
- const body = (await request.json()) as Record<string, unknown>;
- puts.push(body);
- served = { ...served, ...body };
- return HttpResponse.json(served);
- })
- );
- });
- it('does not write its stale copy back over a server-side change', async () => {
- // The defect: the page diffed the live query cache against its own copy, so
- // a refetch that carried someone else's change read as a local edit and was
- // reverted ~500ms later with no user interaction at all.
- renderPage();
- await screen.findByText(restoreLabel);
- await new Promise((resolve) => setTimeout(resolve, 200));
- await changeOnServer({ currency: 'EUR' });
- // Well past the 500ms debounce.
- await new Promise((resolve) => setTimeout(resolve, 1200));
- expect(puts).toEqual([]);
- });
- it('adopts the server value, so a later save carries it rather than the stale one', async () => {
- renderPage();
- await new Promise((resolve) => setTimeout(resolve, 200));
- await changeOnServer({ currency: 'EUR' });
- await toggleRestorePlate();
- await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
- // The user's edit is saved...
- expect(puts[0].finish_photo_restore_plate).toBe(false);
- // ...and the field they never touched goes back as the server's value, not
- // the USD the page loaded with.
- expect(puts[0].currency).toBe('EUR');
- });
- it('never reverts a pending user edit that the server changed too', async () => {
- renderPage();
- await toggleRestorePlate();
- // Lands while the edit is still sitting in the 500ms debounce, i.e. before
- // the page has committed it. Adopting the server's value here would throw
- // the edit away silently.
- await changeOnServer({ finish_photo_restore_plate: true });
- await waitFor(() => expect(puts.length).toBeGreaterThan(0), { timeout: 3000 });
- await new Promise((resolve) => setTimeout(resolve, 1200));
- // Asserted over every request rather than a particular one: whichever order
- // the refetch and the debounce happen to land in, no write may carry the
- // server's value back over the user's.
- expect(puts.map((p) => p.finish_photo_restore_plate)).toEqual(puts.map(() => false));
- });
- it('saves once per edit — the baseline moves with the saved row', async () => {
- // Guards the failure mode the baseline introduces if it is not advanced on
- // save: every render would diff against the pre-save snapshot and re-send.
- renderPage();
- await toggleRestorePlate();
- await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
- await new Promise((resolve) => setTimeout(resolve, 1500));
- expect(puts).toHaveLength(1);
- });
- it('still persists the external_url it detects from the browser', async () => {
- // The page seeds external_url from window.location.origin when the server
- // has none and relies on the auto-save to persist it. That only works
- // because the baseline is the raw server row: seed the baseline from the
- // adjusted copy instead and the detected URL matches it, so nothing ever
- // marks it as needing a save.
- served = { ...mockSettings };
- renderPage();
- await screen.findByText(restoreLabel);
- await new Promise((resolve) => setTimeout(resolve, 200));
- // A refetch carrying a field this page does not manage. It is enough to
- // re-run the diff, and the only thing that differs is the detected URL.
- await changeOnServer({ spoolman_url: 'http://spoolman.example' });
- await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
- expect(puts[0].external_url).toBe(window.location.origin);
- });
- });
|