FilamentOverride.test.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. /**
  2. * Tests for the FilamentOverride component.
  3. *
  4. * FilamentOverride allows users to override the 3MF's original filament
  5. * choices with filaments available across printers of the selected model.
  6. */
  7. import { describe, it, expect, vi, afterEach } from 'vitest';
  8. import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
  9. import { http, HttpResponse } from 'msw';
  10. import { render } from '../utils';
  11. import { server } from '../mocks/server';
  12. import { FilamentOverride } from '../../components/PrintModal/FilamentOverride';
  13. import type { FilamentReqsData } from '../../components/PrintModal/types';
  14. const defaultFilamentReqs: FilamentReqsData = {
  15. filaments: [
  16. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5 },
  17. ],
  18. };
  19. const defaultAvailable = [
  20. { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },
  21. { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Basic', extruder_id: null },
  22. { type: 'PETG', color: '#0000FF', tray_info_idx: 'GFG00', tray_sub_brands: 'PETG Basic', extruder_id: null },
  23. ];
  24. const mockOnChange = vi.fn();
  25. afterEach(() => {
  26. cleanup();
  27. vi.clearAllMocks();
  28. });
  29. describe('FilamentOverride', () => {
  30. describe('rendering', () => {
  31. it('returns null when filamentReqs is undefined', () => {
  32. render(
  33. <FilamentOverride
  34. filamentReqs={undefined}
  35. availableFilaments={defaultAvailable}
  36. overrides={{}}
  37. onChange={mockOnChange}
  38. />
  39. );
  40. expect(screen.queryByText('Filament Override')).not.toBeInTheDocument();
  41. });
  42. it('returns null when filaments array is empty', () => {
  43. render(
  44. <FilamentOverride
  45. filamentReqs={{ filaments: [] }}
  46. availableFilaments={defaultAvailable}
  47. overrides={{}}
  48. onChange={mockOnChange}
  49. />
  50. );
  51. expect(screen.queryByText('Filament Override')).not.toBeInTheDocument();
  52. });
  53. it('returns null when availableFilaments is empty', () => {
  54. render(
  55. <FilamentOverride
  56. filamentReqs={defaultFilamentReqs}
  57. availableFilaments={[]}
  58. overrides={{}}
  59. onChange={mockOnChange}
  60. />
  61. );
  62. expect(screen.queryByText('Filament Override')).not.toBeInTheDocument();
  63. });
  64. it('renders filament slot with type and grams', () => {
  65. render(
  66. <FilamentOverride
  67. filamentReqs={defaultFilamentReqs}
  68. availableFilaments={defaultAvailable}
  69. overrides={{}}
  70. onChange={mockOnChange}
  71. />
  72. );
  73. // The grams text "(25g)" is in a nested span within the type label
  74. expect(screen.getByText('(25g)')).toBeInTheDocument();
  75. // "Filament Override" heading confirms the section renders
  76. expect(screen.getByText('Filament Override')).toBeInTheDocument();
  77. });
  78. it('renders override dropdown for each slot', () => {
  79. const twoSlotReqs: FilamentReqsData = {
  80. filaments: [
  81. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5 },
  82. { slot_id: 2, type: 'PLA', color: '#00FF00', used_grams: 10, used_meters: 3.2 },
  83. ],
  84. };
  85. render(
  86. <FilamentOverride
  87. filamentReqs={twoSlotReqs}
  88. availableFilaments={defaultAvailable}
  89. overrides={{}}
  90. onChange={mockOnChange}
  91. />
  92. );
  93. const selects = screen.getAllByRole('combobox');
  94. expect(selects).toHaveLength(2);
  95. });
  96. });
  97. describe('type filtering', () => {
  98. it('only shows same-type filaments in dropdown', () => {
  99. render(
  100. <FilamentOverride
  101. filamentReqs={defaultFilamentReqs}
  102. availableFilaments={defaultAvailable}
  103. overrides={{}}
  104. onChange={mockOnChange}
  105. />
  106. );
  107. const select = screen.getByRole('combobox');
  108. const options = select.querySelectorAll('option');
  109. // 1 default "Original" option + 2 PLA options (not PETG)
  110. expect(options).toHaveLength(3);
  111. // Verify no PETG option values exist
  112. const optionValues = Array.from(options).map((o) => o.getAttribute('value'));
  113. expect(optionValues).not.toContain('PETG|#0000FF');
  114. });
  115. it('shows all same-type options regardless of color', () => {
  116. const threeColorAvailable = [
  117. { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },
  118. { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Basic', extruder_id: null },
  119. { type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA02', tray_sub_brands: 'PLA Basic', extruder_id: null },
  120. ];
  121. render(
  122. <FilamentOverride
  123. filamentReqs={defaultFilamentReqs}
  124. availableFilaments={threeColorAvailable}
  125. overrides={{}}
  126. onChange={mockOnChange}
  127. />
  128. );
  129. const select = screen.getByRole('combobox');
  130. const options = select.querySelectorAll('option');
  131. // 1 default "Original" option + 3 PLA color options
  132. expect(options).toHaveLength(4);
  133. });
  134. });
  135. describe('subtype display', () => {
  136. it('shows tray_sub_brands in dropdown options when available', () => {
  137. const subtypeAvailable = [
  138. { type: 'PLA', color: '#000000', tray_info_idx: 'GFL99', tray_sub_brands: 'PLA Basic', extruder_id: null },
  139. { type: 'PLA', color: '#000000', tray_info_idx: 'GFL05', tray_sub_brands: 'PLA Matte', extruder_id: null },
  140. ];
  141. render(
  142. <FilamentOverride
  143. filamentReqs={defaultFilamentReqs}
  144. availableFilaments={subtypeAvailable}
  145. overrides={{}}
  146. onChange={mockOnChange}
  147. />
  148. );
  149. const select = screen.getByRole('combobox');
  150. const options = Array.from(select.querySelectorAll('option'));
  151. const optionTexts = options.map((o) => o.textContent);
  152. // Should show "PLA Basic" and "PLA Matte", not just "PLA"
  153. expect(optionTexts.some((t) => t?.includes('PLA Basic'))).toBe(true);
  154. expect(optionTexts.some((t) => t?.includes('PLA Matte'))).toBe(true);
  155. });
  156. it('falls back to type when tray_sub_brands is empty', () => {
  157. const noSubtypeAvailable = [
  158. { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: '', extruder_id: null },
  159. ];
  160. render(
  161. <FilamentOverride
  162. filamentReqs={defaultFilamentReqs}
  163. availableFilaments={noSubtypeAvailable}
  164. overrides={{}}
  165. onChange={mockOnChange}
  166. />
  167. );
  168. const select = screen.getByRole('combobox');
  169. const options = Array.from(select.querySelectorAll('option'));
  170. // Non-default option should show "PLA" as the type fallback
  171. const nonDefaultOptions = options.filter((o) => o.getAttribute('value') !== '');
  172. expect(nonDefaultOptions[0].textContent).toContain('PLA');
  173. });
  174. });
  175. describe('nozzle filtering', () => {
  176. it('filters by extruder_id when nozzle_id is set', () => {
  177. const nozzleReqs: FilamentReqsData = {
  178. filaments: [
  179. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, nozzle_id: 0 },
  180. ],
  181. };
  182. const dualExtruderAvailable = [
  183. { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: 0 },
  184. { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Basic', extruder_id: 1 },
  185. ];
  186. render(
  187. <FilamentOverride
  188. filamentReqs={nozzleReqs}
  189. availableFilaments={dualExtruderAvailable}
  190. overrides={{}}
  191. onChange={mockOnChange}
  192. />
  193. );
  194. const select = screen.getByRole('combobox');
  195. const options = select.querySelectorAll('option');
  196. // 1 default + 1 PLA with extruder_id=0 (extruder_id=1 is filtered out)
  197. expect(options).toHaveLength(2);
  198. const optionValues = Array.from(options).map((o) => o.getAttribute('value'));
  199. expect(optionValues).toContain('PLA|#FF0000');
  200. expect(optionValues).not.toContain('PLA|#00FF00');
  201. });
  202. it('shows all filaments when nozzle_id is undefined', () => {
  203. const noNozzleReqs: FilamentReqsData = {
  204. filaments: [
  205. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5 },
  206. ],
  207. };
  208. const mixedExtruderAvailable = [
  209. { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: 0 },
  210. { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Basic', extruder_id: 1 },
  211. ];
  212. render(
  213. <FilamentOverride
  214. filamentReqs={noNozzleReqs}
  215. availableFilaments={mixedExtruderAvailable}
  216. overrides={{}}
  217. onChange={mockOnChange}
  218. />
  219. );
  220. const select = screen.getByRole('combobox');
  221. const options = select.querySelectorAll('option');
  222. // 1 default + 2 PLA options (no nozzle filtering)
  223. expect(options).toHaveLength(3);
  224. });
  225. it('includes filaments with null extruder_id', () => {
  226. const nozzleReqs: FilamentReqsData = {
  227. filaments: [
  228. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, nozzle_id: 0 },
  229. ],
  230. };
  231. const mixedAvailable = [
  232. { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: 0 },
  233. { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Basic', extruder_id: null },
  234. { type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA02', tray_sub_brands: 'PLA Basic', extruder_id: 1 },
  235. ];
  236. render(
  237. <FilamentOverride
  238. filamentReqs={nozzleReqs}
  239. availableFilaments={mixedAvailable}
  240. overrides={{}}
  241. onChange={mockOnChange}
  242. />
  243. );
  244. const select = screen.getByRole('combobox');
  245. const options = select.querySelectorAll('option');
  246. // 1 default + extruder_id=0 + extruder_id=null (extruder_id=1 filtered out)
  247. expect(options).toHaveLength(3);
  248. const optionValues = Array.from(options).map((o) => o.getAttribute('value'));
  249. expect(optionValues).toContain('PLA|#FF0000');
  250. expect(optionValues).toContain('PLA|#00FF00');
  251. expect(optionValues).not.toContain('PLA|#FFFFFF');
  252. });
  253. });
  254. describe('interactions', () => {
  255. it('calls onChange when selecting an override', () => {
  256. render(
  257. <FilamentOverride
  258. filamentReqs={defaultFilamentReqs}
  259. availableFilaments={defaultAvailable}
  260. overrides={{}}
  261. onChange={mockOnChange}
  262. />
  263. );
  264. const select = screen.getByRole('combobox');
  265. fireEvent.change(select, { target: { value: 'PLA|#00FF00' } });
  266. expect(mockOnChange).toHaveBeenCalledWith({
  267. 1: { type: 'PLA', color: '#00FF00' },
  268. });
  269. });
  270. it('calls onChange to remove override when selecting original', () => {
  271. const activeOverrides = {
  272. 1: { type: 'PLA', color: '#00FF00' },
  273. };
  274. render(
  275. <FilamentOverride
  276. filamentReqs={defaultFilamentReqs}
  277. availableFilaments={defaultAvailable}
  278. overrides={activeOverrides}
  279. onChange={mockOnChange}
  280. />
  281. );
  282. const select = screen.getByRole('combobox');
  283. fireEvent.change(select, { target: { value: '' } });
  284. expect(mockOnChange).toHaveBeenCalledWith({});
  285. });
  286. });
  287. describe('original-label SKU resolution (#1718)', () => {
  288. it('uses the builtin filament name when tray_info_idx maps to a known SKU', async () => {
  289. // Stamped by Bambu Studio when slicing with PLA Matte Charcoal: 3MF
  290. // carries type=PLA + the GFA01 SKU. Without resolution the label
  291. // collapses to "PLA (Black)" which was Sam's bug.
  292. server.use(
  293. http.get('/api/v1/cloud/builtin-filaments', () =>
  294. HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
  295. ),
  296. http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
  297. );
  298. const reqs: FilamentReqsData = {
  299. filaments: [
  300. { slot_id: 1, type: 'PLA', color: '#1A1A1A', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' },
  301. ],
  302. };
  303. render(
  304. <FilamentOverride
  305. filamentReqs={reqs}
  306. availableFilaments={defaultAvailable}
  307. overrides={{}}
  308. onChange={mockOnChange}
  309. />,
  310. );
  311. // Wait for the queries to resolve and the resolved label to land in
  312. // the dropdown's "original" placeholder option. The tooltip on the
  313. // color swatch carries the same text, so we scope to the option to
  314. // avoid the multi-match.
  315. await waitFor(() => {
  316. const select = screen.getByRole('combobox');
  317. const placeholder = select.querySelector('option[value=""]');
  318. expect(placeholder?.textContent).toMatch(/Bambu PLA Matte/);
  319. });
  320. });
  321. it('prefers the cloud user-preset name over the builtin entry for the same id', async () => {
  322. // Cloud user-preset names are more specific than the builtin fallback —
  323. // e.g. a user has renamed GFA00 to "My House PLA".
  324. server.use(
  325. http.get('/api/v1/cloud/builtin-filaments', () =>
  326. HttpResponse.json([{ filament_id: 'GFA00', name: 'Bambu PLA Basic' }]),
  327. ),
  328. http.get('/api/v1/cloud/filament-id-map', () =>
  329. HttpResponse.json({ GFA00: 'My House PLA' }),
  330. ),
  331. );
  332. const reqs: FilamentReqsData = {
  333. filaments: [
  334. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA00' },
  335. ],
  336. };
  337. render(
  338. <FilamentOverride
  339. filamentReqs={reqs}
  340. availableFilaments={defaultAvailable}
  341. overrides={{}}
  342. onChange={mockOnChange}
  343. />,
  344. );
  345. await waitFor(() => {
  346. const select = screen.getByRole('combobox');
  347. const placeholder = select.querySelector('option[value=""]');
  348. expect(placeholder?.textContent).toMatch(/My House PLA/);
  349. });
  350. // The builtin fallback must NOT bleed through anywhere — neither the
  351. // placeholder option nor the tooltip.
  352. expect(screen.queryByText(/Bambu PLA Basic/)).not.toBeInTheDocument();
  353. });
  354. it('uses the material-disambiguated catalogue color name (PLA Matte Charcoal — #1718 round 2)', async () => {
  355. // Sam's exact case: 3MF carries hex #000000 + tray_info_idx GFA01.
  356. // Without material context, /colors/map collapses #000000 to "Black"
  357. // (PLA Basic wins the priority race). The override panel must pass
  358. // the derived material hint "PLA Matte" through to /colors/by-material
  359. // so the user sees "Charcoal" — the actually-sliced color.
  360. server.use(
  361. http.get('/api/v1/cloud/builtin-filaments', () =>
  362. HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
  363. ),
  364. http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
  365. http.get('/api/v1/inventory/colors/by-material', ({ request }) => {
  366. const url = new URL(request.url);
  367. const hex = url.searchParams.get('hex');
  368. const material = url.searchParams.get('material');
  369. if (hex === '#000000' && material === 'PLA Matte') {
  370. return HttpResponse.json({ color_name: 'Charcoal' });
  371. }
  372. return HttpResponse.json({ color_name: null });
  373. }),
  374. );
  375. const reqs: FilamentReqsData = {
  376. filaments: [
  377. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' },
  378. ],
  379. };
  380. render(
  381. <FilamentOverride
  382. filamentReqs={reqs}
  383. availableFilaments={defaultAvailable}
  384. overrides={{}}
  385. onChange={mockOnChange}
  386. />,
  387. );
  388. await waitFor(() => {
  389. const select = screen.getByRole('combobox');
  390. const placeholder = select.querySelector('option[value=""]');
  391. expect(placeholder?.textContent).toMatch(/Bambu PLA Matte \(Charcoal\)/);
  392. });
  393. });
  394. it('disambiguates per slot when two slots share a hex but differ in material', async () => {
  395. // Regression guard: the per-slot useQueries dispatch must key on
  396. // (hex, material) so a "PLA Matte Charcoal" slot does not adopt the
  397. // "PLA Basic Black" slot's answer.
  398. server.use(
  399. http.get('/api/v1/cloud/builtin-filaments', () =>
  400. HttpResponse.json([
  401. { filament_id: 'GFA00', name: 'Bambu PLA Basic' },
  402. { filament_id: 'GFA01', name: 'Bambu PLA Matte' },
  403. ]),
  404. ),
  405. http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
  406. http.get('/api/v1/inventory/colors/by-material', ({ request }) => {
  407. const url = new URL(request.url);
  408. const material = url.searchParams.get('material');
  409. if (material === 'PLA Matte') return HttpResponse.json({ color_name: 'Charcoal' });
  410. if (material === 'PLA Basic') return HttpResponse.json({ color_name: 'Black' });
  411. return HttpResponse.json({ color_name: null });
  412. }),
  413. );
  414. const reqs: FilamentReqsData = {
  415. filaments: [
  416. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' },
  417. { slot_id: 2, type: 'PLA', color: '#000000', used_grams: 10, used_meters: 3.2, tray_info_idx: 'GFA00' },
  418. ],
  419. };
  420. render(
  421. <FilamentOverride
  422. filamentReqs={reqs}
  423. availableFilaments={defaultAvailable}
  424. overrides={{}}
  425. onChange={mockOnChange}
  426. />,
  427. );
  428. await waitFor(() => {
  429. const selects = screen.getAllByRole('combobox');
  430. expect(selects).toHaveLength(2);
  431. expect(selects[0].querySelector('option[value=""]')?.textContent).toMatch(/Bambu PLA Matte \(Charcoal\)/);
  432. expect(selects[1].querySelector('option[value=""]')?.textContent).toMatch(/Bambu PLA Basic \(Black\)/);
  433. });
  434. });
  435. it('falls back to getColorName(hex) when the by-material lookup returns null', async () => {
  436. // Any time the catalogue has no entry for the hex (or the endpoint is
  437. // unreachable), the placeholder must still render — the HSL-bucket
  438. // fallback is strictly better than a blank.
  439. server.use(
  440. http.get('/api/v1/cloud/builtin-filaments', () =>
  441. HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
  442. ),
  443. http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
  444. http.get('/api/v1/inventory/colors/by-material', () =>
  445. HttpResponse.json({ color_name: null }),
  446. ),
  447. );
  448. const reqs: FilamentReqsData = {
  449. filaments: [
  450. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' },
  451. ],
  452. };
  453. render(
  454. <FilamentOverride
  455. filamentReqs={reqs}
  456. availableFilaments={defaultAvailable}
  457. overrides={{}}
  458. onChange={mockOnChange}
  459. />,
  460. );
  461. // Wait for the builtin lookup to land so we know the row mounted; the
  462. // colour fallback to getColorName for #FF0000 produces "Red"-shaped text.
  463. await waitFor(() => {
  464. const select = screen.getByRole('combobox');
  465. const placeholder = select.querySelector('option[value=""]');
  466. expect(placeholder?.textContent).toMatch(/Bambu PLA Matte/);
  467. expect(placeholder?.textContent).not.toMatch(/null/);
  468. });
  469. });
  470. it('falls back to the raw type when the SKU is unknown to both maps', async () => {
  471. // Unknown ids must not break rendering — the original "PLA" label is
  472. // still better than a blank.
  473. server.use(
  474. http.get('/api/v1/cloud/builtin-filaments', () => HttpResponse.json([])),
  475. http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
  476. );
  477. const reqs: FilamentReqsData = {
  478. filaments: [
  479. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFXXX' },
  480. ],
  481. };
  482. render(
  483. <FilamentOverride
  484. filamentReqs={reqs}
  485. availableFilaments={defaultAvailable}
  486. overrides={{}}
  487. onChange={mockOnChange}
  488. />,
  489. );
  490. // (25g) is the easiest signal the row mounted at all; once it's there,
  491. // assert the placeholder option carries the raw type.
  492. await waitFor(() => {
  493. expect(screen.getByText('(25g)')).toBeInTheDocument();
  494. });
  495. const select = screen.getByRole('combobox');
  496. const placeholder = select.querySelector('option[value=""]');
  497. expect(placeholder?.textContent).toMatch(/PLA \(/);
  498. });
  499. });
  500. });