SpoolBuddyDashboard.test.tsx 42 KB

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