FilamentHoverCard.test.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733
  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. import { setColorCatalog, __resetColorCatalogForTests } from '../../utils/colors';
  9. const baseFilamentData = {
  10. vendor: 'Bambu Lab' as const,
  11. profile: 'PLA Basic',
  12. colorName: 'Red',
  13. colorHex: 'FF0000',
  14. kFactor: '0.030',
  15. fillLevel: 75,
  16. trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
  17. };
  18. function renderWithHover(ui: React.ReactElement) {
  19. const result = render(ui);
  20. // Trigger hover to show the card
  21. const trigger = result.container.firstElementChild as HTMLElement;
  22. fireEvent.mouseEnter(trigger);
  23. return result;
  24. }
  25. describe('FilamentHoverCard', () => {
  26. beforeEach(() => {
  27. vi.useFakeTimers({ shouldAdvanceTime: true });
  28. });
  29. describe('fill level display', () => {
  30. it('shows fill percentage when fillLevel is set', async () => {
  31. renderWithHover(
  32. <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 75 }}>
  33. <div>trigger</div>
  34. </FilamentHoverCard>
  35. );
  36. vi.advanceTimersByTime(100);
  37. await waitFor(() => {
  38. expect(screen.getByText('75%')).toBeInTheDocument();
  39. });
  40. });
  41. it('shows dash when fillLevel is null', async () => {
  42. renderWithHover(
  43. <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: null }}>
  44. <div>trigger</div>
  45. </FilamentHoverCard>
  46. );
  47. vi.advanceTimersByTime(100);
  48. await waitFor(() => {
  49. expect(screen.getByText('—')).toBeInTheDocument();
  50. });
  51. });
  52. it('shows 0% when fillLevel is zero', async () => {
  53. renderWithHover(
  54. <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 0 }}>
  55. <div>trigger</div>
  56. </FilamentHoverCard>
  57. );
  58. vi.advanceTimersByTime(100);
  59. await waitFor(() => {
  60. expect(screen.getByText('0%')).toBeInTheDocument();
  61. });
  62. });
  63. });
  64. describe('fill source badge transparency (#11)', () => {
  65. it('never shows a Spoolman-source badge — UI stays mode-agnostic', async () => {
  66. renderWithHover(
  67. <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'spoolman' }}>
  68. <div>trigger</div>
  69. </FilamentHoverCard>
  70. );
  71. vi.advanceTimersByTime(100);
  72. await waitFor(() => {
  73. expect(screen.getByText('80%')).toBeInTheDocument();
  74. expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
  75. });
  76. });
  77. it('never shows an inventory-source badge — UI stays mode-agnostic', async () => {
  78. renderWithHover(
  79. <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'inventory' }}>
  80. <div>trigger</div>
  81. </FilamentHoverCard>
  82. );
  83. vi.advanceTimersByTime(100);
  84. await waitFor(() => {
  85. expect(screen.getByText('80%')).toBeInTheDocument();
  86. expect(screen.queryByText('(Inv)')).not.toBeInTheDocument();
  87. });
  88. });
  89. it('does not render an empty source-label span when fillLevel is null', async () => {
  90. renderWithHover(
  91. <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: null, fillSource: 'spoolman' }}>
  92. <div>trigger</div>
  93. </FilamentHoverCard>
  94. );
  95. vi.advanceTimersByTime(100);
  96. await waitFor(() => {
  97. expect(screen.getByText('—')).toBeInTheDocument();
  98. expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
  99. expect(screen.queryByText('(Inv)')).not.toBeInTheDocument();
  100. });
  101. });
  102. });
  103. describe('hover behavior', () => {
  104. it('does not show card when disabled', () => {
  105. renderWithHover(
  106. <FilamentHoverCard data={baseFilamentData} disabled>
  107. <div>trigger</div>
  108. </FilamentHoverCard>
  109. );
  110. vi.advanceTimersByTime(100);
  111. // Card should not be visible
  112. expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument();
  113. });
  114. it('shows filament details on hover', async () => {
  115. renderWithHover(
  116. <FilamentHoverCard data={baseFilamentData}>
  117. <div>trigger</div>
  118. </FilamentHoverCard>
  119. );
  120. vi.advanceTimersByTime(100);
  121. await waitFor(() => {
  122. expect(screen.getByText('Red')).toBeInTheDocument();
  123. expect(screen.getByText('PLA Basic')).toBeInTheDocument();
  124. expect(screen.getByText('0.030')).toBeInTheDocument();
  125. });
  126. });
  127. });
  128. // The inventory section was previously hidden for `vendor === 'Bambu Lab'`
  129. // because BL spools were assumed to be managed entirely via RFID. #1133
  130. // removed that gate so users who don't want to scan via SpoolBuddy NFC
  131. // can still pick a BL spool from inventory the same way they pick a
  132. // third-party one.
  133. // Paired with the EmptySlotHoverCard assertion below (#2791) — together
  134. // they pin the two render paths to the same Assign-then-Configure order.
  135. it('lists Assign Spool above Configure (#2791)', async () => {
  136. renderWithHover(
  137. <FilamentHoverCard
  138. data={baseFilamentData}
  139. inventory={{ assignedSpool: null, onAssignSpool: vi.fn() }}
  140. configureSlot={{ enabled: true, onConfigure: vi.fn() }}
  141. >
  142. <div>trigger</div>
  143. </FilamentHoverCard>
  144. );
  145. vi.advanceTimersByTime(100);
  146. await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
  147. const assign = screen.getByText(/assign spool/i);
  148. const configure = screen.getByText(/^configure$/i);
  149. expect(assign.compareDocumentPosition(configure)).toBe(
  150. Node.DOCUMENT_POSITION_FOLLOWING
  151. );
  152. });
  153. describe('inventory section vendor visibility (#1133)', () => {
  154. it('shows the assign-spool button on a Bambu Lab slot when the spool is unassigned', async () => {
  155. const onAssign = vi.fn();
  156. renderWithHover(
  157. <FilamentHoverCard
  158. data={{ ...baseFilamentData, vendor: 'Bambu Lab' }}
  159. inventory={{ assignedSpool: null, onAssignSpool: onAssign }}
  160. >
  161. <div>trigger</div>
  162. </FilamentHoverCard>
  163. );
  164. vi.advanceTimersByTime(100);
  165. await waitFor(() => {
  166. expect(screen.getByText(/assign/i)).toBeInTheDocument();
  167. });
  168. });
  169. it('shows the unassign button on a Bambu Lab slot when an inventory spool is already assigned', async () => {
  170. // Regression guard: the original gate hid BOTH the assign and unassign
  171. // buttons for BL slots. A user who'd already assigned an inventory
  172. // spool to a BL slot couldn't undo it without dropping into the
  173. // inventory page directly.
  174. const onUnassign = vi.fn();
  175. renderWithHover(
  176. <FilamentHoverCard
  177. data={{ ...baseFilamentData, vendor: 'Bambu Lab' }}
  178. inventory={{
  179. assignedSpool: {
  180. id: 1,
  181. material: 'PLA',
  182. subtype: null,
  183. brand: 'Devil Design',
  184. color_name: 'Black',
  185. },
  186. onUnassignSpool: onUnassign,
  187. }}
  188. >
  189. <div>trigger</div>
  190. </FilamentHoverCard>
  191. );
  192. vi.advanceTimersByTime(100);
  193. await waitFor(() => {
  194. expect(screen.getByText(/unassign/i)).toBeInTheDocument();
  195. });
  196. });
  197. it('still shows the assign-spool button for a non-Bambu vendor (no behaviour change)', async () => {
  198. const onAssign = vi.fn();
  199. renderWithHover(
  200. <FilamentHoverCard
  201. data={{ ...baseFilamentData, vendor: 'Polymaker' as unknown as 'Bambu Lab' }}
  202. inventory={{ assignedSpool: null, onAssignSpool: onAssign }}
  203. >
  204. <div>trigger</div>
  205. </FilamentHoverCard>
  206. );
  207. vi.advanceTimersByTime(100);
  208. await waitFor(() => {
  209. expect(screen.getByText(/assign/i)).toBeInTheDocument();
  210. });
  211. });
  212. it('shows the assign-spool button as disabled when isAssigned=true', async () => {
  213. const onAssign = vi.fn();
  214. renderWithHover(
  215. <FilamentHoverCard
  216. data={{ ...baseFilamentData, vendor: 'Bambu Lab' }}
  217. inventory={{ assignedSpool: null, onAssignSpool: onAssign, isAssigned: true }}
  218. >
  219. <div>trigger</div>
  220. </FilamentHoverCard>
  221. );
  222. vi.advanceTimersByTime(100);
  223. await waitFor(() => {
  224. expect(screen.getByText(/assign/i)).toBeInTheDocument();
  225. expect(screen.getByText(/assign/i).closest('button')).toBeDisabled();
  226. });
  227. });
  228. it('does not call onAssignSpool when the button is disabled via isAssigned', async () => {
  229. const onAssign = vi.fn();
  230. renderWithHover(
  231. <FilamentHoverCard
  232. data={{ ...baseFilamentData, vendor: 'Bambu Lab' }}
  233. inventory={{ assignedSpool: null, onAssignSpool: onAssign, isAssigned: true }}
  234. >
  235. <div>trigger</div>
  236. </FilamentHoverCard>
  237. );
  238. vi.advanceTimersByTime(100);
  239. await waitFor(() => expect(screen.getByText(/assign/i)).toBeInTheDocument());
  240. const btn = screen.getByText(/assign/i).closest('button')!;
  241. btn.click();
  242. expect(onAssign).not.toHaveBeenCalled();
  243. });
  244. });
  245. // For RFID-synced BL spools, both spoolman.linkedSpoolId and
  246. // inventory.assignedSpool.id point to the same Spoolman spool. Rendering
  247. // both branches gave two identical "Open in Inventory" buttons. The
  248. // inventory-side button is suppressed when it would duplicate the
  249. // spoolman-side link.
  250. describe('"Open in Inventory" deduplication', () => {
  251. const inventorySpool = {
  252. id: 42,
  253. material: 'PLA',
  254. subtype: null,
  255. brand: 'eSun',
  256. color_name: 'Black',
  257. };
  258. it('shows only one Open in Inventory button when spoolman linkedSpoolId matches assignedSpool id', async () => {
  259. renderWithHover(
  260. <FilamentHoverCard
  261. data={baseFilamentData}
  262. spoolman={{ enabled: true, linkedSpoolId: 42 }}
  263. inventory={{ assignedSpool: inventorySpool }}
  264. >
  265. <div>trigger</div>
  266. </FilamentHoverCard>
  267. );
  268. vi.advanceTimersByTime(100);
  269. await waitFor(() => {
  270. expect(screen.getByText(/assigned/i)).toBeInTheDocument();
  271. });
  272. expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(1);
  273. });
  274. it('shows two Open in Inventory buttons when spoolman and inventory point to different spools', async () => {
  275. renderWithHover(
  276. <FilamentHoverCard
  277. data={baseFilamentData}
  278. spoolman={{ enabled: true, linkedSpoolId: 99 }}
  279. inventory={{ assignedSpool: inventorySpool }}
  280. >
  281. <div>trigger</div>
  282. </FilamentHoverCard>
  283. );
  284. vi.advanceTimersByTime(100);
  285. await waitFor(() => {
  286. expect(screen.getByText(/assigned/i)).toBeInTheDocument();
  287. });
  288. expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(2);
  289. });
  290. it('shows one Open in Inventory button when spoolman is absent but inventory spool is assigned', async () => {
  291. renderWithHover(
  292. <FilamentHoverCard
  293. data={baseFilamentData}
  294. inventory={{ assignedSpool: inventorySpool }}
  295. >
  296. <div>trigger</div>
  297. </FilamentHoverCard>
  298. );
  299. vi.advanceTimersByTime(100);
  300. await waitFor(() => {
  301. expect(screen.getByText(/assigned/i)).toBeInTheDocument();
  302. });
  303. expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(1);
  304. });
  305. it('shows the spool ID in the assigned-spool block', async () => {
  306. renderWithHover(
  307. <FilamentHoverCard
  308. data={baseFilamentData}
  309. inventory={{ assignedSpool: inventorySpool }}
  310. >
  311. <div>trigger</div>
  312. </FilamentHoverCard>
  313. );
  314. vi.advanceTimersByTime(100);
  315. await waitFor(() => {
  316. expect(screen.getByText('#42')).toBeInTheDocument();
  317. });
  318. });
  319. });
  320. // The card is portaled at z-[60] — above ConfigureAmsSlotModal and
  321. // LinkSpoolModal at z-50 — so a card left standing draws OVER the dialog its
  322. // own button just opened. Mouseleave is the only thing that used to hide it,
  323. // and a touch device never sends one after the tap that opened the card, so on
  324. // a tablet it hung there indefinitely: two overlapping layers, competing focus.
  325. describe('dismissal when an action opens a dialog (#2631)', () => {
  326. it('closes the card when Configure is pressed, and still configures', async () => {
  327. const onConfigure = vi.fn();
  328. renderWithHover(
  329. <FilamentHoverCard
  330. data={baseFilamentData}
  331. configureSlot={{ enabled: true, onConfigure }}
  332. >
  333. <div>trigger</div>
  334. </FilamentHoverCard>
  335. );
  336. vi.advanceTimersByTime(100);
  337. await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
  338. fireEvent.click(screen.getByText(/configure/i));
  339. expect(onConfigure).toHaveBeenCalledTimes(1);
  340. await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
  341. });
  342. it('stays closed with no mouseleave, which is all a tablet ever gives us', async () => {
  343. renderWithHover(
  344. <FilamentHoverCard
  345. data={baseFilamentData}
  346. configureSlot={{ enabled: true, onConfigure: vi.fn() }}
  347. >
  348. <div>trigger</div>
  349. </FilamentHoverCard>
  350. );
  351. vi.advanceTimersByTime(100);
  352. await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
  353. fireEvent.click(screen.getByText(/configure/i));
  354. await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
  355. // A pending show timer would resurrect the card on top of the dialog.
  356. vi.advanceTimersByTime(1000);
  357. expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument();
  358. });
  359. it('closes the card when Assign Spool is pressed', async () => {
  360. const onAssignSpool = vi.fn();
  361. renderWithHover(
  362. <FilamentHoverCard
  363. data={baseFilamentData}
  364. inventory={{ assignedSpool: null, onAssignSpool }}
  365. >
  366. <div>trigger</div>
  367. </FilamentHoverCard>
  368. );
  369. vi.advanceTimersByTime(100);
  370. await waitFor(() => expect(screen.getByText(/assign/i)).toBeInTheDocument());
  371. fireEvent.click(screen.getByText(/assign/i));
  372. expect(onAssignSpool).toHaveBeenCalledTimes(1);
  373. await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
  374. });
  375. it('closes the card when Unassign Spool is pressed', async () => {
  376. const onUnassignSpool = vi.fn();
  377. renderWithHover(
  378. <FilamentHoverCard
  379. data={baseFilamentData}
  380. inventory={{
  381. assignedSpool: { id: 7, material: 'PLA', subtype: null, brand: 'eSun', color_name: 'Black' },
  382. onUnassignSpool,
  383. }}
  384. >
  385. <div>trigger</div>
  386. </FilamentHoverCard>
  387. );
  388. vi.advanceTimersByTime(100);
  389. await waitFor(() => expect(screen.getByText(/unassign/i)).toBeInTheDocument());
  390. fireEvent.click(screen.getByText(/unassign/i));
  391. expect(onUnassignSpool).toHaveBeenCalledTimes(1);
  392. await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
  393. });
  394. });
  395. });
  396. // EmptySlotHoverCard is the hover wrapper rendered for a physically empty
  397. // AMS slot. #1133 removed its inventory affordance: a slot with nothing
  398. // loaded has no spool to attach an inventory record to, and offering the
  399. // action there only led to users assigning the wrong spool to a slot the
  400. // printer hadn't actually loaded yet. The configure-slot affordance is
  401. // kept, since "preset for the next spool to land here" is still a sensible
  402. // thing to do on an empty slot.
  403. describe('EmptySlotHoverCard (#1133)', () => {
  404. beforeEach(() => {
  405. vi.useFakeTimers({ shouldAdvanceTime: true });
  406. });
  407. it('does not render an assign-spool button when onAssignSpool is not provided', async () => {
  408. const result = render(
  409. <EmptySlotHoverCard configureSlot={{ enabled: true, onConfigure: vi.fn() }}>
  410. <div>trigger</div>
  411. </EmptySlotHoverCard>
  412. );
  413. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  414. vi.advanceTimersByTime(100);
  415. await waitFor(() => {
  416. // The card itself is showing — guard the negative assertion against
  417. // a card that simply never opened.
  418. expect(screen.getByText(/empty/i)).toBeInTheDocument();
  419. });
  420. expect(screen.queryByText(/assign spool/i)).not.toBeInTheDocument();
  421. });
  422. it('still shows the configure button on an empty slot', async () => {
  423. const onConfigure = vi.fn();
  424. const result = render(
  425. <EmptySlotHoverCard configureSlot={{ enabled: true, onConfigure }}>
  426. <div>trigger</div>
  427. </EmptySlotHoverCard>
  428. );
  429. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  430. vi.advanceTimersByTime(100);
  431. await waitFor(() => {
  432. expect(screen.getByText(/configure/i)).toBeInTheDocument();
  433. });
  434. });
  435. it('shows Assign Spool button when onAssignSpool is provided', async () => {
  436. const onAssign = vi.fn();
  437. const result = render(
  438. <EmptySlotHoverCard onAssignSpool={onAssign}>
  439. <div>trigger</div>
  440. </EmptySlotHoverCard>
  441. );
  442. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  443. vi.advanceTimersByTime(100);
  444. await waitFor(() => {
  445. expect(screen.getByText(/assign spool/i)).toBeInTheDocument();
  446. });
  447. });
  448. it('calls onAssignSpool when Assign Spool button is clicked', async () => {
  449. const onAssign = vi.fn();
  450. const result = render(
  451. <EmptySlotHoverCard onAssignSpool={onAssign}>
  452. <div>trigger</div>
  453. </EmptySlotHoverCard>
  454. );
  455. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  456. vi.advanceTimersByTime(100);
  457. await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
  458. fireEvent.click(screen.getByText(/assign spool/i));
  459. expect(onAssign).toHaveBeenCalledTimes(1);
  460. });
  461. // #2791: the empty-slot and filled-slot cards are separate render paths
  462. // that had drifted into opposite orders, so the menu reshuffled itself
  463. // depending on whether the slot happened to hold filament. Both now put
  464. // the spool action above the slot action; assert it on both paths so the
  465. // two can't drift apart again.
  466. it('lists Assign Spool above Configure, matching the filled-slot card (#2791)', async () => {
  467. const result = render(
  468. <EmptySlotHoverCard
  469. configureSlot={{ enabled: true, onConfigure: vi.fn() }}
  470. onAssignSpool={vi.fn()}
  471. >
  472. <div>trigger</div>
  473. </EmptySlotHoverCard>
  474. );
  475. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  476. vi.advanceTimersByTime(100);
  477. await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
  478. const assign = screen.getByText(/assign spool/i);
  479. const configure = screen.getByText(/^configure$/i);
  480. expect(assign.compareDocumentPosition(configure)).toBe(
  481. Node.DOCUMENT_POSITION_FOLLOWING
  482. );
  483. });
  484. // Same z-[60]-over-a-z-50-dialog problem as FilamentHoverCard (#2631).
  485. describe('dismissal when an action opens a dialog (#2631)', () => {
  486. it('closes the card when Configure is pressed, and still configures', async () => {
  487. const onConfigure = vi.fn();
  488. const result = render(
  489. <EmptySlotHoverCard configureSlot={{ enabled: true, onConfigure }}>
  490. <div>trigger</div>
  491. </EmptySlotHoverCard>
  492. );
  493. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  494. vi.advanceTimersByTime(100);
  495. await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
  496. fireEvent.click(screen.getByText(/configure/i));
  497. expect(onConfigure).toHaveBeenCalledTimes(1);
  498. await waitFor(() => expect(screen.queryByText(/empty/i)).not.toBeInTheDocument());
  499. });
  500. it('closes the card when Assign Spool is pressed', async () => {
  501. const onAssign = vi.fn();
  502. const result = render(
  503. <EmptySlotHoverCard onAssignSpool={onAssign}>
  504. <div>trigger</div>
  505. </EmptySlotHoverCard>
  506. );
  507. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  508. vi.advanceTimersByTime(100);
  509. await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
  510. fireEvent.click(screen.getByText(/assign spool/i));
  511. expect(onAssign).toHaveBeenCalledTimes(1);
  512. await waitFor(() => expect(screen.queryByText(/empty/i)).not.toBeInTheDocument());
  513. });
  514. });
  515. });
  516. describe('FilamentHoverCard colour name (#2875)', () => {
  517. beforeEach(() => {
  518. vi.useFakeTimers({ shouldAdvanceTime: true });
  519. __resetColorCatalogForTests();
  520. });
  521. const whiteMatte = {
  522. ...baseFilamentData,
  523. profile: 'Bambu PLA Matte',
  524. colorHex: 'FFFFFFFF',
  525. // What PrintersPage now resolves with the slot's own tray_sub_brands.
  526. colorName: 'Ivory White',
  527. };
  528. async function showCard(ui: React.ReactElement) {
  529. renderWithHover(ui);
  530. vi.advanceTimersByTime(100);
  531. }
  532. it('shows the resolved catalogue name for the slot', async () => {
  533. await showCard(
  534. <FilamentHoverCard data={whiteMatte}>
  535. <div>trigger</div>
  536. </FilamentHoverCard>
  537. );
  538. await waitFor(() => expect(screen.getByText('Ivory White')).toBeInTheDocument());
  539. expect(screen.queryByText('Jade White')).not.toBeInTheDocument();
  540. });
  541. it('prefers the assigned spool name, which is what the user put in the slot', async () => {
  542. await showCard(
  543. <FilamentHoverCard
  544. data={{ ...whiteMatte, colorName: 'Jade White' }}
  545. inventory={{
  546. isAssigned: true,
  547. assignedSpool: { id: 17, material: 'PLA', subtype: null, brand: 'Bambu Lab', color_name: 'Matte Ivory White' },
  548. }}
  549. >
  550. <div>trigger</div>
  551. </FilamentHoverCard>
  552. );
  553. await waitFor(() => expect(screen.getByText('Matte Ivory White')).toBeInTheDocument());
  554. expect(screen.queryByText('Jade White')).not.toBeInTheDocument();
  555. });
  556. it('ignores a Bambu internal colour code on the assigned spool', async () => {
  557. // "A06-D0" is not a name, and is not unique across material families
  558. // (#857) -- the catalogue answer stands.
  559. await showCard(
  560. <FilamentHoverCard
  561. data={whiteMatte}
  562. inventory={{
  563. isAssigned: true,
  564. assignedSpool: { id: 17, material: 'PLA', subtype: null, brand: 'Bambu Lab', color_name: 'A06-D0' },
  565. }}
  566. >
  567. <div>trigger</div>
  568. </FilamentHoverCard>
  569. );
  570. await waitFor(() => expect(screen.getByText('Ivory White')).toBeInTheDocument());
  571. expect(screen.queryByText('A06-D0')).not.toBeInTheDocument();
  572. });
  573. it.each([
  574. ['an empty colour name', ''],
  575. ['a whitespace-only colour name', ' '],
  576. ])('keeps the catalogue answer for a spool with %s', async (_label, colorName) => {
  577. await showCard(
  578. <FilamentHoverCard
  579. data={whiteMatte}
  580. inventory={{
  581. isAssigned: true,
  582. assignedSpool: { id: 17, material: 'PLA', subtype: null, brand: 'Bambu Lab', color_name: colorName },
  583. }}
  584. >
  585. <div>trigger</div>
  586. </FilamentHoverCard>
  587. );
  588. await waitFor(() => expect(screen.getByText('Ivory White')).toBeInTheDocument());
  589. });
  590. it('keeps the catalogue answer when a spool is assigned with no colour recorded', async () => {
  591. setColorCatalog({ ffffff: 'Jade White' }, { 'pla matte|ffffff': 'Ivory White' });
  592. await showCard(
  593. <FilamentHoverCard
  594. data={whiteMatte}
  595. inventory={{
  596. isAssigned: true,
  597. assignedSpool: { id: 17, material: 'PLA', subtype: null, brand: 'Bambu Lab', color_name: null },
  598. }}
  599. >
  600. <div>trigger</div>
  601. </FilamentHoverCard>
  602. );
  603. await waitFor(() => expect(screen.getByText('Ivory White')).toBeInTheDocument());
  604. });
  605. });
  606. // A spool's subtype is part of its name. Dropping it made a wood-filled roll
  607. // read as plain PLA on the slot card, which is the display-side version of
  608. // the mistake #2902 fixed on the backend -- and the printer, the inventory
  609. // page and Studio all named it correctly at the same time, so the card was
  610. // the only thing saying otherwise.
  611. describe('FilamentHoverCard assigned spool name', () => {
  612. beforeEach(() => {
  613. vi.useFakeTimers({ shouldAdvanceTime: true });
  614. __resetColorCatalogForTests();
  615. });
  616. function showAssigned(assignedSpool: {
  617. id: number;
  618. material: string;
  619. subtype: string | null;
  620. brand: string | null;
  621. color_name: string | null;
  622. }) {
  623. renderWithHover(
  624. <FilamentHoverCard data={baseFilamentData} inventory={{ isAssigned: true, assignedSpool }}>
  625. <div>trigger</div>
  626. </FilamentHoverCard>
  627. );
  628. vi.advanceTimersByTime(100);
  629. }
  630. it('names a filled filament by its subtype, not by its base material', async () => {
  631. showAssigned({
  632. id: 77,
  633. material: 'PLA',
  634. subtype: 'Wood',
  635. brand: 'Bambu Lab',
  636. color_name: 'Classic Birch',
  637. });
  638. await waitFor(() =>
  639. expect(screen.getByText('Bambu Lab PLA Wood - Classic Birch')).toBeInTheDocument()
  640. );
  641. });
  642. it('omits the subtype entirely for a spool that has none', async () => {
  643. showAssigned({
  644. id: 78,
  645. material: 'PLA',
  646. subtype: null,
  647. brand: 'Bambu Lab',
  648. color_name: 'Jade White',
  649. });
  650. await waitFor(() =>
  651. expect(screen.getByText('Bambu Lab PLA - Jade White')).toBeInTheDocument()
  652. );
  653. });
  654. });