SpoolBuddyDashboard.test.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  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. }));
  43. vi.mock('react-i18next', () => ({
  44. useTranslation: () => ({
  45. // Mirrors i18next's (key, defaultValue, options) signature with simple
  46. // {{var}} interpolation so tests can assert on the rendered text.
  47. t: (key: string, fallback?: string, options?: Record<string, unknown>) => {
  48. const text = fallback ?? key;
  49. if (!options) return text;
  50. return text.replace(/\{\{(\w+)\}\}/g, (_m, k) => String(options[k] ?? ''));
  51. },
  52. i18n: { language: 'en', changeLanguage: vi.fn() },
  53. }),
  54. }));
  55. const mockOutletContext = {
  56. selectedPrinterId: null,
  57. setSelectedPrinterId: vi.fn(),
  58. sbState: {
  59. weight: null,
  60. weightStable: false,
  61. rawAdc: null,
  62. matchedSpool: null,
  63. unknownTagUid: null,
  64. unknownTrayUuid: null,
  65. deviceOnline: true,
  66. deviceId: 'dev-1',
  67. remainingWeight: null,
  68. netWeight: null,
  69. },
  70. setAlert: vi.fn(),
  71. displayBrightness: 100,
  72. setDisplayBrightness: vi.fn(),
  73. displayBlankTimeout: 0,
  74. setDisplayBlankTimeout: vi.fn(),
  75. };
  76. function renderPage(overrides: Partial<typeof mockOutletContext['sbState']> = {}) {
  77. const ctx = {
  78. ...mockOutletContext,
  79. sbState: { ...mockOutletContext.sbState, ...overrides },
  80. };
  81. function Wrapper() {
  82. return <Outlet context={ctx} />;
  83. }
  84. const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
  85. return render(
  86. <ToastProvider>
  87. <QueryClientProvider client={qc}>
  88. <MemoryRouter initialEntries={['/spoolbuddy']}>
  89. <Routes>
  90. <Route element={<Wrapper />}>
  91. <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
  92. </Route>
  93. </Routes>
  94. </MemoryRouter>
  95. </QueryClientProvider>
  96. </ToastProvider>
  97. );
  98. }
  99. describe('SpoolBuddyDashboard', () => {
  100. beforeEach(() => {
  101. vi.clearAllMocks();
  102. });
  103. it('shows stats bar with spool count, materials, and brands', async () => {
  104. renderPage();
  105. await waitFor(() => {
  106. expect(screen.getByText('Spools')).toBeDefined();
  107. expect(screen.getByText('Materials')).toBeDefined();
  108. expect(screen.getByText('Brands')).toBeDefined();
  109. // Check that the stats numbers are rendered (3 spools, 3 materials, 2 brands)
  110. const statNumbers = screen.getAllByText(/^[0-9]+$/);
  111. expect(statNumbers.length).toBeGreaterThanOrEqual(3);
  112. });
  113. });
  114. it('shows "Ready to scan" idle state when device online with no tag', async () => {
  115. renderPage();
  116. await waitFor(() => {
  117. expect(screen.getByText('Ready to scan')).toBeDefined();
  118. expect(screen.getByText('Place a spool on the scale to identify it')).toBeDefined();
  119. });
  120. });
  121. it('shows device status section', async () => {
  122. renderPage();
  123. await waitFor(() => {
  124. expect(screen.getByText('Device')).toBeDefined();
  125. });
  126. });
  127. it('shows "Online" when device is online', async () => {
  128. renderPage({ deviceOnline: true });
  129. await waitFor(() => {
  130. expect(screen.getByText('Online')).toBeDefined();
  131. });
  132. });
  133. it('shows "Device Offline" state when device offline', async () => {
  134. renderPage({ deviceOnline: false });
  135. await waitFor(() => {
  136. expect(screen.getByText('Device Offline')).toBeDefined();
  137. expect(screen.getByText('Connect the SpoolBuddy display to scan spools')).toBeDefined();
  138. });
  139. });
  140. it('shows current spool section heading', async () => {
  141. renderPage();
  142. await waitFor(() => {
  143. expect(screen.getByText('Current Spool')).toBeDefined();
  144. });
  145. });
  146. describe('plate-clear row', () => {
  147. // We re-mock api.getPrinters / getPrinterStatus per test so each scenario
  148. // controls exactly which printers report awaiting_plate_clear.
  149. it('does not render the plate-clear button when no printer needs it', async () => {
  150. const { api } = await import('../../api/client');
  151. (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
  152. { id: 1, name: 'X1C' },
  153. ]);
  154. (api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
  155. connected: true,
  156. awaiting_plate_clear: false,
  157. });
  158. renderPage();
  159. await waitFor(() => {
  160. expect(screen.getByText('X1C')).toBeDefined();
  161. });
  162. expect(screen.queryByTestId('plate-clear-section')).toBeNull();
  163. });
  164. it('renders a plate-clear pill only for printers with awaiting_plate_clear=true', async () => {
  165. const { api } = await import('../../api/client');
  166. (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
  167. { id: 1, name: 'X1C' },
  168. { id: 2, name: 'P1S' },
  169. ]);
  170. (api.getPrinterStatus as ReturnType<typeof vi.fn>).mockImplementation((printerId: number) =>
  171. Promise.resolve({
  172. connected: true,
  173. awaiting_plate_clear: printerId === 2,
  174. })
  175. );
  176. renderPage();
  177. await waitFor(() => {
  178. expect(screen.getByTestId('plate-clear-button-2')).toBeDefined();
  179. });
  180. expect(screen.queryByTestId('plate-clear-button-1')).toBeNull();
  181. // Pill content: printer name + "Clear" label, plus full "Plate ready: P1S" in title attr.
  182. const pill = screen.getByTestId('plate-clear-button-2');
  183. expect(pill.getAttribute('title')).toBe('Plate ready: P1S');
  184. expect(pill.textContent).toContain('P1S');
  185. expect(pill.textContent).toContain('Clear');
  186. });
  187. it('renders multiple plate-clear pills inline when several printers are pending', async () => {
  188. const { api } = await import('../../api/client');
  189. (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
  190. { id: 1, name: 'A' },
  191. { id: 2, name: 'B' },
  192. { id: 3, name: 'C' },
  193. ]);
  194. (api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
  195. connected: true,
  196. awaiting_plate_clear: true,
  197. });
  198. renderPage();
  199. await waitFor(() => {
  200. expect(screen.getByTestId('plate-clear-button-1')).toBeDefined();
  201. expect(screen.getByTestId('plate-clear-button-2')).toBeDefined();
  202. expect(screen.getByTestId('plate-clear-button-3')).toBeDefined();
  203. });
  204. // Pills sit in the same flex-wrap container so they flow inline.
  205. const section = screen.getByTestId('plate-clear-section');
  206. expect(section.className).toContain('flex-wrap');
  207. });
  208. it('calls api.clearPlate with the printer id when clicked', async () => {
  209. const { api } = await import('../../api/client');
  210. (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
  211. { id: 7, name: 'H2D' },
  212. ]);
  213. (api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
  214. connected: true,
  215. awaiting_plate_clear: true,
  216. });
  217. renderPage();
  218. const btn = await waitFor(() => screen.getByTestId('plate-clear-button-7'));
  219. fireEvent.click(btn);
  220. await waitFor(() => {
  221. expect(api.clearPlate).toHaveBeenCalledWith(7);
  222. });
  223. });
  224. it('hides the row optimistically after a successful click without a refetch', async () => {
  225. const { api } = await import('../../api/client');
  226. (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
  227. { id: 9, name: 'X1E' },
  228. ]);
  229. // Stable resolve — even if refetch happens it would still report pending,
  230. // so a disappearing row proves the optimistic cache write worked.
  231. (api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
  232. connected: true,
  233. awaiting_plate_clear: true,
  234. });
  235. renderPage();
  236. const btn = await waitFor(() => screen.getByTestId('plate-clear-button-9'));
  237. fireEvent.click(btn);
  238. await waitFor(() => {
  239. expect(screen.queryByTestId('plate-clear-button-9')).toBeNull();
  240. });
  241. });
  242. });
  243. describe('Spoolman mode', () => {
  244. it('fetches from getSpoolmanInventorySpools when Spoolman is enabled', async () => {
  245. const { api } = await import('../../api/client');
  246. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  247. spoolman_enabled: 'true',
  248. spoolman_url: 'http://localhost:7912',
  249. spoolman_sync_mode: 'off',
  250. spoolman_disable_weight_sync: 'false',
  251. spoolman_report_partial_usage: 'false',
  252. });
  253. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  254. { 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 },
  255. ]);
  256. renderPage();
  257. await waitFor(() => {
  258. expect(api.getSpoolmanInventorySpools).toHaveBeenCalled();
  259. });
  260. });
  261. it('still uses getSpools when Spoolman is disabled', async () => {
  262. const { api } = await import('../../api/client');
  263. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  264. spoolman_enabled: 'false',
  265. spoolman_url: '',
  266. spoolman_sync_mode: 'off',
  267. spoolman_disable_weight_sync: 'false',
  268. spoolman_report_partial_usage: 'false',
  269. });
  270. renderPage();
  271. await waitFor(() => {
  272. expect(api.getSpools).toHaveBeenCalled();
  273. });
  274. });
  275. it('excludes tray_uuid spools from the untagged list in Spoolman mode', async () => {
  276. const { api } = await import('../../api/client');
  277. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  278. spoolman_enabled: 'true',
  279. spoolman_url: 'http://localhost:7912',
  280. spoolman_sync_mode: 'off',
  281. spoolman_disable_weight_sync: 'false',
  282. spoolman_report_partial_usage: 'false',
  283. });
  284. // One spool has tray_uuid (linked via Bambu) → excluded from untagged
  285. // One spool has neither tag_uid nor tray_uuid → included
  286. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  287. { 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 },
  288. { 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 },
  289. ]);
  290. renderPage({ unknownTagUid: 'AABB1122', unknownTrayUuid: 'CAFEBABECAFEBABECAFEBABECAFEBABE' });
  291. // Open the link modal
  292. const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
  293. fireEvent.click(linkBtn);
  294. await waitFor(() => {
  295. // Only the ABS spool (id=21) should appear — the PETG with tray_uuid is excluded
  296. expect(screen.getByText('Black')).toBeDefined();
  297. expect(screen.queryByText('Blue')).toBeNull();
  298. });
  299. });
  300. it('calls linkTagToSpoolmanSpool with tag_uid when linking in Spoolman mode', async () => {
  301. const { api } = await import('../../api/client');
  302. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  303. spoolman_enabled: 'true',
  304. spoolman_url: 'http://localhost:7912',
  305. spoolman_sync_mode: 'off',
  306. spoolman_disable_weight_sync: 'false',
  307. spoolman_report_partial_usage: 'false',
  308. });
  309. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  310. { 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 },
  311. ]);
  312. renderPage({
  313. unknownTagUid: 'AABB1122334455FF',
  314. unknownTrayUuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
  315. });
  316. const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
  317. fireEvent.click(linkBtn);
  318. const spoolBtn = await waitFor(() => screen.getByText('Orange'));
  319. fireEvent.click(spoolBtn);
  320. const confirmBtn = await waitFor(() => screen.getByText('Link Tag'));
  321. fireEvent.click(confirmBtn);
  322. await waitFor(() => {
  323. expect(api.linkTagToSpoolmanSpool).toHaveBeenCalledWith(30, {
  324. tag_uid: 'AABB1122334455FF',
  325. tray_uuid: undefined,
  326. });
  327. });
  328. });
  329. it('switches to SpoolInfoCard and hides UnknownTagCard after successful Spoolman link', async () => {
  330. const { api } = await import('../../api/client');
  331. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  332. spoolman_enabled: 'true',
  333. spoolman_url: 'http://localhost:7912',
  334. spoolman_sync_mode: 'off',
  335. spoolman_disable_weight_sync: 'false',
  336. spoolman_report_partial_usage: 'false',
  337. });
  338. const linkedSpool = {
  339. id: 30, material: 'TPU', brand: 'Bambu', tag_uid: null,
  340. tray_uuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF', archived_at: null,
  341. color_name: 'Orange', rgba: 'FF6600FF', subtype: null,
  342. label_weight: 1000, core_weight: 250, weight_used: 0,
  343. created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
  344. };
  345. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  346. { ...linkedSpool, tag_uid: null, tray_uuid: null },
  347. ]);
  348. (api.linkTagToSpoolmanSpool as ReturnType<typeof vi.fn>).mockResolvedValue(linkedSpool);
  349. renderPage({
  350. unknownTagUid: 'AABB1122334455FF',
  351. unknownTrayUuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
  352. });
  353. const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
  354. fireEvent.click(linkBtn);
  355. const spoolBtn = await waitFor(() => screen.getByText('Orange'));
  356. fireEvent.click(spoolBtn);
  357. const confirmBtn = await waitFor(() => screen.getByText('Link Tag'));
  358. fireEvent.click(confirmBtn);
  359. await waitFor(() => {
  360. expect(screen.queryByText('Assign Spool')).toBeNull();
  361. expect(screen.getByText('Sync Weight')).toBeDefined();
  362. expect(mockShowToast).toHaveBeenCalledWith('spoolman.linkSuccess', 'success');
  363. });
  364. });
  365. it('shows error toast and closes modal when Spoolman link fails', async () => {
  366. const { api } = await import('../../api/client');
  367. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  368. spoolman_enabled: 'true',
  369. spoolman_url: 'http://localhost:7912',
  370. spoolman_sync_mode: 'off',
  371. spoolman_disable_weight_sync: 'false',
  372. spoolman_report_partial_usage: 'false',
  373. });
  374. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  375. { 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 },
  376. ]);
  377. (api.linkTagToSpoolmanSpool as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('503'));
  378. renderPage({
  379. unknownTagUid: 'AABB1122334455FF',
  380. unknownTrayUuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
  381. });
  382. const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
  383. fireEvent.click(linkBtn);
  384. const spoolBtn = await waitFor(() => screen.getByText('Orange'));
  385. fireEvent.click(spoolBtn);
  386. fireEvent.click(await waitFor(() => screen.getByText('Link Tag')));
  387. await waitFor(() => {
  388. // Error toast shown
  389. expect(mockShowToast).toHaveBeenCalledWith('spoolman.linkFailed', 'error');
  390. // Modal closed via finally
  391. expect(screen.queryByText('Link Tag')).toBeNull();
  392. // UnknownTagCard still visible — no card switch on failure
  393. expect(screen.getByText('Assign Spool')).toBeDefined();
  394. });
  395. });
  396. it('clears justLinkedSpool and shows new UnknownTagCard when a different tag is placed', 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. const linkedSpool = {
  406. id: 30, material: 'TPU', brand: 'Bambu', tag_uid: null,
  407. tray_uuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF', archived_at: null,
  408. color_name: 'Orange', rgba: 'FF6600FF', subtype: null,
  409. label_weight: 1000, core_weight: 250, weight_used: 0,
  410. created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
  411. };
  412. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  413. { ...linkedSpool, tag_uid: null, tray_uuid: null },
  414. { 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 },
  415. ]);
  416. (api.linkTagToSpoolmanSpool as ReturnType<typeof vi.fn>).mockResolvedValue(linkedSpool);
  417. // Stateful wrapper so sbState can be updated mid-test. Stash the setter
  418. // on a ref-shaped object instead of a bare `let` reassigned during
  419. // render — react-hooks/globals (eslint-plugin-react-hooks v5) flags
  420. // that as a side effect during render. Mutating a property on a
  421. // pre-allocated object is fine because the object identity doesn't
  422. // change.
  423. const setterRef: { current: React.Dispatch<React.SetStateAction<typeof mockOutletContext.sbState>> | null } = { current: null };
  424. function DynWrapper() {
  425. const [sbState, setSbState] = React.useState({
  426. ...mockOutletContext.sbState,
  427. unknownTagUid: 'AABB1122334455FF',
  428. unknownTrayUuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
  429. });
  430. setterRef.current = setSbState;
  431. return <Outlet context={{ ...mockOutletContext, sbState }} />;
  432. }
  433. const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
  434. render(
  435. <ToastProvider>
  436. <QueryClientProvider client={qc}>
  437. <MemoryRouter initialEntries={['/spoolbuddy']}>
  438. <Routes>
  439. <Route element={<DynWrapper />}>
  440. <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
  441. </Route>
  442. </Routes>
  443. </MemoryRouter>
  444. </QueryClientProvider>
  445. </ToastProvider>
  446. );
  447. // Link spool — SpoolInfoCard appears via justLinkedSpool
  448. fireEvent.click(await waitFor(() => screen.getByText('Assign Spool')));
  449. fireEvent.click(await waitFor(() => screen.getByText('Orange')));
  450. fireEvent.click(await waitFor(() => screen.getByText('Link Tag')));
  451. await waitFor(() => expect(screen.getByText('Sync Weight')).toBeDefined());
  452. // Different tag placed → justLinkedSpool cleared
  453. act(() => setterRef.current!((prev) => ({ ...prev, unknownTagUid: 'CCDD5566', unknownTrayUuid: null })));
  454. await waitFor(() => {
  455. expect(screen.queryByText('Sync Weight')).toBeNull();
  456. expect(screen.getByText('Assign Spool')).toBeDefined();
  457. });
  458. });
  459. it('calls linkTagToSpool (local) when Spoolman is disabled — no regression', async () => {
  460. const { api } = await import('../../api/client');
  461. (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
  462. spoolman_enabled: 'false',
  463. spoolman_url: '',
  464. spoolman_sync_mode: 'off',
  465. spoolman_disable_weight_sync: 'false',
  466. spoolman_report_partial_usage: 'false',
  467. });
  468. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([
  469. { 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 },
  470. ]);
  471. renderPage({ unknownTagUid: 'AABB9999' });
  472. const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
  473. fireEvent.click(linkBtn);
  474. const spoolBtn = await waitFor(() => screen.getByText('White'));
  475. fireEvent.click(spoolBtn);
  476. const confirmBtn = await waitFor(() => screen.getByText('Link Tag'));
  477. fireEvent.click(confirmBtn);
  478. await waitFor(() => {
  479. expect(api.linkTagToSpool).toHaveBeenCalledWith(3, {
  480. tag_uid: 'AABB9999',
  481. tag_type: 'generic',
  482. data_origin: 'nfc_link',
  483. });
  484. expect(api.linkTagToSpoolmanSpool).not.toHaveBeenCalled();
  485. // Local path never sets justLinkedSpool → no Spoolman success toast
  486. expect(mockShowToast).not.toHaveBeenCalled();
  487. // Modal closes via finally, UnknownTagCard still absent (tag still present but no SpoolInfoCard)
  488. expect(screen.queryByText('Link Tag')).toBeNull();
  489. });
  490. });
  491. });
  492. describe('Spoolman mode', () => {
  493. const SPOOLMAN_SPOOL = {
  494. id: 42, material: 'PLA', subtype: null, brand: 'Bambu',
  495. color_name: 'Red', rgba: 'FF0000FF', extra_colors: null, effect_type: null,
  496. label_weight: 1000, core_weight: 250, core_weight_catalog_id: null,
  497. weight_used: 200, tag_uid: 'AABB11223344', tray_uuid: null,
  498. slicer_filament: null, slicer_filament_name: null, nozzle_temp_min: null,
  499. nozzle_temp_max: null, note: null, added_full: null, last_used: null,
  500. encode_time: null, data_origin: 'spoolman', tag_type: null, archived_at: null,
  501. created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
  502. cost_per_kg: null, last_scale_weight: null, last_weighed_at: null,
  503. category: null, low_stock_threshold_pct: null, k_profiles: [], storage_location: null,
  504. };
  505. it('disables "Assign to AMS" button when spool is already assigned', async () => {
  506. const { api } = await import('../../api/client');
  507. vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
  508. spoolman_enabled: 'true', spoolman_url: 'http://localhost:7912',
  509. spoolman_sync_mode: 'off', spoolman_disable_weight_sync: 'false',
  510. spoolman_report_partial_usage: 'false',
  511. } as never);
  512. vi.mocked(api.getSpoolmanInventorySpools).mockResolvedValue([SPOOLMAN_SPOOL] as never);
  513. vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValue([
  514. { printer_id: 1, ams_id: 0, tray_id: 0, spoolman_spool_id: 42 },
  515. ] as never);
  516. renderPage({
  517. deviceOnline: true,
  518. matchedSpool: {
  519. id: 42, tag_uid: 'AABB11223344', material: 'PLA', subtype: null,
  520. color_name: 'Red', rgba: 'FF0000FF', brand: 'Bambu',
  521. label_weight: 1000, core_weight: 250, weight_used: 200,
  522. },
  523. });
  524. await waitFor(() => {
  525. const btn = screen.queryByText('Assign to AMS');
  526. expect(btn).toBeTruthy();
  527. expect(btn).toBeDisabled();
  528. });
  529. });
  530. });
  531. });