SpoolBuddyDashboard.test.tsx 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974
  1. /**
  2. * Tests for SpoolBuddyDashboard:
  3. * - Shows stats bar (Spools, Materials, Brands)
  4. * - Shows "Ready to scan" idle state when no tag detected
  5. * - Shows device status section
  6. * - Shows "Device Offline" state when device offline
  7. */
  8. import { describe, it, expect, vi, beforeEach } from 'vitest';
  9. import { screen, waitFor, fireEvent, act } from '@testing-library/react';
  10. import React from 'react';
  11. import { render } from '@testing-library/react';
  12. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  13. import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
  14. import { SpoolBuddyDashboard } from '../../pages/spoolbuddy/SpoolBuddyDashboard';
  15. import { ToastProvider } from '../../contexts/ToastContext';
  16. // The kiosk runs signed in; these tests render it without the auth provider.
  17. vi.mock('../../contexts/AuthContext', async (importOriginal) => {
  18. const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
  19. return { ...actual, useAuth: () => ({ hasPermission: () => true }) };
  20. });
  21. const mockShowToast = vi.fn();
  22. vi.mock('../../contexts/ToastContext', async (importOriginal) => {
  23. const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
  24. return { ...actual, useToast: () => ({ showToast: mockShowToast }) };
  25. });
  26. vi.mock('../../api/client', () => ({
  27. api: {
  28. getSpools: vi.fn().mockResolvedValue([
  29. { id: 1, material: 'PLA', brand: 'Bambu', tag_uid: 'AA:BB', tray_uuid: null, archived_at: null, color_name: 'Red', rgba: 'FF0000FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 100 },
  30. { id: 2, material: 'PETG', brand: 'Bambu', tag_uid: 'CC:DD', tray_uuid: null, archived_at: null, color_name: 'Blue', rgba: '0000FFFF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 200 },
  31. { id: 3, material: 'ABS', brand: 'Polymaker', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'White', rgba: 'FFFFFFFF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
  32. ]),
  33. getPrinters: vi.fn().mockResolvedValue([]),
  34. getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),
  35. getSpoolmanSettings: vi.fn().mockResolvedValue({ spoolman_enabled: 'false', spoolman_url: '', spoolman_sync_mode: 'off', spoolman_disable_weight_sync: 'false', spoolman_report_partial_usage: 'false' }),
  36. getSpoolmanInventorySpools: vi.fn().mockResolvedValue([]),
  37. getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]),
  38. linkTagToSpool: vi.fn().mockResolvedValue({}),
  39. linkTagToSpoolmanSpool: vi.fn().mockResolvedValue({}),
  40. createSpool: vi.fn().mockResolvedValue({ id: 4 }),
  41. createSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 4 }),
  42. clearPlate: vi.fn().mockResolvedValue({}),
  43. },
  44. spoolbuddyApi: {
  45. getDevices: vi.fn().mockResolvedValue([]),
  46. },
  47. // Real class, not a stub: the link handler branches on `instanceof ApiError`
  48. // to decide whether a failure carries a structured code (#3110).
  49. ApiError: class ApiError extends Error {
  50. status: number;
  51. code: string | null;
  52. detail: Record<string, unknown> | null;
  53. constructor(message: string, status: number, code: string | null = null, detail: Record<string, unknown> | null = null) {
  54. super(message);
  55. this.name = 'ApiError';
  56. this.status = status;
  57. this.code = code;
  58. this.detail = detail;
  59. }
  60. },
  61. }));
  62. // Hoisted so the react-i18next factory can reach it: what the toast shows is
  63. // only half the contract -- the other half is that the spool id reaches the
  64. // interpolation bag (#3110), and the key-as-text mock cannot show that.
  65. const i18nSpy = vi.hoisted(() => ({ t: vi.fn() }));
  66. vi.mock('react-i18next', () => ({
  67. useTranslation: () => ({
  68. // Mirrors i18next's overloaded (key, defaultValue?, options?) signature --
  69. // including the (key, options) form, where the second argument is the
  70. // interpolation bag and there is no default value -- with simple {{var}}
  71. // interpolation so tests can assert on the rendered text.
  72. t: (key: string, fallbackOrOptions?: string | Record<string, unknown>, maybeOptions?: Record<string, unknown>) => {
  73. i18nSpy.t(key, fallbackOrOptions, maybeOptions);
  74. const fallback = typeof fallbackOrOptions === 'string' ? fallbackOrOptions : undefined;
  75. const options = typeof fallbackOrOptions === 'object' ? fallbackOrOptions : maybeOptions;
  76. const text = fallback ?? key;
  77. if (!options) return text;
  78. return text.replace(/\{\{(\w+)\}\}/g, (_m, k) => String(options[k] ?? ''));
  79. },
  80. i18n: { language: 'en', changeLanguage: vi.fn() },
  81. }),
  82. }));
  83. const mockOutletContext = {
  84. selectedPrinterId: null,
  85. setSelectedPrinterId: vi.fn(),
  86. sbState: {
  87. weight: null,
  88. weightStable: false,
  89. rawAdc: null,
  90. matchedSpool: null,
  91. unknownTagUid: null,
  92. unknownTrayUuid: null,
  93. deviceOnline: true,
  94. deviceId: 'dev-1',
  95. remainingWeight: null,
  96. netWeight: null,
  97. },
  98. setAlert: vi.fn(),
  99. displayBrightness: 100,
  100. setDisplayBrightness: vi.fn(),
  101. displayBlankTimeout: 0,
  102. setDisplayBlankTimeout: vi.fn(),
  103. };
  104. function renderPage(overrides: Partial<typeof mockOutletContext['sbState']> = {}) {
  105. const ctx = {
  106. ...mockOutletContext,
  107. sbState: { ...mockOutletContext.sbState, ...overrides },
  108. };
  109. function Wrapper() {
  110. return <Outlet context={ctx} />;
  111. }
  112. const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
  113. return render(
  114. <ToastProvider>
  115. <QueryClientProvider client={qc}>
  116. <MemoryRouter initialEntries={['/spoolbuddy']}>
  117. <Routes>
  118. <Route element={<Wrapper />}>
  119. <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
  120. </Route>
  121. </Routes>
  122. </MemoryRouter>
  123. </QueryClientProvider>
  124. </ToastProvider>
  125. );
  126. }
  127. describe('SpoolBuddyDashboard', () => {
  128. beforeEach(() => {
  129. vi.clearAllMocks();
  130. });
  131. it('shows stats bar with spool count, materials, and brands', async () => {
  132. renderPage();
  133. await waitFor(() => {
  134. expect(screen.getByText('Spools')).toBeDefined();
  135. expect(screen.getByText('Materials')).toBeDefined();
  136. expect(screen.getByText('Brands')).toBeDefined();
  137. // Check that the stats numbers are rendered (3 spools, 3 materials, 2 brands)
  138. const statNumbers = screen.getAllByText(/^[0-9]+$/);
  139. expect(statNumbers.length).toBeGreaterThanOrEqual(3);
  140. });
  141. });
  142. it('shows "Ready to scan" idle state when device online with no tag', async () => {
  143. renderPage();
  144. await waitFor(() => {
  145. expect(screen.getByText('Ready to scan')).toBeDefined();
  146. expect(screen.getByText('Place a spool on the scale to identify it')).toBeDefined();
  147. });
  148. });
  149. it('shows device status section', async () => {
  150. renderPage();
  151. await waitFor(() => {
  152. expect(screen.getByText('Device')).toBeDefined();
  153. });
  154. });
  155. it('shows "Online" when device is online', async () => {
  156. renderPage({ deviceOnline: true });
  157. await waitFor(() => {
  158. expect(screen.getByText('Online')).toBeDefined();
  159. });
  160. });
  161. it('shows "Device Offline" state when device offline', async () => {
  162. renderPage({ deviceOnline: false });
  163. await waitFor(() => {
  164. expect(screen.getByText('Device Offline')).toBeDefined();
  165. expect(screen.getByText('Connect the SpoolBuddy display to scan spools')).toBeDefined();
  166. });
  167. });
  168. it('shows current spool section heading', async () => {
  169. renderPage();
  170. await waitFor(() => {
  171. expect(screen.getByText('Current Spool')).toBeDefined();
  172. });
  173. });
  174. describe('plate-clear row', () => {
  175. // We re-mock api.getPrinters / getPrinterStatus per test so each scenario
  176. // controls exactly which printers report awaiting_plate_clear.
  177. it('does not render the plate-clear button when no printer needs it', async () => {
  178. const { api } = await import('../../api/client');
  179. (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
  180. { id: 1, name: 'X1C' },
  181. ]);
  182. (api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
  183. connected: true,
  184. awaiting_plate_clear: false,
  185. });
  186. renderPage();
  187. await waitFor(() => {
  188. expect(screen.getByText('X1C')).toBeDefined();
  189. });
  190. expect(screen.queryByTestId('plate-clear-section')).toBeNull();
  191. });
  192. it('renders a plate-clear pill only for printers with awaiting_plate_clear=true', async () => {
  193. const { api } = await import('../../api/client');
  194. (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
  195. { id: 1, name: 'X1C' },
  196. { id: 2, name: 'P1S' },
  197. ]);
  198. (api.getPrinterStatus as ReturnType<typeof vi.fn>).mockImplementation((printerId: number) =>
  199. Promise.resolve({
  200. connected: true,
  201. awaiting_plate_clear: printerId === 2,
  202. })
  203. );
  204. renderPage();
  205. await waitFor(() => {
  206. expect(screen.getByTestId('plate-clear-button-2')).toBeDefined();
  207. });
  208. expect(screen.queryByTestId('plate-clear-button-1')).toBeNull();
  209. // Pill content: printer name + "Clear" label, plus full "Plate ready: P1S" in title attr.
  210. const pill = screen.getByTestId('plate-clear-button-2');
  211. expect(pill.getAttribute('title')).toBe('Plate ready: P1S');
  212. expect(pill.textContent).toContain('P1S');
  213. expect(pill.textContent).toContain('Clear');
  214. });
  215. it('renders multiple plate-clear pills inline when several printers are pending', async () => {
  216. const { api } = await import('../../api/client');
  217. (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
  218. { id: 1, name: 'A' },
  219. { id: 2, name: 'B' },
  220. { id: 3, name: 'C' },
  221. ]);
  222. (api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
  223. connected: true,
  224. awaiting_plate_clear: true,
  225. });
  226. renderPage();
  227. await waitFor(() => {
  228. expect(screen.getByTestId('plate-clear-button-1')).toBeDefined();
  229. expect(screen.getByTestId('plate-clear-button-2')).toBeDefined();
  230. expect(screen.getByTestId('plate-clear-button-3')).toBeDefined();
  231. });
  232. // Pills sit in the same flex-wrap container so they flow inline.
  233. const section = screen.getByTestId('plate-clear-section');
  234. expect(section.className).toContain('flex-wrap');
  235. });
  236. it('calls api.clearPlate with the printer id when clicked', async () => {
  237. const { api } = await import('../../api/client');
  238. (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
  239. { id: 7, name: 'H2D' },
  240. ]);
  241. (api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
  242. connected: true,
  243. awaiting_plate_clear: true,
  244. });
  245. renderPage();
  246. const btn = await waitFor(() => screen.getByTestId('plate-clear-button-7'));
  247. fireEvent.click(btn);
  248. await waitFor(() => {
  249. expect(api.clearPlate).toHaveBeenCalledWith(7);
  250. });
  251. });
  252. it('hides the row optimistically after a successful click without a refetch', async () => {
  253. const { api } = await import('../../api/client');
  254. (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
  255. { id: 9, name: 'X1E' },
  256. ]);
  257. // Stable resolve — even if refetch happens it would still report pending,
  258. // so a disappearing row proves the optimistic cache write worked.
  259. (api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
  260. connected: true,
  261. awaiting_plate_clear: true,
  262. });
  263. renderPage();
  264. const btn = await waitFor(() => screen.getByTestId('plate-clear-button-9'));
  265. fireEvent.click(btn);
  266. await waitFor(() => {
  267. expect(screen.queryByTestId('plate-clear-button-9')).toBeNull();
  268. });
  269. });
  270. });
  271. describe('Spoolman mode', () => {
  272. it('fetches from getSpoolmanInventorySpools when Spoolman is enabled', async () => {
  273. const { api } = await import('../../api/client');
  274. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  275. spoolman_enabled: 'true',
  276. spoolman_url: 'http://localhost:7912',
  277. spoolman_sync_mode: 'off',
  278. spoolman_disable_weight_sync: 'false',
  279. spoolman_report_partial_usage: 'false',
  280. });
  281. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  282. { id: 10, material: 'PLA', brand: 'Bambu', tag_uid: 'SM:01', tray_uuid: null, archived_at: null, color_name: 'Green', rgba: '00FF00FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
  283. ]);
  284. renderPage();
  285. await waitFor(() => {
  286. expect(api.getSpoolmanInventorySpools).toHaveBeenCalled();
  287. });
  288. });
  289. it('still uses getSpools when Spoolman is disabled', async () => {
  290. const { api } = await import('../../api/client');
  291. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  292. spoolman_enabled: 'false',
  293. spoolman_url: '',
  294. spoolman_sync_mode: 'off',
  295. spoolman_disable_weight_sync: 'false',
  296. spoolman_report_partial_usage: 'false',
  297. });
  298. renderPage();
  299. await waitFor(() => {
  300. expect(api.getSpools).toHaveBeenCalled();
  301. });
  302. });
  303. it('excludes tray_uuid spools from the untagged list in Spoolman mode', async () => {
  304. const { api } = await import('../../api/client');
  305. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  306. spoolman_enabled: 'true',
  307. spoolman_url: 'http://localhost:7912',
  308. spoolman_sync_mode: 'off',
  309. spoolman_disable_weight_sync: 'false',
  310. spoolman_report_partial_usage: 'false',
  311. });
  312. // One spool has tray_uuid (linked via Bambu) → excluded from untagged
  313. // One spool has neither tag_uid nor tray_uuid → included
  314. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  315. { id: 20, material: 'PETG', brand: 'Bambu', tag_uid: null, tray_uuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF', archived_at: null, color_name: 'Blue', rgba: '0000FFFF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
  316. { id: 21, material: 'ABS', brand: 'Polymaker', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'Black', rgba: '000000FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
  317. ]);
  318. renderPage({ unknownTagUid: 'AABB1122', unknownTrayUuid: 'CAFEBABECAFEBABECAFEBABECAFEBABE' });
  319. // Open the link modal
  320. const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
  321. fireEvent.click(linkBtn);
  322. await waitFor(() => {
  323. // Only the ABS spool (id=21) should appear — the PETG with tray_uuid is excluded
  324. expect(screen.getByText('Black')).toBeDefined();
  325. expect(screen.queryByText('Blue')).toBeNull();
  326. });
  327. });
  328. it('sends the tray UUID when linking a Bambu tag in Spoolman mode (#984)', async () => {
  329. const { api } = await import('../../api/client');
  330. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  331. spoolman_enabled: 'true',
  332. spoolman_url: 'http://localhost:7912',
  333. spoolman_sync_mode: 'off',
  334. spoolman_disable_weight_sync: 'false',
  335. spoolman_report_partial_usage: 'false',
  336. });
  337. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  338. { id: 30, material: 'TPU', brand: 'Bambu', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'Orange', rgba: 'FF6600FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
  339. ]);
  340. renderPage({
  341. unknownTagUid: 'AABB1122334455FF',
  342. unknownTrayUuid: '9E0B0717BEE94D7887EB1D8DFD1A14F3',
  343. });
  344. const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
  345. fireEvent.click(linkBtn);
  346. const spoolBtn = await waitFor(() => screen.getByText('Orange'));
  347. fireEvent.click(spoolBtn);
  348. const confirmBtn = await waitFor(() => screen.getByText('Link Tag'));
  349. fireEvent.click(confirmBtn);
  350. // The route stores tray_uuid over tag_uid -- the value the AMS keeps in
  351. // extra.tag, and the same on both tags of the spool.
  352. await waitFor(() => {
  353. expect(api.linkTagToSpoolmanSpool).toHaveBeenCalledWith(30, {
  354. tag_uid: 'AABB1122334455FF',
  355. tray_uuid: '9E0B0717BEE94D7887EB1D8DFD1A14F3',
  356. });
  357. });
  358. });
  359. it('links by tag_uid alone in Spoolman mode when the tag has no tray UUID', async () => {
  360. const { api } = await import('../../api/client');
  361. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  362. spoolman_enabled: 'true',
  363. spoolman_url: 'http://localhost:7912',
  364. spoolman_sync_mode: 'off',
  365. spoolman_disable_weight_sync: 'false',
  366. spoolman_report_partial_usage: 'false',
  367. });
  368. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  369. { id: 30, material: 'TPU', brand: 'Bambu', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'Orange', rgba: 'FF6600FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
  370. ]);
  371. renderPage({ unknownTagUid: 'AABB1122334455FF' });
  372. fireEvent.click(await waitFor(() => screen.getByText('Assign Spool')));
  373. fireEvent.click(await waitFor(() => screen.getByText('Orange')));
  374. fireEvent.click(await waitFor(() => screen.getByText('Link Tag')));
  375. await waitFor(() => {
  376. expect(api.linkTagToSpoolmanSpool).toHaveBeenCalledWith(30, {
  377. tag_uid: 'AABB1122334455FF',
  378. tray_uuid: undefined,
  379. });
  380. });
  381. });
  382. it('shows the spool card for a Spoolman spool stored under the tag\'s tray UUID (#984)', async () => {
  383. // After quick-add or link, Spoolman's extra.tag holds the tray UUID and the
  384. // spool comes back with tray_uuid set and tag_uid null. The card must still
  385. // find it, or the kiosk keeps offering "Add to Inventory" until a re-scan.
  386. const { api } = await import('../../api/client');
  387. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  388. spoolman_enabled: 'true',
  389. spoolman_url: 'http://localhost:7912',
  390. spoolman_sync_mode: 'off',
  391. spoolman_disable_weight_sync: 'false',
  392. spoolman_report_partial_usage: 'false',
  393. });
  394. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  395. { id: 40, material: 'PLA', brand: 'Bambu', tag_uid: null, tray_uuid: '9E0B0717BEE94D7887EB1D8DFD1A14F3', archived_at: null, color_name: 'Teal', rgba: '008080FF', subtype: 'Matte', label_weight: 1000, core_weight: 250, weight_used: 0 },
  396. ]);
  397. renderPage({ unknownTagUid: 'AABB1122', unknownTrayUuid: '9E0B0717BEE94D7887EB1D8DFD1A14F3' });
  398. await waitFor(() => expect(screen.getByText('Sync Weight')).toBeDefined());
  399. expect(screen.queryByText('Add to Inventory')).toBeNull();
  400. });
  401. it('quick-adds a Spoolman spool linked by its tray UUID (#984)', async () => {
  402. const { api } = await import('../../api/client');
  403. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  404. spoolman_enabled: 'true',
  405. spoolman_url: 'http://localhost:7912',
  406. spoolman_sync_mode: 'off',
  407. spoolman_disable_weight_sync: 'false',
  408. spoolman_report_partial_usage: 'false',
  409. });
  410. renderPage({ unknownTagUid: 'AABB1122334455FF', unknownTrayUuid: '9E0B0717BEE94D7887EB1D8DFD1A14F3' });
  411. fireEvent.click(await waitFor(() => screen.getAllByText('Add to Inventory')[0]));
  412. fireEvent.click(await waitFor(() => screen.getByText('Add Anyway')));
  413. await waitFor(() => {
  414. expect(api.linkTagToSpoolmanSpool).toHaveBeenCalledWith(4, {
  415. tag_uid: 'AABB1122334455FF',
  416. tray_uuid: '9E0B0717BEE94D7887EB1D8DFD1A14F3',
  417. });
  418. });
  419. });
  420. it('quick-adds a Spoolman spool without a tare, so it inherits the filament type\'s (#2908)', async () => {
  421. // The quick-create has no tare input. The 250 it used to send was a
  422. // placeholder, and since #2908 a sent value lands on the spool's own
  423. // spool_weight -- every kiosk-created spool would stop inheriting.
  424. const { api } = await import('../../api/client');
  425. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  426. spoolman_enabled: 'true',
  427. spoolman_url: 'http://localhost:7912',
  428. spoolman_sync_mode: 'off',
  429. spoolman_disable_weight_sync: 'false',
  430. spoolman_report_partial_usage: 'false',
  431. });
  432. renderPage({ unknownTagUid: 'AABB1122334455FF' });
  433. fireEvent.click(await waitFor(() => screen.getAllByText('Add to Inventory')[0]));
  434. fireEvent.click(await waitFor(() => screen.getByText('Add Anyway')));
  435. await waitFor(() => {
  436. expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
  437. });
  438. const payload = (api.createSpoolmanInventorySpool as ReturnType<typeof vi.fn>).mock.calls[0][0];
  439. expect(payload).not.toHaveProperty('core_weight');
  440. expect(payload).not.toHaveProperty('core_weight_catalog_id');
  441. expect(api.createSpool).not.toHaveBeenCalled();
  442. });
  443. it('switches to SpoolInfoCard and hides UnknownTagCard after successful Spoolman link', async () => {
  444. const { api } = await import('../../api/client');
  445. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  446. spoolman_enabled: 'true',
  447. spoolman_url: 'http://localhost:7912',
  448. spoolman_sync_mode: 'off',
  449. spoolman_disable_weight_sync: 'false',
  450. spoolman_report_partial_usage: 'false',
  451. });
  452. const linkedSpool = {
  453. id: 30, material: 'TPU', brand: 'Bambu', tag_uid: null,
  454. tray_uuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF', archived_at: null,
  455. color_name: 'Orange', rgba: 'FF6600FF', subtype: null,
  456. label_weight: 1000, core_weight: 250, weight_used: 0,
  457. created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
  458. };
  459. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  460. { ...linkedSpool, tag_uid: null, tray_uuid: null },
  461. ]);
  462. (api.linkTagToSpoolmanSpool as ReturnType<typeof vi.fn>).mockResolvedValue(linkedSpool);
  463. renderPage({
  464. unknownTagUid: 'AABB1122334455FF',
  465. unknownTrayUuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
  466. });
  467. const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
  468. fireEvent.click(linkBtn);
  469. const spoolBtn = await waitFor(() => screen.getByText('Orange'));
  470. fireEvent.click(spoolBtn);
  471. const confirmBtn = await waitFor(() => screen.getByText('Link Tag'));
  472. fireEvent.click(confirmBtn);
  473. await waitFor(() => {
  474. expect(screen.queryByText('Assign Spool')).toBeNull();
  475. expect(screen.getByText('Sync Weight')).toBeDefined();
  476. expect(mockShowToast).toHaveBeenCalledWith('spoolman.linkSuccess', 'success');
  477. });
  478. });
  479. it('shows error toast and closes modal when Spoolman link fails', async () => {
  480. const { api } = await import('../../api/client');
  481. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  482. spoolman_enabled: 'true',
  483. spoolman_url: 'http://localhost:7912',
  484. spoolman_sync_mode: 'off',
  485. spoolman_disable_weight_sync: 'false',
  486. spoolman_report_partial_usage: 'false',
  487. });
  488. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  489. { id: 30, material: 'TPU', brand: 'Bambu', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'Orange', rgba: 'FF6600FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
  490. ]);
  491. (api.linkTagToSpoolmanSpool as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('503'));
  492. renderPage({
  493. unknownTagUid: 'AABB1122334455FF',
  494. unknownTrayUuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
  495. });
  496. const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
  497. fireEvent.click(linkBtn);
  498. const spoolBtn = await waitFor(() => screen.getByText('Orange'));
  499. fireEvent.click(spoolBtn);
  500. fireEvent.click(await waitFor(() => screen.getByText('Link Tag')));
  501. await waitFor(() => {
  502. // Error toast shown
  503. expect(mockShowToast).toHaveBeenCalledWith('spoolman.linkFailed', 'error');
  504. // Modal closed via finally
  505. expect(screen.queryByText('Link Tag')).toBeNull();
  506. // UnknownTagCard still visible — no card switch on failure
  507. expect(screen.getByText('Assign Spool')).toBeDefined();
  508. });
  509. });
  510. it('names the spool holding the tag when Spoolman refuses the link (#3110)', async () => {
  511. const { api, ApiError } = await import('../../api/client');
  512. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  513. spoolman_enabled: 'true',
  514. spoolman_url: 'http://localhost:7912',
  515. spoolman_sync_mode: 'off',
  516. spoolman_disable_weight_sync: 'false',
  517. spoolman_report_partial_usage: 'false',
  518. });
  519. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  520. { id: 30, material: 'TPU', brand: 'Bambu', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'Orange', rgba: 'FF6600FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
  521. ]);
  522. (api.linkTagToSpoolmanSpool as ReturnType<typeof vi.fn>).mockRejectedValue(
  523. new ApiError('Tray UUID is already linked to spool 42', 409, 'tag_already_linked', {
  524. code: 'tag_already_linked',
  525. message: 'Tray UUID is already linked to spool 42',
  526. spool_id: 42,
  527. field: 'tray_uuid',
  528. }),
  529. );
  530. renderPage({
  531. unknownTagUid: 'AABB1122334455FF',
  532. unknownTrayUuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
  533. });
  534. const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
  535. fireEvent.click(linkBtn);
  536. fireEvent.click(await waitFor(() => screen.getByText('Orange')));
  537. fireEvent.click(await waitFor(() => screen.getByText('Link Tag')));
  538. await waitFor(() => {
  539. expect(mockShowToast).toHaveBeenCalledWith('inventory.tagAlreadyLinked', 'error');
  540. });
  541. // The operator can only walk to the other spool if the id is in the
  542. // sentence, so assert it reached the interpolation bag rather than
  543. // trusting the key-as-text mock's output.
  544. expect(i18nSpy.t).toHaveBeenCalledWith('inventory.tagAlreadyLinked', { id: 42 }, undefined);
  545. });
  546. it('keeps the generic toast for a link failure that carries no code', async () => {
  547. const { api, ApiError } = await import('../../api/client');
  548. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  549. spoolman_enabled: 'true',
  550. spoolman_url: 'http://localhost:7912',
  551. spoolman_sync_mode: 'off',
  552. spoolman_disable_weight_sync: 'false',
  553. spoolman_report_partial_usage: 'false',
  554. });
  555. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  556. { id: 30, material: 'TPU', brand: 'Bambu', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'Orange', rgba: 'FF6600FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
  557. ]);
  558. // A 409 from something other than a tag conflict, and a plain-string
  559. // detail, must not be dressed up as one.
  560. (api.linkTagToSpoolmanSpool as ReturnType<typeof vi.fn>).mockRejectedValue(
  561. new ApiError('Spoolman unavailable', 503),
  562. );
  563. renderPage({
  564. unknownTagUid: 'AABB1122334455FF',
  565. unknownTrayUuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
  566. });
  567. const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
  568. fireEvent.click(linkBtn);
  569. fireEvent.click(await waitFor(() => screen.getByText('Orange')));
  570. fireEvent.click(await waitFor(() => screen.getByText('Link Tag')));
  571. await waitFor(() => {
  572. expect(mockShowToast).toHaveBeenCalledWith('spoolman.linkFailed', 'error');
  573. });
  574. });
  575. it('clears justLinkedSpool and shows new UnknownTagCard when a different tag is placed', async () => {
  576. const { api } = await import('../../api/client');
  577. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  578. spoolman_enabled: 'true',
  579. spoolman_url: 'http://localhost:7912',
  580. spoolman_sync_mode: 'off',
  581. spoolman_disable_weight_sync: 'false',
  582. spoolman_report_partial_usage: 'false',
  583. });
  584. const linkedSpool = {
  585. id: 30, material: 'TPU', brand: 'Bambu', tag_uid: null,
  586. tray_uuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF', archived_at: null,
  587. color_name: 'Orange', rgba: 'FF6600FF', subtype: null,
  588. label_weight: 1000, core_weight: 250, weight_used: 0,
  589. created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
  590. };
  591. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  592. { ...linkedSpool, tag_uid: null, tray_uuid: null },
  593. { id: 31, material: 'PLA', brand: 'Bambu', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'Green', rgba: '00FF00FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
  594. ]);
  595. (api.linkTagToSpoolmanSpool as ReturnType<typeof vi.fn>).mockResolvedValue(linkedSpool);
  596. // Stateful wrapper so sbState can be updated mid-test. Stash the setter
  597. // on a ref-shaped object instead of a bare `let` reassigned during
  598. // render — react-hooks/globals (eslint-plugin-react-hooks v5) flags
  599. // that as a side effect during render. Mutating a property on a
  600. // pre-allocated object is fine because the object identity doesn't
  601. // change.
  602. const setterRef: { current: React.Dispatch<React.SetStateAction<typeof mockOutletContext.sbState>> | null } = { current: null };
  603. function DynWrapper() {
  604. const [sbState, setSbState] = React.useState({
  605. ...mockOutletContext.sbState,
  606. unknownTagUid: 'AABB1122334455FF',
  607. unknownTrayUuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
  608. });
  609. setterRef.current = setSbState;
  610. return <Outlet context={{ ...mockOutletContext, sbState }} />;
  611. }
  612. const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
  613. render(
  614. <ToastProvider>
  615. <QueryClientProvider client={qc}>
  616. <MemoryRouter initialEntries={['/spoolbuddy']}>
  617. <Routes>
  618. <Route element={<DynWrapper />}>
  619. <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
  620. </Route>
  621. </Routes>
  622. </MemoryRouter>
  623. </QueryClientProvider>
  624. </ToastProvider>
  625. );
  626. // Link spool — SpoolInfoCard appears via justLinkedSpool
  627. fireEvent.click(await waitFor(() => screen.getByText('Assign Spool')));
  628. fireEvent.click(await waitFor(() => screen.getByText('Orange')));
  629. fireEvent.click(await waitFor(() => screen.getByText('Link Tag')));
  630. await waitFor(() => expect(screen.getByText('Sync Weight')).toBeDefined());
  631. // Different tag placed → justLinkedSpool cleared
  632. act(() => setterRef.current!((prev) => ({ ...prev, unknownTagUid: 'CCDD5566', unknownTrayUuid: null })));
  633. await waitFor(() => {
  634. expect(screen.queryByText('Sync Weight')).toBeNull();
  635. expect(screen.getByText('Assign Spool')).toBeDefined();
  636. });
  637. });
  638. it('calls linkTagToSpool (local) when Spoolman is disabled — no regression', async () => {
  639. const { api } = await import('../../api/client');
  640. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  641. spoolman_enabled: 'false',
  642. spoolman_url: '',
  643. spoolman_sync_mode: 'off',
  644. spoolman_disable_weight_sync: 'false',
  645. spoolman_report_partial_usage: 'false',
  646. });
  647. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  648. { id: 3, material: 'ABS', brand: 'Polymaker', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'White', rgba: 'FFFFFFFF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
  649. ]);
  650. renderPage({ unknownTagUid: 'AABB9999' });
  651. const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
  652. fireEvent.click(linkBtn);
  653. const spoolBtn = await waitFor(() => screen.getByText('White'));
  654. fireEvent.click(spoolBtn);
  655. const confirmBtn = await waitFor(() => screen.getByText('Link Tag'));
  656. fireEvent.click(confirmBtn);
  657. await waitFor(() => {
  658. expect(api.linkTagToSpool).toHaveBeenCalledWith(3, {
  659. tag_uid: 'AABB9999',
  660. tag_type: 'generic',
  661. data_origin: 'nfc_link',
  662. });
  663. expect(api.linkTagToSpoolmanSpool).not.toHaveBeenCalled();
  664. // Local path never sets justLinkedSpool → no Spoolman success toast
  665. expect(mockShowToast).not.toHaveBeenCalled();
  666. // Modal closes via finally, UnknownTagCard still absent (tag still present but no SpoolInfoCard)
  667. expect(screen.queryByText('Link Tag')).toBeNull();
  668. });
  669. });
  670. });
  671. describe('Bambu tray UUID in local mode (#984)', () => {
  672. const TRAY_UUID = '9E0B0717BEE94D7887EB1D8DFD1A14F3';
  673. beforeEach(async () => {
  674. const { api } = await import('../../api/client');
  675. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  676. spoolman_enabled: 'false',
  677. spoolman_url: '',
  678. spoolman_sync_mode: 'off',
  679. spoolman_disable_weight_sync: 'false',
  680. spoolman_report_partial_usage: 'false',
  681. });
  682. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  683. { id: 3, material: 'ABS', brand: 'Polymaker', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'White', rgba: 'FFFFFFFF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
  684. ]);
  685. });
  686. it('links the tag together with its tray UUID as a Bambu Lab tag', async () => {
  687. const { api } = await import('../../api/client');
  688. renderPage({ unknownTagUid: 'AABB9999', unknownTrayUuid: TRAY_UUID });
  689. fireEvent.click(await waitFor(() => screen.getByText('Assign Spool')));
  690. fireEvent.click(await waitFor(() => screen.getByText('White')));
  691. fireEvent.click(await waitFor(() => screen.getByText('Link Tag')));
  692. await waitFor(() => {
  693. expect(api.linkTagToSpool).toHaveBeenCalledWith(3, {
  694. tag_uid: 'AABB9999',
  695. tray_uuid: TRAY_UUID,
  696. tag_type: 'bambulab',
  697. data_origin: 'nfc_link',
  698. });
  699. });
  700. });
  701. it('quick-adds the spool with its tray UUID, so the other tag and the AMS find it', async () => {
  702. const { api } = await import('../../api/client');
  703. renderPage({ unknownTagUid: 'AABB9999', unknownTrayUuid: TRAY_UUID });
  704. fireEvent.click(await waitFor(() => screen.getAllByText('Add to Inventory')[0]));
  705. fireEvent.click(await waitFor(() => screen.getByText('Add Anyway')));
  706. await waitFor(() => expect(api.createSpool).toHaveBeenCalledTimes(1));
  707. const payload = (api.createSpool as ReturnType<typeof vi.fn>).mock.calls[0][0];
  708. expect(payload.tag_uid).toBe('AABB9999');
  709. expect(payload.tray_uuid).toBe(TRAY_UUID);
  710. expect(payload.tag_type).toBe('bambulab');
  711. });
  712. it('quick-adds a tag without a tray UUID as a generic tag, as before', async () => {
  713. const { api } = await import('../../api/client');
  714. renderPage({ unknownTagUid: 'AABB9999' });
  715. fireEvent.click(await waitFor(() => screen.getAllByText('Add to Inventory')[0]));
  716. fireEvent.click(await waitFor(() => screen.getByText('Add Anyway')));
  717. await waitFor(() => expect(api.createSpool).toHaveBeenCalledTimes(1));
  718. const payload = (api.createSpool as ReturnType<typeof vi.fn>).mock.calls[0][0];
  719. expect(payload.tray_uuid).toBeNull();
  720. expect(payload.tag_type).toBe('generic');
  721. });
  722. it('still saves the tray UUID when the spool is lifted off the reader while quick-add is open', async () => {
  723. const { api } = await import('../../api/client');
  724. const setterRef: { current: React.Dispatch<React.SetStateAction<typeof mockOutletContext.sbState>> | null } = { current: null };
  725. function DynWrapper() {
  726. const [sbState, setSbState] = React.useState({
  727. ...mockOutletContext.sbState,
  728. unknownTagUid: 'AABB9999',
  729. unknownTrayUuid: TRAY_UUID,
  730. });
  731. setterRef.current = setSbState;
  732. return <Outlet context={{ ...mockOutletContext, sbState }} />;
  733. }
  734. const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
  735. render(
  736. <ToastProvider>
  737. <QueryClientProvider client={qc}>
  738. <MemoryRouter initialEntries={['/spoolbuddy']}>
  739. <Routes>
  740. <Route element={<DynWrapper />}>
  741. <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
  742. </Route>
  743. </Routes>
  744. </MemoryRouter>
  745. </QueryClientProvider>
  746. </ToastProvider>
  747. );
  748. fireEvent.click(await waitFor(() => screen.getAllByText('Add to Inventory')[0]));
  749. await waitFor(() => screen.getByText('Add Anyway'));
  750. // Tag removed: the dialog stays open, the live tag state is gone.
  751. act(() => setterRef.current!((prev) => ({ ...prev, unknownTagUid: null, unknownTrayUuid: null })));
  752. fireEvent.click(await waitFor(() => screen.getByText('Add Anyway')));
  753. await waitFor(() => expect(api.createSpool).toHaveBeenCalledTimes(1));
  754. const payload = (api.createSpool as ReturnType<typeof vi.fn>).mock.calls[0][0];
  755. expect(payload.tag_uid).toBe('AABB9999');
  756. expect(payload.tray_uuid).toBe(TRAY_UUID);
  757. });
  758. });
  759. describe('Spoolman mode, spool lifted while a dialog is open', () => {
  760. // The reader state is cleared when the tag leaves the reader; the card and
  761. // its dialogs stay. Linking must use the tag the card shows.
  762. function renderLiftable() {
  763. const setterRef: { current: React.Dispatch<React.SetStateAction<typeof mockOutletContext.sbState>> | null } = { current: null };
  764. function DynWrapper() {
  765. const [sbState, setSbState] = React.useState({ ...mockOutletContext.sbState, unknownTagUid: 'AABB1122334455FF' });
  766. setterRef.current = setSbState;
  767. return <Outlet context={{ ...mockOutletContext, sbState }} />;
  768. }
  769. const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
  770. render(
  771. <ToastProvider>
  772. <QueryClientProvider client={qc}>
  773. <MemoryRouter initialEntries={['/spoolbuddy']}>
  774. <Routes>
  775. <Route element={<DynWrapper />}>
  776. <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
  777. </Route>
  778. </Routes>
  779. </MemoryRouter>
  780. </QueryClientProvider>
  781. </ToastProvider>
  782. );
  783. return () => act(() => setterRef.current!((prev) => ({ ...prev, unknownTagUid: null, unknownTrayUuid: null })));
  784. }
  785. beforeEach(async () => {
  786. const { api } = await import('../../api/client');
  787. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  788. spoolman_enabled: 'true',
  789. spoolman_url: 'http://localhost:7912',
  790. spoolman_sync_mode: 'off',
  791. spoolman_disable_weight_sync: 'false',
  792. spoolman_report_partial_usage: 'false',
  793. });
  794. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  795. { id: 30, material: 'TPU', brand: 'Polymaker', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'Orange', rgba: 'FF6600FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
  796. ]);
  797. });
  798. it('links the tag shown on the card', async () => {
  799. const { api } = await import('../../api/client');
  800. const lift = renderLiftable();
  801. fireEvent.click(await waitFor(() => screen.getByText('Assign Spool')));
  802. fireEvent.click(await waitFor(() => screen.getByText('Orange')));
  803. await waitFor(() => screen.getByText('Link Tag'));
  804. lift();
  805. fireEvent.click(screen.getByText('Link Tag'));
  806. await waitFor(() => {
  807. expect(api.linkTagToSpoolmanSpool).toHaveBeenCalledWith(30, {
  808. tag_uid: 'AABB1122334455FF',
  809. tray_uuid: undefined,
  810. });
  811. });
  812. });
  813. it('quick-adds the spool with the tag shown on the card', async () => {
  814. const { api } = await import('../../api/client');
  815. const lift = renderLiftable();
  816. fireEvent.click(await waitFor(() => screen.getAllByText('Add to Inventory')[0]));
  817. await waitFor(() => screen.getByText('Add Anyway'));
  818. lift();
  819. fireEvent.click(screen.getByText('Add Anyway'));
  820. await waitFor(() => {
  821. expect(api.linkTagToSpoolmanSpool).toHaveBeenCalledWith(4, {
  822. tag_uid: 'AABB1122334455FF',
  823. tray_uuid: undefined,
  824. });
  825. });
  826. });
  827. });
  828. describe('Spoolman mode', () => {
  829. const SPOOLMAN_SPOOL = {
  830. id: 42, material: 'PLA', subtype: null, brand: 'Bambu',
  831. color_name: 'Red', rgba: 'FF0000FF', extra_colors: null, effect_type: null,
  832. label_weight: 1000, core_weight: 250, core_weight_catalog_id: null,
  833. weight_used: 200, tag_uid: 'AABB11223344', tray_uuid: null,
  834. slicer_filament: null, slicer_filament_name: null, nozzle_temp_min: null,
  835. nozzle_temp_max: null, note: null, added_full: null, last_used: null,
  836. encode_time: null, data_origin: 'spoolman', tag_type: null, archived_at: null,
  837. created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
  838. cost_per_kg: null, last_scale_weight: null, last_weighed_at: null,
  839. category: null, low_stock_threshold_pct: null, k_profiles: [], storage_location: null,
  840. };
  841. it('disables "Assign to AMS" button when spool is already assigned', async () => {
  842. const { api } = await import('../../api/client');
  843. vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
  844. spoolman_enabled: 'true', spoolman_url: 'http://localhost:7912',
  845. spoolman_sync_mode: 'off', spoolman_disable_weight_sync: 'false',
  846. spoolman_report_partial_usage: 'false',
  847. } as never);
  848. vi.mocked(api.getSpoolmanInventorySpools).mockResolvedValue([SPOOLMAN_SPOOL] as never);
  849. vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValue([
  850. { printer_id: 1, ams_id: 0, tray_id: 0, spoolman_spool_id: 42 },
  851. ] as never);
  852. renderPage({
  853. deviceOnline: true,
  854. matchedSpool: {
  855. id: 42, tag_uid: 'AABB11223344', material: 'PLA', subtype: null,
  856. color_name: 'Red', rgba: 'FF0000FF', brand: 'Bambu',
  857. label_weight: 1000, core_weight: 250, weight_used: 200,
  858. },
  859. });
  860. await waitFor(() => {
  861. const btn = screen.queryByText('Assign to AMS');
  862. expect(btn).toBeTruthy();
  863. expect(btn).toBeDisabled();
  864. });
  865. });
  866. });
  867. });