FilamentHoverCard.test.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676
  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. brand: 'Devil Design',
  183. color_name: 'Black',
  184. },
  185. onUnassignSpool: onUnassign,
  186. }}
  187. >
  188. <div>trigger</div>
  189. </FilamentHoverCard>
  190. );
  191. vi.advanceTimersByTime(100);
  192. await waitFor(() => {
  193. expect(screen.getByText(/unassign/i)).toBeInTheDocument();
  194. });
  195. });
  196. it('still shows the assign-spool button for a non-Bambu vendor (no behaviour change)', async () => {
  197. const onAssign = vi.fn();
  198. renderWithHover(
  199. <FilamentHoverCard
  200. data={{ ...baseFilamentData, vendor: 'Polymaker' as unknown as 'Bambu Lab' }}
  201. inventory={{ assignedSpool: null, onAssignSpool: onAssign }}
  202. >
  203. <div>trigger</div>
  204. </FilamentHoverCard>
  205. );
  206. vi.advanceTimersByTime(100);
  207. await waitFor(() => {
  208. expect(screen.getByText(/assign/i)).toBeInTheDocument();
  209. });
  210. });
  211. it('shows the assign-spool button as disabled when isAssigned=true', async () => {
  212. const onAssign = vi.fn();
  213. renderWithHover(
  214. <FilamentHoverCard
  215. data={{ ...baseFilamentData, vendor: 'Bambu Lab' }}
  216. inventory={{ assignedSpool: null, onAssignSpool: onAssign, isAssigned: true }}
  217. >
  218. <div>trigger</div>
  219. </FilamentHoverCard>
  220. );
  221. vi.advanceTimersByTime(100);
  222. await waitFor(() => {
  223. expect(screen.getByText(/assign/i)).toBeInTheDocument();
  224. expect(screen.getByText(/assign/i).closest('button')).toBeDisabled();
  225. });
  226. });
  227. it('does not call onAssignSpool when the button is disabled via isAssigned', async () => {
  228. const onAssign = vi.fn();
  229. renderWithHover(
  230. <FilamentHoverCard
  231. data={{ ...baseFilamentData, vendor: 'Bambu Lab' }}
  232. inventory={{ assignedSpool: null, onAssignSpool: onAssign, isAssigned: true }}
  233. >
  234. <div>trigger</div>
  235. </FilamentHoverCard>
  236. );
  237. vi.advanceTimersByTime(100);
  238. await waitFor(() => expect(screen.getByText(/assign/i)).toBeInTheDocument());
  239. const btn = screen.getByText(/assign/i).closest('button')!;
  240. btn.click();
  241. expect(onAssign).not.toHaveBeenCalled();
  242. });
  243. });
  244. // For RFID-synced BL spools, both spoolman.linkedSpoolId and
  245. // inventory.assignedSpool.id point to the same Spoolman spool. Rendering
  246. // both branches gave two identical "Open in Inventory" buttons. The
  247. // inventory-side button is suppressed when it would duplicate the
  248. // spoolman-side link.
  249. describe('"Open in Inventory" deduplication', () => {
  250. const inventorySpool = {
  251. id: 42,
  252. material: 'PLA',
  253. brand: 'eSun',
  254. color_name: 'Black',
  255. };
  256. it('shows only one Open in Inventory button when spoolman linkedSpoolId matches assignedSpool id', async () => {
  257. renderWithHover(
  258. <FilamentHoverCard
  259. data={baseFilamentData}
  260. spoolman={{ enabled: true, linkedSpoolId: 42 }}
  261. inventory={{ assignedSpool: inventorySpool }}
  262. >
  263. <div>trigger</div>
  264. </FilamentHoverCard>
  265. );
  266. vi.advanceTimersByTime(100);
  267. await waitFor(() => {
  268. expect(screen.getByText(/assigned/i)).toBeInTheDocument();
  269. });
  270. expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(1);
  271. });
  272. it('shows two Open in Inventory buttons when spoolman and inventory point to different spools', async () => {
  273. renderWithHover(
  274. <FilamentHoverCard
  275. data={baseFilamentData}
  276. spoolman={{ enabled: true, linkedSpoolId: 99 }}
  277. inventory={{ assignedSpool: inventorySpool }}
  278. >
  279. <div>trigger</div>
  280. </FilamentHoverCard>
  281. );
  282. vi.advanceTimersByTime(100);
  283. await waitFor(() => {
  284. expect(screen.getByText(/assigned/i)).toBeInTheDocument();
  285. });
  286. expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(2);
  287. });
  288. it('shows one Open in Inventory button when spoolman is absent but inventory spool is assigned', async () => {
  289. renderWithHover(
  290. <FilamentHoverCard
  291. data={baseFilamentData}
  292. inventory={{ assignedSpool: inventorySpool }}
  293. >
  294. <div>trigger</div>
  295. </FilamentHoverCard>
  296. );
  297. vi.advanceTimersByTime(100);
  298. await waitFor(() => {
  299. expect(screen.getByText(/assigned/i)).toBeInTheDocument();
  300. });
  301. expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(1);
  302. });
  303. it('shows the spool ID in the assigned-spool block', async () => {
  304. renderWithHover(
  305. <FilamentHoverCard
  306. data={baseFilamentData}
  307. inventory={{ assignedSpool: inventorySpool }}
  308. >
  309. <div>trigger</div>
  310. </FilamentHoverCard>
  311. );
  312. vi.advanceTimersByTime(100);
  313. await waitFor(() => {
  314. expect(screen.getByText('#42')).toBeInTheDocument();
  315. });
  316. });
  317. });
  318. // The card is portaled at z-[60] — above ConfigureAmsSlotModal and
  319. // LinkSpoolModal at z-50 — so a card left standing draws OVER the dialog its
  320. // own button just opened. Mouseleave is the only thing that used to hide it,
  321. // and a touch device never sends one after the tap that opened the card, so on
  322. // a tablet it hung there indefinitely: two overlapping layers, competing focus.
  323. describe('dismissal when an action opens a dialog (#2631)', () => {
  324. it('closes the card when Configure is pressed, and still configures', async () => {
  325. const onConfigure = vi.fn();
  326. renderWithHover(
  327. <FilamentHoverCard
  328. data={baseFilamentData}
  329. configureSlot={{ enabled: true, onConfigure }}
  330. >
  331. <div>trigger</div>
  332. </FilamentHoverCard>
  333. );
  334. vi.advanceTimersByTime(100);
  335. await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
  336. fireEvent.click(screen.getByText(/configure/i));
  337. expect(onConfigure).toHaveBeenCalledTimes(1);
  338. await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
  339. });
  340. it('stays closed with no mouseleave, which is all a tablet ever gives us', async () => {
  341. renderWithHover(
  342. <FilamentHoverCard
  343. data={baseFilamentData}
  344. configureSlot={{ enabled: true, onConfigure: vi.fn() }}
  345. >
  346. <div>trigger</div>
  347. </FilamentHoverCard>
  348. );
  349. vi.advanceTimersByTime(100);
  350. await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
  351. fireEvent.click(screen.getByText(/configure/i));
  352. await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
  353. // A pending show timer would resurrect the card on top of the dialog.
  354. vi.advanceTimersByTime(1000);
  355. expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument();
  356. });
  357. it('closes the card when Assign Spool is pressed', async () => {
  358. const onAssignSpool = vi.fn();
  359. renderWithHover(
  360. <FilamentHoverCard
  361. data={baseFilamentData}
  362. inventory={{ assignedSpool: null, onAssignSpool }}
  363. >
  364. <div>trigger</div>
  365. </FilamentHoverCard>
  366. );
  367. vi.advanceTimersByTime(100);
  368. await waitFor(() => expect(screen.getByText(/assign/i)).toBeInTheDocument());
  369. fireEvent.click(screen.getByText(/assign/i));
  370. expect(onAssignSpool).toHaveBeenCalledTimes(1);
  371. await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
  372. });
  373. it('closes the card when Unassign Spool is pressed', async () => {
  374. const onUnassignSpool = vi.fn();
  375. renderWithHover(
  376. <FilamentHoverCard
  377. data={baseFilamentData}
  378. inventory={{
  379. assignedSpool: { id: 7, material: 'PLA', brand: 'eSun', color_name: 'Black' },
  380. onUnassignSpool,
  381. }}
  382. >
  383. <div>trigger</div>
  384. </FilamentHoverCard>
  385. );
  386. vi.advanceTimersByTime(100);
  387. await waitFor(() => expect(screen.getByText(/unassign/i)).toBeInTheDocument());
  388. fireEvent.click(screen.getByText(/unassign/i));
  389. expect(onUnassignSpool).toHaveBeenCalledTimes(1);
  390. await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
  391. });
  392. });
  393. });
  394. // EmptySlotHoverCard is the hover wrapper rendered for a physically empty
  395. // AMS slot. #1133 removed its inventory affordance: a slot with nothing
  396. // loaded has no spool to attach an inventory record to, and offering the
  397. // action there only led to users assigning the wrong spool to a slot the
  398. // printer hadn't actually loaded yet. The configure-slot affordance is
  399. // kept, since "preset for the next spool to land here" is still a sensible
  400. // thing to do on an empty slot.
  401. describe('EmptySlotHoverCard (#1133)', () => {
  402. beforeEach(() => {
  403. vi.useFakeTimers({ shouldAdvanceTime: true });
  404. });
  405. it('does not render an assign-spool button when onAssignSpool is not provided', async () => {
  406. const result = render(
  407. <EmptySlotHoverCard configureSlot={{ enabled: true, onConfigure: vi.fn() }}>
  408. <div>trigger</div>
  409. </EmptySlotHoverCard>
  410. );
  411. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  412. vi.advanceTimersByTime(100);
  413. await waitFor(() => {
  414. // The card itself is showing — guard the negative assertion against
  415. // a card that simply never opened.
  416. expect(screen.getByText(/empty/i)).toBeInTheDocument();
  417. });
  418. expect(screen.queryByText(/assign spool/i)).not.toBeInTheDocument();
  419. });
  420. it('still shows the configure button on an empty slot', async () => {
  421. const onConfigure = vi.fn();
  422. const result = render(
  423. <EmptySlotHoverCard configureSlot={{ enabled: true, onConfigure }}>
  424. <div>trigger</div>
  425. </EmptySlotHoverCard>
  426. );
  427. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  428. vi.advanceTimersByTime(100);
  429. await waitFor(() => {
  430. expect(screen.getByText(/configure/i)).toBeInTheDocument();
  431. });
  432. });
  433. it('shows Assign Spool button when onAssignSpool is provided', async () => {
  434. const onAssign = vi.fn();
  435. const result = render(
  436. <EmptySlotHoverCard onAssignSpool={onAssign}>
  437. <div>trigger</div>
  438. </EmptySlotHoverCard>
  439. );
  440. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  441. vi.advanceTimersByTime(100);
  442. await waitFor(() => {
  443. expect(screen.getByText(/assign spool/i)).toBeInTheDocument();
  444. });
  445. });
  446. it('calls onAssignSpool when Assign Spool button is clicked', async () => {
  447. const onAssign = vi.fn();
  448. const result = render(
  449. <EmptySlotHoverCard onAssignSpool={onAssign}>
  450. <div>trigger</div>
  451. </EmptySlotHoverCard>
  452. );
  453. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  454. vi.advanceTimersByTime(100);
  455. await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
  456. fireEvent.click(screen.getByText(/assign spool/i));
  457. expect(onAssign).toHaveBeenCalledTimes(1);
  458. });
  459. // #2791: the empty-slot and filled-slot cards are separate render paths
  460. // that had drifted into opposite orders, so the menu reshuffled itself
  461. // depending on whether the slot happened to hold filament. Both now put
  462. // the spool action above the slot action; assert it on both paths so the
  463. // two can't drift apart again.
  464. it('lists Assign Spool above Configure, matching the filled-slot card (#2791)', async () => {
  465. const result = render(
  466. <EmptySlotHoverCard
  467. configureSlot={{ enabled: true, onConfigure: vi.fn() }}
  468. onAssignSpool={vi.fn()}
  469. >
  470. <div>trigger</div>
  471. </EmptySlotHoverCard>
  472. );
  473. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  474. vi.advanceTimersByTime(100);
  475. await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
  476. const assign = screen.getByText(/assign spool/i);
  477. const configure = screen.getByText(/^configure$/i);
  478. expect(assign.compareDocumentPosition(configure)).toBe(
  479. Node.DOCUMENT_POSITION_FOLLOWING
  480. );
  481. });
  482. // Same z-[60]-over-a-z-50-dialog problem as FilamentHoverCard (#2631).
  483. describe('dismissal when an action opens a dialog (#2631)', () => {
  484. it('closes the card when Configure is pressed, and still configures', async () => {
  485. const onConfigure = vi.fn();
  486. const result = render(
  487. <EmptySlotHoverCard configureSlot={{ enabled: true, onConfigure }}>
  488. <div>trigger</div>
  489. </EmptySlotHoverCard>
  490. );
  491. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  492. vi.advanceTimersByTime(100);
  493. await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
  494. fireEvent.click(screen.getByText(/configure/i));
  495. expect(onConfigure).toHaveBeenCalledTimes(1);
  496. await waitFor(() => expect(screen.queryByText(/empty/i)).not.toBeInTheDocument());
  497. });
  498. it('closes the card when Assign Spool is pressed', async () => {
  499. const onAssign = vi.fn();
  500. const result = render(
  501. <EmptySlotHoverCard onAssignSpool={onAssign}>
  502. <div>trigger</div>
  503. </EmptySlotHoverCard>
  504. );
  505. fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
  506. vi.advanceTimersByTime(100);
  507. await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
  508. fireEvent.click(screen.getByText(/assign spool/i));
  509. expect(onAssign).toHaveBeenCalledTimes(1);
  510. await waitFor(() => expect(screen.queryByText(/empty/i)).not.toBeInTheDocument());
  511. });
  512. });
  513. });
  514. describe('FilamentHoverCard colour name (#2875)', () => {
  515. beforeEach(() => {
  516. vi.useFakeTimers({ shouldAdvanceTime: true });
  517. __resetColorCatalogForTests();
  518. });
  519. const whiteMatte = {
  520. ...baseFilamentData,
  521. profile: 'Bambu PLA Matte',
  522. colorHex: 'FFFFFFFF',
  523. // What PrintersPage now resolves with the slot's own tray_sub_brands.
  524. colorName: 'Ivory White',
  525. };
  526. async function showCard(ui: React.ReactElement) {
  527. renderWithHover(ui);
  528. vi.advanceTimersByTime(100);
  529. }
  530. it('shows the resolved catalogue name for the slot', async () => {
  531. await showCard(
  532. <FilamentHoverCard data={whiteMatte}>
  533. <div>trigger</div>
  534. </FilamentHoverCard>
  535. );
  536. await waitFor(() => expect(screen.getByText('Ivory White')).toBeInTheDocument());
  537. expect(screen.queryByText('Jade White')).not.toBeInTheDocument();
  538. });
  539. it('prefers the assigned spool name, which is what the user put in the slot', async () => {
  540. await showCard(
  541. <FilamentHoverCard
  542. data={{ ...whiteMatte, colorName: 'Jade White' }}
  543. inventory={{
  544. isAssigned: true,
  545. assignedSpool: { id: 17, material: 'PLA', brand: 'Bambu Lab', color_name: 'Matte Ivory White' },
  546. }}
  547. >
  548. <div>trigger</div>
  549. </FilamentHoverCard>
  550. );
  551. await waitFor(() => expect(screen.getByText('Matte Ivory White')).toBeInTheDocument());
  552. expect(screen.queryByText('Jade White')).not.toBeInTheDocument();
  553. });
  554. it('ignores a Bambu internal colour code on the assigned spool', async () => {
  555. // "A06-D0" is not a name, and is not unique across material families
  556. // (#857) -- the catalogue answer stands.
  557. await showCard(
  558. <FilamentHoverCard
  559. data={whiteMatte}
  560. inventory={{
  561. isAssigned: true,
  562. assignedSpool: { id: 17, material: 'PLA', brand: 'Bambu Lab', color_name: 'A06-D0' },
  563. }}
  564. >
  565. <div>trigger</div>
  566. </FilamentHoverCard>
  567. );
  568. await waitFor(() => expect(screen.getByText('Ivory White')).toBeInTheDocument());
  569. expect(screen.queryByText('A06-D0')).not.toBeInTheDocument();
  570. });
  571. it.each([
  572. ['an empty colour name', ''],
  573. ['a whitespace-only colour name', ' '],
  574. ])('keeps the catalogue answer for a spool with %s', async (_label, colorName) => {
  575. await showCard(
  576. <FilamentHoverCard
  577. data={whiteMatte}
  578. inventory={{
  579. isAssigned: true,
  580. assignedSpool: { id: 17, material: 'PLA', brand: 'Bambu Lab', color_name: colorName },
  581. }}
  582. >
  583. <div>trigger</div>
  584. </FilamentHoverCard>
  585. );
  586. await waitFor(() => expect(screen.getByText('Ivory White')).toBeInTheDocument());
  587. });
  588. it('keeps the catalogue answer when a spool is assigned with no colour recorded', async () => {
  589. setColorCatalog({ ffffff: 'Jade White' }, { 'pla matte|ffffff': 'Ivory White' });
  590. await showCard(
  591. <FilamentHoverCard
  592. data={whiteMatte}
  593. inventory={{
  594. isAssigned: true,
  595. assignedSpool: { id: 17, material: 'PLA', brand: 'Bambu Lab', color_name: null },
  596. }}
  597. >
  598. <div>trigger</div>
  599. </FilamentHoverCard>
  600. );
  601. await waitFor(() => expect(screen.getByText('Ivory White')).toBeInTheDocument());
  602. });
  603. });