FilamentHoverCard.test.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  1. /**
  2. * Tests for the FilamentHoverCard component.
  3. * Focuses on fill level display and Spoolman source indicator.
  4. */
  5. import { describe, it, expect, vi, beforeEach } from 'vitest';
  6. import { render, screen, fireEvent, waitFor } from '../utils';
  7. import { FilamentHoverCard, EmptySlotHoverCard } from '../../components/FilamentHoverCard';
  8. const baseFilamentData = {
  9. vendor: 'Bambu Lab' as const,
  10. profile: 'PLA Basic',
  11. colorName: 'Red',
  12. colorHex: 'FF0000',
  13. kFactor: '0.030',
  14. fillLevel: 75,
  15. trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
  16. };
  17. function renderWithHover(ui: React.ReactElement) {
  18. const result = render(ui);
  19. // Trigger hover to show the card
  20. const trigger = result.container.firstElementChild as HTMLElement;
  21. fireEvent.mouseEnter(trigger);
  22. return result;
  23. }
  24. describe('FilamentHoverCard', () => {
  25. beforeEach(() => {
  26. vi.useFakeTimers({ shouldAdvanceTime: true });
  27. });
  28. describe('fill level display', () => {
  29. it('shows fill percentage when fillLevel is set', async () => {
  30. renderWithHover(
  31. <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 75 }}>
  32. <div>trigger</div>
  33. </FilamentHoverCard>
  34. );
  35. vi.advanceTimersByTime(100);
  36. await waitFor(() => {
  37. expect(screen.getByText('75%')).toBeInTheDocument();
  38. });
  39. });
  40. it('shows dash when fillLevel is null', async () => {
  41. renderWithHover(
  42. <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: null }}>
  43. <div>trigger</div>
  44. </FilamentHoverCard>
  45. );
  46. vi.advanceTimersByTime(100);
  47. await waitFor(() => {
  48. expect(screen.getByText('—')).toBeInTheDocument();
  49. });
  50. });
  51. it('shows 0% when fillLevel is zero', async () => {
  52. renderWithHover(
  53. <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 0 }}>
  54. <div>trigger</div>
  55. </FilamentHoverCard>
  56. );
  57. vi.advanceTimersByTime(100);
  58. await waitFor(() => {
  59. expect(screen.getByText('0%')).toBeInTheDocument();
  60. });
  61. });
  62. });
  63. describe('fill source badge transparency (#11)', () => {
  64. it('never shows a Spoolman-source badge — UI stays mode-agnostic', async () => {
  65. renderWithHover(
  66. <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'spoolman' }}>
  67. <div>trigger</div>
  68. </FilamentHoverCard>
  69. );
  70. vi.advanceTimersByTime(100);
  71. await waitFor(() => {
  72. expect(screen.getByText('80%')).toBeInTheDocument();
  73. expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
  74. });
  75. });
  76. it('never shows an inventory-source badge — UI stays mode-agnostic', async () => {
  77. renderWithHover(
  78. <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'inventory' }}>
  79. <div>trigger</div>
  80. </FilamentHoverCard>
  81. );
  82. vi.advanceTimersByTime(100);
  83. await waitFor(() => {
  84. expect(screen.getByText('80%')).toBeInTheDocument();
  85. expect(screen.queryByText('(Inv)')).not.toBeInTheDocument();
  86. });
  87. });
  88. it('does not render an empty source-label span when fillLevel is null', async () => {
  89. renderWithHover(
  90. <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: null, fillSource: 'spoolman' }}>
  91. <div>trigger</div>
  92. </FilamentHoverCard>
  93. );
  94. vi.advanceTimersByTime(100);
  95. await waitFor(() => {
  96. expect(screen.getByText('—')).toBeInTheDocument();
  97. expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
  98. expect(screen.queryByText('(Inv)')).not.toBeInTheDocument();
  99. });
  100. });
  101. });
  102. describe('hover behavior', () => {
  103. it('does not show card when disabled', () => {
  104. renderWithHover(
  105. <FilamentHoverCard data={baseFilamentData} disabled>
  106. <div>trigger</div>
  107. </FilamentHoverCard>
  108. );
  109. vi.advanceTimersByTime(100);
  110. // Card should not be visible
  111. expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument();
  112. });
  113. it('shows filament details on hover', async () => {
  114. renderWithHover(
  115. <FilamentHoverCard data={baseFilamentData}>
  116. <div>trigger</div>
  117. </FilamentHoverCard>
  118. );
  119. vi.advanceTimersByTime(100);
  120. await waitFor(() => {
  121. expect(screen.getByText('Red')).toBeInTheDocument();
  122. expect(screen.getByText('PLA Basic')).toBeInTheDocument();
  123. expect(screen.getByText('0.030')).toBeInTheDocument();
  124. });
  125. });
  126. });
  127. // The inventory section was previously hidden for `vendor === 'Bambu Lab'`
  128. // because BL spools were assumed to be managed entirely via RFID. #1133
  129. // removed that gate so users who don't want to scan via SpoolBuddy NFC
  130. // can still pick a BL spool from inventory the same way they pick a
  131. // third-party one.
  132. describe('inventory section vendor visibility (#1133)', () => {
  133. it('shows the assign-spool button on a Bambu Lab slot when the spool is unassigned', async () => {
  134. const onAssign = vi.fn();
  135. renderWithHover(
  136. <FilamentHoverCard
  137. data={{ ...baseFilamentData, vendor: 'Bambu Lab' }}
  138. inventory={{ assignedSpool: null, onAssignSpool: onAssign }}
  139. >
  140. <div>trigger</div>
  141. </FilamentHoverCard>
  142. );
  143. vi.advanceTimersByTime(100);
  144. await waitFor(() => {
  145. expect(screen.getByText(/assign/i)).toBeInTheDocument();
  146. });
  147. });
  148. it('shows the unassign button on a Bambu Lab slot when an inventory spool is already assigned', async () => {
  149. // Regression guard: the original gate hid BOTH the assign and unassign
  150. // buttons for BL slots. A user who'd already assigned an inventory
  151. // spool to a BL slot couldn't undo it without dropping into the
  152. // inventory page directly.
  153. const onUnassign = vi.fn();
  154. renderWithHover(
  155. <FilamentHoverCard
  156. data={{ ...baseFilamentData, vendor: 'Bambu Lab' }}
  157. inventory={{
  158. assignedSpool: {
  159. id: 1,
  160. material: 'PLA',
  161. brand: 'Devil Design',
  162. color_name: 'Black',
  163. },
  164. onUnassignSpool: onUnassign,
  165. }}
  166. >
  167. <div>trigger</div>
  168. </FilamentHoverCard>
  169. );
  170. vi.advanceTimersByTime(100);
  171. await waitFor(() => {
  172. expect(screen.getByText(/unassign/i)).toBeInTheDocument();
  173. });
  174. });
  175. it('still shows the assign-spool button for a non-Bambu vendor (no behaviour change)', async () => {
  176. const onAssign = vi.fn();
  177. renderWithHover(
  178. <FilamentHoverCard
  179. data={{ ...baseFilamentData, vendor: 'Polymaker' as unknown as 'Bambu Lab' }}
  180. inventory={{ assignedSpool: null, onAssignSpool: onAssign }}
  181. >
  182. <div>trigger</div>
  183. </FilamentHoverCard>
  184. );
  185. vi.advanceTimersByTime(100);
  186. await waitFor(() => {
  187. expect(screen.getByText(/assign/i)).toBeInTheDocument();
  188. });
  189. });
  190. it('shows the assign-spool button as disabled when isAssigned=true', async () => {
  191. const onAssign = vi.fn();
  192. renderWithHover(
  193. <FilamentHoverCard
  194. data={{ ...baseFilamentData, vendor: 'Bambu Lab' }}
  195. inventory={{ assignedSpool: null, onAssignSpool: onAssign, isAssigned: true }}
  196. >
  197. <div>trigger</div>
  198. </FilamentHoverCard>
  199. );
  200. vi.advanceTimersByTime(100);
  201. await waitFor(() => {
  202. expect(screen.getByText(/assign/i)).toBeInTheDocument();
  203. expect(screen.getByText(/assign/i).closest('button')).toBeDisabled();
  204. });
  205. });
  206. it('does not call onAssignSpool when the button is disabled via isAssigned', async () => {
  207. const onAssign = vi.fn();
  208. renderWithHover(
  209. <FilamentHoverCard
  210. data={{ ...baseFilamentData, vendor: 'Bambu Lab' }}
  211. inventory={{ assignedSpool: null, onAssignSpool: onAssign, isAssigned: true }}
  212. >
  213. <div>trigger</div>
  214. </FilamentHoverCard>
  215. );
  216. vi.advanceTimersByTime(100);
  217. await waitFor(() => expect(screen.getByText(/assign/i)).toBeInTheDocument());
  218. const btn = screen.getByText(/assign/i).closest('button')!;
  219. btn.click();
  220. expect(onAssign).not.toHaveBeenCalled();
  221. });
  222. });
  223. // For RFID-synced BL spools, both spoolman.linkedSpoolId and
  224. // inventory.assignedSpool.id point to the same Spoolman spool. Rendering
  225. // both branches gave two identical "Open in Inventory" buttons. The
  226. // inventory-side button is suppressed when it would duplicate the
  227. // spoolman-side link.
  228. describe('"Open in Inventory" deduplication', () => {
  229. const inventorySpool = {
  230. id: 42,
  231. material: 'PLA',
  232. brand: 'eSun',
  233. color_name: 'Black',
  234. };
  235. it('shows only one Open in Inventory button when spoolman linkedSpoolId matches assignedSpool id', async () => {
  236. renderWithHover(
  237. <FilamentHoverCard
  238. data={baseFilamentData}
  239. spoolman={{ enabled: true, linkedSpoolId: 42 }}
  240. inventory={{ assignedSpool: inventorySpool }}
  241. >
  242. <div>trigger</div>
  243. </FilamentHoverCard>
  244. );
  245. vi.advanceTimersByTime(100);
  246. await waitFor(() => {
  247. expect(screen.getByText(/assigned/i)).toBeInTheDocument();
  248. });
  249. expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(1);
  250. });
  251. it('shows two Open in Inventory buttons when spoolman and inventory point to different spools', async () => {
  252. renderWithHover(
  253. <FilamentHoverCard
  254. data={baseFilamentData}
  255. spoolman={{ enabled: true, linkedSpoolId: 99 }}
  256. inventory={{ assignedSpool: inventorySpool }}
  257. >
  258. <div>trigger</div>
  259. </FilamentHoverCard>
  260. );
  261. vi.advanceTimersByTime(100);
  262. await waitFor(() => {
  263. expect(screen.getByText(/assigned/i)).toBeInTheDocument();
  264. });
  265. expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(2);
  266. });
  267. it('shows one Open in Inventory button when spoolman is absent but inventory spool is assigned', async () => {
  268. renderWithHover(
  269. <FilamentHoverCard
  270. data={baseFilamentData}
  271. inventory={{ assignedSpool: inventorySpool }}
  272. >
  273. <div>trigger</div>
  274. </FilamentHoverCard>
  275. );
  276. vi.advanceTimersByTime(100);
  277. await waitFor(() => {
  278. expect(screen.getByText(/assigned/i)).toBeInTheDocument();
  279. });
  280. expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(1);
  281. });
  282. it('shows the spool ID in the assigned-spool block', async () => {
  283. renderWithHover(
  284. <FilamentHoverCard
  285. data={baseFilamentData}
  286. inventory={{ assignedSpool: inventorySpool }}
  287. >
  288. <div>trigger</div>
  289. </FilamentHoverCard>
  290. );
  291. vi.advanceTimersByTime(100);
  292. await waitFor(() => {
  293. expect(screen.getByText('#42')).toBeInTheDocument();
  294. });
  295. });
  296. });
  297. // The card is portaled at z-[60] — above ConfigureAmsSlotModal and
  298. // LinkSpoolModal at z-50 — so a card left standing draws OVER the dialog its
  299. // own button just opened. Mouseleave is the only thing that used to hide it,
  300. // and a touch device never sends one after the tap that opened the card, so on
  301. // a tablet it hung there indefinitely: two overlapping layers, competing focus.
  302. describe('dismissal when an action opens a dialog (#2631)', () => {
  303. it('closes the card when Configure is pressed, and still configures', async () => {
  304. const onConfigure = vi.fn();
  305. renderWithHover(
  306. <FilamentHoverCard
  307. data={baseFilamentData}
  308. configureSlot={{ enabled: true, onConfigure }}
  309. >
  310. <div>trigger</div>
  311. </FilamentHoverCard>
  312. );
  313. vi.advanceTimersByTime(100);
  314. await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
  315. fireEvent.click(screen.getByText(/configure/i));
  316. expect(onConfigure).toHaveBeenCalledTimes(1);
  317. await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
  318. });
  319. it('stays closed with no mouseleave, which is all a tablet ever gives us', async () => {
  320. renderWithHover(
  321. <FilamentHoverCard
  322. data={baseFilamentData}
  323. configureSlot={{ enabled: true, onConfigure: vi.fn() }}
  324. >
  325. <div>trigger</div>
  326. </FilamentHoverCard>
  327. );
  328. vi.advanceTimersByTime(100);
  329. await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
  330. fireEvent.click(screen.getByText(/configure/i));
  331. await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
  332. // A pending show timer would resurrect the card on top of the dialog.
  333. vi.advanceTimersByTime(1000);
  334. expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument();
  335. });
  336. it('closes the card when Assign Spool is pressed', async () => {
  337. const onAssignSpool = vi.fn();
  338. renderWithHover(
  339. <FilamentHoverCard
  340. data={baseFilamentData}
  341. inventory={{ assignedSpool: null, onAssignSpool }}
  342. >
  343. <div>trigger</div>
  344. </FilamentHoverCard>
  345. );
  346. vi.advanceTimersByTime(100);
  347. await waitFor(() => expect(screen.getByText(/assign/i)).toBeInTheDocument());
  348. fireEvent.click(screen.getByText(/assign/i));
  349. expect(onAssignSpool).toHaveBeenCalledTimes(1);
  350. await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
  351. });
  352. it('closes the card when Unassign Spool is pressed', async () => {
  353. const onUnassignSpool = vi.fn();
  354. renderWithHover(
  355. <FilamentHoverCard
  356. data={baseFilamentData}
  357. inventory={{
  358. assignedSpool: { id: 7, material: 'PLA', brand: 'eSun', color_name: 'Black' },
  359. onUnassignSpool,
  360. }}
  361. >
  362. <div>trigger</div>
  363. </FilamentHoverCard>
  364. );
  365. vi.advanceTimersByTime(100);
  366. await waitFor(() => expect(screen.getByText(/unassign/i)).toBeInTheDocument());
  367. fireEvent.click(screen.getByText(/unassign/i));
  368. expect(onUnassignSpool).toHaveBeenCalledTimes(1);
  369. await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
  370. });
  371. });
  372. });
  373. // EmptySlotHoverCard is the hover wrapper rendered for a physically empty
  374. // AMS slot. #1133 removed its inventory affordance: a slot with nothing
  375. // loaded has no spool to attach an inventory record to, and offering the
  376. // action there only led to users assigning the wrong spool to a slot the
  377. // printer hadn't actually loaded yet. The configure-slot affordance is
  378. // kept, since "preset for the next spool to land here" is still a sensible
  379. // thing to do on an empty slot.
  380. describe('EmptySlotHoverCard (#1133)', () => {
  381. beforeEach(() => {
  382. vi.useFakeTimers({ shouldAdvanceTime: true });
  383. });
  384. it('does not render an assign-spool button when onAssignSpool is not provided', async () => {
  385. const result = render(
  386. <EmptySlotHoverCard configureSlot={{ enabled: true, onConfigure: vi.fn() }}>
  387. <div>trigger</div>
  388. </EmptySlotHoverCard>
  389. );
  390. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  391. vi.advanceTimersByTime(100);
  392. await waitFor(() => {
  393. // The card itself is showing — guard the negative assertion against
  394. // a card that simply never opened.
  395. expect(screen.getByText(/empty/i)).toBeInTheDocument();
  396. });
  397. expect(screen.queryByText(/assign spool/i)).not.toBeInTheDocument();
  398. });
  399. it('still shows the configure button on an empty slot', async () => {
  400. const onConfigure = vi.fn();
  401. const result = render(
  402. <EmptySlotHoverCard configureSlot={{ enabled: true, onConfigure }}>
  403. <div>trigger</div>
  404. </EmptySlotHoverCard>
  405. );
  406. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  407. vi.advanceTimersByTime(100);
  408. await waitFor(() => {
  409. expect(screen.getByText(/configure/i)).toBeInTheDocument();
  410. });
  411. });
  412. it('shows Assign Spool button when onAssignSpool is provided', async () => {
  413. const onAssign = vi.fn();
  414. const result = render(
  415. <EmptySlotHoverCard onAssignSpool={onAssign}>
  416. <div>trigger</div>
  417. </EmptySlotHoverCard>
  418. );
  419. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  420. vi.advanceTimersByTime(100);
  421. await waitFor(() => {
  422. expect(screen.getByText(/assign spool/i)).toBeInTheDocument();
  423. });
  424. });
  425. it('calls onAssignSpool when Assign Spool button is clicked', async () => {
  426. const onAssign = vi.fn();
  427. const result = render(
  428. <EmptySlotHoverCard onAssignSpool={onAssign}>
  429. <div>trigger</div>
  430. </EmptySlotHoverCard>
  431. );
  432. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  433. vi.advanceTimersByTime(100);
  434. await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
  435. fireEvent.click(screen.getByText(/assign spool/i));
  436. expect(onAssign).toHaveBeenCalledTimes(1);
  437. });
  438. // Same z-[60]-over-a-z-50-dialog problem as FilamentHoverCard (#2631).
  439. describe('dismissal when an action opens a dialog (#2631)', () => {
  440. it('closes the card when Configure is pressed, and still configures', async () => {
  441. const onConfigure = vi.fn();
  442. const result = render(
  443. <EmptySlotHoverCard configureSlot={{ enabled: true, onConfigure }}>
  444. <div>trigger</div>
  445. </EmptySlotHoverCard>
  446. );
  447. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  448. vi.advanceTimersByTime(100);
  449. await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
  450. fireEvent.click(screen.getByText(/configure/i));
  451. expect(onConfigure).toHaveBeenCalledTimes(1);
  452. await waitFor(() => expect(screen.queryByText(/empty/i)).not.toBeInTheDocument());
  453. });
  454. it('closes the card when Assign Spool is pressed', async () => {
  455. const onAssign = vi.fn();
  456. const result = render(
  457. <EmptySlotHoverCard onAssignSpool={onAssign}>
  458. <div>trigger</div>
  459. </EmptySlotHoverCard>
  460. );
  461. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  462. vi.advanceTimersByTime(100);
  463. await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
  464. fireEvent.click(screen.getByText(/assign spool/i));
  465. expect(onAssign).toHaveBeenCalledTimes(1);
  466. await waitFor(() => expect(screen.queryByText(/empty/i)).not.toBeInTheDocument());
  467. });
  468. });
  469. });