PrintersPageDeletePowerWiring.test.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. /**
  2. * Printer card wiring for the destructive / out-of-service actions:
  3. * delete printer (with or without its archives), smart-plug power off and
  4. * auto-off, maintenance mode and the plate-detection toggle.
  5. *
  6. * Every test asserts the request that actually leaves the browser, and the
  7. * destructive ones also pin that cancelling sends nothing.
  8. */
  9. import { describe, it, expect, vi, beforeEach } from 'vitest';
  10. import { QueryClient } from '@tanstack/react-query';
  11. import { screen, waitFor, within } from '@testing-library/react';
  12. import userEvent from '@testing-library/user-event';
  13. import { http, HttpResponse } from 'msw';
  14. import { server } from '../mocks/server';
  15. const permissions = { denied: new Set<string>() };
  16. const mockUseAuth = {
  17. user: { id: 1, username: 'operator', permissions: [] as string[] },
  18. authEnabled: true,
  19. loading: false,
  20. isAdmin: false,
  21. login: vi.fn(),
  22. loginWithToken: vi.fn(),
  23. logout: vi.fn(),
  24. refreshAuth: vi.fn(),
  25. hasPermission: vi.fn((permission: string) => !permissions.denied.has(permission)),
  26. hasAnyPermission: vi.fn(() => true),
  27. canModify: vi.fn(() => true),
  28. };
  29. vi.mock('../../contexts/AuthContext', async (importOriginal) => {
  30. const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
  31. return { ...actual, useAuth: () => mockUseAuth };
  32. });
  33. import { render } from '../utils';
  34. import { PrintersPage } from '../../pages/PrintersPage';
  35. const PRINTER_ID = 4;
  36. const PLUG_ID = 42;
  37. const basePrinter = {
  38. id: PRINTER_ID,
  39. name: 'Garage P1S',
  40. ip_address: '192.0.2.104',
  41. serial_number: '01P00A000000004',
  42. access_code: '12345678',
  43. model: 'P1S',
  44. enabled: true,
  45. is_active: true,
  46. plate_detection_enabled: false,
  47. nozzle_diameter: 0.4,
  48. nozzle_type: 'stainless_steel',
  49. auto_archive: true,
  50. created_at: '2024-01-01T00:00:00Z',
  51. updated_at: '2024-01-01T00:00:00Z',
  52. };
  53. const statusFor = (state: string) => ({
  54. connected: true,
  55. state,
  56. progress: state === 'RUNNING' ? 40 : 0,
  57. layer_num: 0,
  58. total_layers: 0,
  59. temperatures: { nozzle: 25, bed: 25, chamber: 25 },
  60. remaining_time: 0,
  61. filename: null,
  62. wifi_signal: -40,
  63. speed_level: 2,
  64. vt_tray: [],
  65. ams: [],
  66. });
  67. const smartPlug = {
  68. id: PLUG_ID,
  69. name: 'Printer Plug',
  70. plug_type: 'tasmota',
  71. ip_address: '192.0.2.50',
  72. ha_entity_id: null,
  73. enabled: true,
  74. printer_id: PRINTER_ID,
  75. auto_on: false,
  76. auto_off: false,
  77. auto_off_executed: false,
  78. };
  79. type Sent = { method: string; path: string; search: string; body: unknown };
  80. interface SetupOptions {
  81. state?: string;
  82. printer?: Partial<typeof basePrinter>;
  83. withPlug?: boolean;
  84. }
  85. function setup({ state = 'IDLE', printer = {}, withPlug = false }: SetupOptions = {}) {
  86. const sent: Sent[] = [];
  87. let assignmentFetches = 0;
  88. const record = async ({ request }: { request: Request }) => {
  89. const url = new URL(request.url);
  90. const text = await request.text();
  91. sent.push({
  92. method: request.method,
  93. path: url.pathname,
  94. search: url.search,
  95. body: text ? JSON.parse(text) : null,
  96. });
  97. return HttpResponse.json({ success: true, status: 'deleted', archives_deleted: true, action: 'off' });
  98. };
  99. server.use(
  100. http.get('/api/v1/printers/', () => HttpResponse.json([{ ...basePrinter, ...printer }])),
  101. http.get('/api/v1/queue/', () => HttpResponse.json([])),
  102. http.get('/api/v1/printers/:id/status', () => HttpResponse.json(statusFor(state))),
  103. http.get('/api/v1/smart-plugs/by-printer/:id', () => HttpResponse.json(withPlug ? smartPlug : null)),
  104. http.get('/api/v1/smart-plugs/:id/status', () =>
  105. HttpResponse.json({ state: 'ON', reachable: true, device_name: 'Plug', energy: { power: 120 } })
  106. ),
  107. http.get('/api/v1/inventory/assignments', () => {
  108. assignmentFetches += 1;
  109. return HttpResponse.json([]);
  110. }),
  111. http.delete('/api/v1/printers/:id', record),
  112. http.patch('/api/v1/printers/:id', record),
  113. http.post('/api/v1/smart-plugs/:id/control', record),
  114. http.patch('/api/v1/smart-plugs/:id', record),
  115. );
  116. render(<PrintersPage />);
  117. return { sent, assignmentFetches: () => assignmentFetches };
  118. }
  119. /** Opens the card's 3-dot overflow menu. */
  120. async function openMenu(user: ReturnType<typeof userEvent.setup>) {
  121. // The trigger's title is t('common.more', 'More'); match loosely so the
  122. // label wording does not matter, but it must be the card's More button.
  123. await screen.findByText('Garage P1S');
  124. const trigger = (await screen.findAllByTitle(/more/i)).find((el) => el.tagName === 'BUTTON');
  125. expect(trigger).toBeDefined();
  126. await user.click(trigger!);
  127. }
  128. const settle = () => new Promise((r) => setTimeout(r, 50));
  129. describe('PrintersPage delete printer wiring', () => {
  130. beforeEach(() => {
  131. permissions.denied = new Set();
  132. });
  133. it('confirm with "Delete print archives" checked sends DELETE ?delete_archives=true', async () => {
  134. const user = userEvent.setup();
  135. const { sent } = setup();
  136. await openMenu(user);
  137. await user.click(await screen.findByRole('button', { name: 'Delete' }));
  138. const heading = await screen.findByText('Delete Printer');
  139. const dialog = heading.closest('div.fixed') as HTMLElement;
  140. expect(within(dialog).getByRole('checkbox')).toBeChecked();
  141. expect(sent).toEqual([]);
  142. await user.click(within(dialog).getByRole('button', { name: 'Delete' }));
  143. await waitFor(() => expect(sent).toHaveLength(1));
  144. expect(sent[0]).toMatchObject({
  145. method: 'DELETE',
  146. path: `/api/v1/printers/${PRINTER_ID}`,
  147. search: '?delete_archives=true',
  148. });
  149. });
  150. it('confirm with the archives box unchecked sends delete_archives=false', async () => {
  151. const user = userEvent.setup();
  152. const { sent } = setup();
  153. await openMenu(user);
  154. await user.click(await screen.findByRole('button', { name: 'Delete' }));
  155. const dialog = (await screen.findByText('Delete Printer')).closest('div.fixed') as HTMLElement;
  156. await user.click(within(dialog).getByRole('checkbox'));
  157. expect(within(dialog).getByRole('checkbox')).not.toBeChecked();
  158. await user.click(within(dialog).getByRole('button', { name: 'Delete' }));
  159. await waitFor(() => expect(sent).toHaveLength(1));
  160. expect(sent[0]).toMatchObject({
  161. method: 'DELETE',
  162. path: `/api/v1/printers/${PRINTER_ID}`,
  163. search: '?delete_archives=false',
  164. });
  165. });
  166. it('cancel sends nothing and resets the archives box for next time', async () => {
  167. const user = userEvent.setup();
  168. const { sent } = setup();
  169. await openMenu(user);
  170. await user.click(await screen.findByRole('button', { name: 'Delete' }));
  171. let dialog = (await screen.findByText('Delete Printer')).closest('div.fixed') as HTMLElement;
  172. await user.click(within(dialog).getByRole('checkbox'));
  173. await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
  174. await waitFor(() => expect(screen.queryByText('Delete Printer')).toBeNull());
  175. await settle();
  176. expect(sent).toEqual([]);
  177. await openMenu(user);
  178. await user.click(await screen.findByRole('button', { name: 'Delete' }));
  179. dialog = (await screen.findByText('Delete Printer')).closest('div.fixed') as HTMLElement;
  180. expect(within(dialog).getByRole('checkbox')).toBeChecked();
  181. });
  182. it('without printers:delete the menu entry opens nothing and sends nothing', async () => {
  183. permissions.denied = new Set(['printers:delete']);
  184. const user = userEvent.setup();
  185. const { sent } = setup();
  186. await openMenu(user);
  187. const entry = await screen.findByRole('button', { name: 'Delete' });
  188. expect(entry).toHaveAttribute('title', 'You do not have permission to delete printers');
  189. await user.click(entry);
  190. await settle();
  191. expect(screen.queryByText('Delete Printer')).toBeNull();
  192. expect(sent).toEqual([]);
  193. });
  194. // The backend drops the deleted printer's slot assignments; a cached list
  195. // would keep showing spools as assigned to a printer that no longer exists.
  196. it('a successful delete refetches the spool assignments', async () => {
  197. const user = userEvent.setup();
  198. const { sent, assignmentFetches } = setup();
  199. await waitFor(() => expect(assignmentFetches()).toBeGreaterThan(0));
  200. await settle();
  201. const before = assignmentFetches();
  202. await openMenu(user);
  203. await user.click(await screen.findByRole('button', { name: 'Delete' }));
  204. const dialog = (await screen.findByText('Delete Printer')).closest('div.fixed') as HTMLElement;
  205. await user.click(within(dialog).getByRole('button', { name: 'Delete' }));
  206. await waitFor(() => expect(sent).toHaveLength(1));
  207. await waitFor(() => expect(assignmentFetches()).toBeGreaterThan(before));
  208. });
  209. it('a successful delete also refreshes the Spoolman slot assignments', async () => {
  210. const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
  211. try {
  212. const user = userEvent.setup();
  213. const { sent } = setup();
  214. await openMenu(user);
  215. await user.click(await screen.findByRole('button', { name: 'Delete' }));
  216. const dialog = (await screen.findByText('Delete Printer')).closest('div.fixed') as HTMLElement;
  217. await user.click(within(dialog).getByRole('button', { name: 'Delete' }));
  218. await waitFor(() => expect(sent).toHaveLength(1));
  219. await waitFor(() =>
  220. expect(invalidate).toHaveBeenCalledWith({ queryKey: ['spoolman-slot-assignments'] })
  221. );
  222. } finally {
  223. invalidate.mockRestore();
  224. }
  225. });
  226. });
  227. describe('PrintersPage smart plug power wiring', () => {
  228. beforeEach(() => {
  229. permissions.denied = new Set();
  230. });
  231. it('power off while RUNNING shows the printing warning; confirm POSTs action=off to the plug', async () => {
  232. const user = userEvent.setup();
  233. const { sent } = setup({ state: 'RUNNING', withPlug: true });
  234. await user.click(await screen.findByTitle('Turn off'));
  235. expect(await screen.findByText(/"Garage P1S" is currently printing!/)).toBeInTheDocument();
  236. expect(sent).toEqual([]);
  237. await user.click(screen.getByRole('button', { name: 'Power Off' }));
  238. await waitFor(() => expect(sent).toHaveLength(1));
  239. expect(sent[0]).toMatchObject({
  240. method: 'POST',
  241. path: `/api/v1/smart-plugs/${PLUG_ID}/control`,
  242. body: { action: 'off' },
  243. });
  244. });
  245. it('power off -> cancel sends nothing', async () => {
  246. const user = userEvent.setup();
  247. const { sent } = setup({ state: 'RUNNING', withPlug: true });
  248. await user.click(await screen.findByTitle('Turn off'));
  249. await screen.findByText('Power Off Printer');
  250. await user.click(screen.getByRole('button', { name: 'Cancel' }));
  251. await waitFor(() => expect(screen.queryByText('Power Off Printer')).toBeNull());
  252. await settle();
  253. expect(sent).toEqual([]);
  254. });
  255. it('auto-off toggle PATCHes the plug with the flipped auto_off', async () => {
  256. const user = userEvent.setup();
  257. const { sent } = setup({ withPlug: true });
  258. await user.click(await screen.findByTitle('Auto power-off after print'));
  259. await waitFor(() => expect(sent).toHaveLength(1));
  260. expect(sent[0]).toMatchObject({
  261. method: 'PATCH',
  262. path: `/api/v1/smart-plugs/${PLUG_ID}`,
  263. body: { auto_off: true },
  264. });
  265. });
  266. it('without smart_plugs:control the power and auto-off buttons are disabled', async () => {
  267. permissions.denied = new Set(['smart_plugs:control']);
  268. const user = userEvent.setup();
  269. const { sent } = setup({ state: 'RUNNING', withPlug: true });
  270. await screen.findByText('Printer Plug');
  271. const buttons = screen.getAllByTitle('You do not have permission to control smart plugs');
  272. expect(buttons).toHaveLength(2);
  273. for (const b of buttons) {
  274. expect(b).toBeDisabled();
  275. await user.click(b);
  276. }
  277. await settle();
  278. expect(screen.queryByText('Power Off Printer')).toBeNull();
  279. expect(sent).toEqual([]);
  280. });
  281. });
  282. describe('PrintersPage maintenance mode and plate detection wiring', () => {
  283. beforeEach(() => {
  284. permissions.denied = new Set();
  285. });
  286. it('entering maintenance on an idle printer PATCHes {is_active:false}', async () => {
  287. const user = userEvent.setup();
  288. const { sent } = setup();
  289. await openMenu(user);
  290. await user.click(await screen.findByRole('button', { name: 'Enter maintenance mode' }));
  291. await waitFor(() => expect(sent).toHaveLength(1));
  292. expect(sent[0]).toMatchObject({
  293. method: 'PATCH',
  294. path: `/api/v1/printers/${PRINTER_ID}`,
  295. body: { is_active: false },
  296. });
  297. });
  298. it('entering maintenance mid-print asks first; cancel sends nothing, confirm sends is_active:false', async () => {
  299. const user = userEvent.setup();
  300. const { sent } = setup({ state: 'RUNNING' });
  301. await openMenu(user);
  302. await user.click(await screen.findByRole('button', { name: 'Enter maintenance mode' }));
  303. await screen.findByText('Enter maintenance mode mid-print?');
  304. await user.click(screen.getByRole('button', { name: 'Cancel' }));
  305. await waitFor(() => expect(screen.queryByText('Enter maintenance mode mid-print?')).toBeNull());
  306. await settle();
  307. expect(sent).toEqual([]);
  308. await openMenu(user);
  309. await user.click(await screen.findByRole('button', { name: 'Enter maintenance mode' }));
  310. const title = await screen.findByText('Enter maintenance mode mid-print?');
  311. const modal = title.closest('[role="dialog"]') ?? title.parentElement!.parentElement!.parentElement!;
  312. const confirm = within(modal as HTMLElement)
  313. .getAllByRole('button', { name: 'Enter maintenance mode' })
  314. .at(-1)!;
  315. await user.click(confirm);
  316. await waitFor(() => expect(sent).toHaveLength(1));
  317. expect(sent[0]).toMatchObject({
  318. method: 'PATCH',
  319. path: `/api/v1/printers/${PRINTER_ID}`,
  320. body: { is_active: false },
  321. });
  322. });
  323. it('exiting maintenance PATCHes {is_active:true}', async () => {
  324. const user = userEvent.setup();
  325. const { sent } = setup({ printer: { is_active: false } });
  326. await user.click(await screen.findByRole('button', { name: 'Exit maintenance' }));
  327. await waitFor(() => expect(sent).toHaveLength(1));
  328. expect(sent[0]).toMatchObject({
  329. method: 'PATCH',
  330. path: `/api/v1/printers/${PRINTER_ID}`,
  331. body: { is_active: true },
  332. });
  333. });
  334. it('plate-detection toggle PATCHes the inverse of the current setting', async () => {
  335. const user = userEvent.setup();
  336. const { sent } = setup({ printer: { plate_detection_enabled: false } });
  337. await user.click(await screen.findByTitle('Plate check disabled - Click to enable'));
  338. await waitFor(() => expect(sent).toHaveLength(1));
  339. expect(sent[0]).toMatchObject({
  340. method: 'PATCH',
  341. path: `/api/v1/printers/${PRINTER_ID}`,
  342. body: { plate_detection_enabled: true },
  343. });
  344. });
  345. it('plate-detection toggle on an enabled printer sends plate_detection_enabled:false', async () => {
  346. const user = userEvent.setup();
  347. const { sent } = setup({ printer: { plate_detection_enabled: true } });
  348. await user.click(await screen.findByTitle('Plate check enabled - Click to disable'));
  349. await waitFor(() => expect(sent).toHaveLength(1));
  350. expect(sent[0]).toMatchObject({ body: { plate_detection_enabled: false } });
  351. });
  352. });