FilamentMapping.test.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. /**
  2. * Tests for the FilamentMapping component's Filament Track Switch (FTS)
  3. * handling (#1162).
  4. *
  5. * The FTS accessory routes any AMS slot to either extruder dynamically. When
  6. * present (printer status `fila_switch.installed === true`), the per-extruder
  7. * dropdown filter must be suppressed — otherwise the print modal's filament
  8. * dropdown is empty since the printer reports info bits 8-11 = 0xE
  9. * (uninitialized) for every AMS unit.
  10. */
  11. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  12. import { screen, waitFor, cleanup, fireEvent, within } from '@testing-library/react';
  13. import { http, HttpResponse } from 'msw';
  14. import { render } from '../utils';
  15. import { server } from '../mocks/server';
  16. import { FilamentMapping } from '../../components/PrintModal/FilamentMapping';
  17. import type { PrinterStatus } from '../../api/client';
  18. /**
  19. * Open the slot picker and hand back its listbox.
  20. *
  21. * The picker replaced a native `<select>` (#3159). A `<select>` keeps every
  22. * `<option>` mounted whether or not it is open, so these assertions used to
  23. * read the closed control; a listbox renders its rows only while open. They
  24. * are about what the user is *offered*, so they open it first — and scope to
  25. * the listbox, because the chosen slot's label now also appears on the
  26. * trigger.
  27. */
  28. async function openSlotPicker(): Promise<HTMLElement> {
  29. const trigger = await waitFor(() => screen.getAllByRole('combobox')[0]);
  30. fireEvent.click(trigger);
  31. return await waitFor(() => screen.getByRole('listbox'));
  32. }
  33. const mockFilamentReqs = {
  34. filaments: [
  35. // Required filament asks for the LEFT extruder (nozzle_id=1).
  36. // Without FTS the dropdown filter would only allow slots with extruderId=1.
  37. { slot_id: 1, type: 'PETG', color: '#00FF00', used_grams: 25, used_meters: 8.5, nozzle_id: 1 },
  38. ],
  39. };
  40. function createStatus(overrides: Partial<PrinterStatus>): PrinterStatus {
  41. return {
  42. id: 1,
  43. name: 'X2D',
  44. connected: true,
  45. state: 'IDLE',
  46. ams: [
  47. {
  48. id: 0,
  49. // Realistic FTS-installed bundle: AMS reports extruder bits 8-11 = 0xE,
  50. // so ams_extruder_map ends up empty.
  51. tray: [
  52. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'Bambu PLA' },
  53. { id: 1, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFG00', tray_sub_brands: 'Bambu PETG' },
  54. ],
  55. },
  56. ],
  57. vt_tray: [],
  58. ams_extruder_map: {},
  59. ...overrides,
  60. } as PrinterStatus;
  61. }
  62. afterEach(() => {
  63. cleanup();
  64. vi.clearAllMocks();
  65. });
  66. describe('FilamentMapping — FTS routing', () => {
  67. beforeEach(() => {
  68. server.use(
  69. http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])),
  70. );
  71. });
  72. it('shows all loaded slots in the dropdown when FTS is installed', async () => {
  73. server.use(
  74. http.get(
  75. '/api/v1/printers/:id/status',
  76. () =>
  77. HttpResponse.json(
  78. createStatus({
  79. fila_switch: {
  80. installed: true,
  81. in_slots: [-1, 1],
  82. out_extruders: [0, 1],
  83. stat: 0,
  84. info: 2,
  85. },
  86. ams_switch_inlet: { '0': 'A' },
  87. }),
  88. ),
  89. ),
  90. );
  91. render(
  92. <FilamentMapping
  93. printerId={1}
  94. filamentReqs={mockFilamentReqs}
  95. manualMappings={{}}
  96. onManualMappingChange={() => {}}
  97. currencySymbol="$"
  98. defaultCostPerKg={0}
  99. defaultExpanded
  100. />,
  101. );
  102. // Both PLA and PETG slots must be offered despite ams_extruder_map being
  103. // empty and the requirement asking for nozzle 1. Without the FTS guard the
  104. // picker would offer only the "-- Select slot --" placeholder.
  105. const listbox = await openSlotPicker();
  106. expect(within(listbox).getByText(/Bambu PLA/)).toBeInTheDocument();
  107. expect(within(listbox).getByText(/Bambu PETG/)).toBeInTheDocument();
  108. // Each slot is badged for the switch INLET its AMS is plumbed into, using
  109. // the same L-for-In-A lettering as the printer card. Both slots are in
  110. // AMS 0, which is on In-A.
  111. expect(within(listbox).getByText(/Bambu PETG/).textContent).toMatch(/\[L\]/);
  112. expect(within(listbox).getByText(/Bambu PLA/).textContent).toMatch(/\[L\]/);
  113. });
  114. it('does not badge slots whose AMS has no inlet binding yet', async () => {
  115. // A switch that has been fitted but not set up on the printer's Manual AMS
  116. // Setup screen reports no binding. Better a missing badge than a made-up one.
  117. server.use(
  118. http.get(
  119. '/api/v1/printers/:id/status',
  120. () =>
  121. HttpResponse.json(
  122. createStatus({
  123. fila_switch: { installed: true, in_slots: [-1, 1], out_extruders: [0, 1], stat: 0, info: 2 },
  124. ams_switch_inlet: {},
  125. }),
  126. ),
  127. ),
  128. );
  129. render(
  130. <FilamentMapping
  131. printerId={1}
  132. filamentReqs={mockFilamentReqs}
  133. manualMappings={{}}
  134. onManualMappingChange={() => {}}
  135. currencySymbol="$"
  136. defaultCostPerKg={0}
  137. defaultExpanded
  138. />,
  139. );
  140. const listbox = await openSlotPicker();
  141. expect(within(listbox).getByText(/Bambu PETG/).textContent).not.toMatch(/\[[LR]\]/);
  142. });
  143. it('renders the per-slot force-color-match checkbox in printer mode (#1717)', async () => {
  144. // Specific-printer assignment used to render FilamentMapping with no
  145. // force-color-match UI even though the dispatcher honours the flag. Pin
  146. // that the checkbox is now mounted and bubbles toggle events up.
  147. server.use(
  148. http.get(
  149. '/api/v1/printers/:id/status',
  150. () =>
  151. HttpResponse.json(
  152. createStatus({
  153. fila_switch: null,
  154. ams_extruder_map: { '0': 1 }, // AMS 0 → left nozzle, matching the requirement
  155. }),
  156. ),
  157. ),
  158. );
  159. const onForceColorMatchChange = vi.fn();
  160. render(
  161. <FilamentMapping
  162. printerId={1}
  163. filamentReqs={mockFilamentReqs}
  164. manualMappings={{}}
  165. onManualMappingChange={() => {}}
  166. currencySymbol="$"
  167. defaultCostPerKg={0}
  168. defaultExpanded
  169. forceColorMatch={{}}
  170. onForceColorMatchChange={onForceColorMatchChange}
  171. />,
  172. );
  173. const checkbox = await waitFor(() => {
  174. const cb = screen.getByLabelText(/Force color match/i) as HTMLInputElement;
  175. expect(cb).toBeInTheDocument();
  176. return cb;
  177. });
  178. expect(checkbox.checked).toBe(false);
  179. fireEvent.click(checkbox);
  180. expect(onForceColorMatchChange).toHaveBeenCalledTimes(1);
  181. expect(onForceColorMatchChange).toHaveBeenCalledWith(1, true);
  182. });
  183. it('omits the force-color-match checkbox when no handler is provided', async () => {
  184. // The checkbox is only meaningful when the caller is wired to persist the
  185. // toggle; absent a handler we must not render dead UI.
  186. server.use(
  187. http.get(
  188. '/api/v1/printers/:id/status',
  189. () =>
  190. HttpResponse.json(
  191. createStatus({
  192. fila_switch: null,
  193. ams_extruder_map: { '0': 1 },
  194. }),
  195. ),
  196. ),
  197. );
  198. render(
  199. <FilamentMapping
  200. printerId={1}
  201. filamentReqs={mockFilamentReqs}
  202. manualMappings={{}}
  203. onManualMappingChange={() => {}}
  204. currencySymbol="$"
  205. defaultCostPerKg={0}
  206. defaultExpanded
  207. />,
  208. );
  209. // Wait for the panel to finish mounting (Re-read button only renders once
  210. // printer status has loaded and the expanded view is open) before asserting
  211. // the checkbox is absent — otherwise the queryByLabelText would pass
  212. // trivially during the loading window.
  213. await waitFor(() => {
  214. expect(screen.getByText(/Re-read/i)).toBeInTheDocument();
  215. });
  216. expect(screen.queryByLabelText(/Force color match/i)).not.toBeInTheDocument();
  217. });
  218. it('offers cross-extruder slots in the dropdown without FTS (#1722)', async () => {
  219. // Before #1722 the dropdown filtered to only slots whose extruder matched
  220. // the filament's slicer-assigned nozzle. On a dual-nozzle printer with one
  221. // AMS per side, that prevented the user from picking a slot on the OTHER
  222. // extruder even when they'd intentionally loaded the required filament
  223. // there. The fix: trust the user, show every loaded slot regardless of
  224. // which extruder it's wired to. The L/R badge on the filament row still
  225. // tells the user what the slicer planned; the printer firmware accepts
  226. // or rejects the cross-extruder ams_mapping at start-print.
  227. server.use(
  228. http.get(
  229. '/api/v1/printers/:id/status',
  230. () =>
  231. HttpResponse.json(
  232. createStatus({
  233. fila_switch: null,
  234. ams_extruder_map: { '0': 0 }, // AMS 0 → right nozzle (extruder 0)
  235. }),
  236. ),
  237. ),
  238. );
  239. render(
  240. <FilamentMapping
  241. printerId={1}
  242. filamentReqs={mockFilamentReqs}
  243. manualMappings={{}}
  244. onManualMappingChange={() => {}}
  245. currencySymbol="$"
  246. defaultCostPerKg={0}
  247. defaultExpanded
  248. />,
  249. );
  250. // Required nozzle is 1 (LEFT) and AMS 0 is wired to extruder 0 (RIGHT).
  251. // Both slots must STILL be offered so the user can pick them — explicitly
  252. // the cross-extruder scenario the #1722 fix unblocks.
  253. const listbox = await openSlotPicker();
  254. expect(within(listbox).getByText(/Bambu PLA/)).toBeInTheDocument();
  255. expect(within(listbox).getByText(/Bambu PETG/)).toBeInTheDocument();
  256. });
  257. it('renders sub-brand + material-disambiguated colour on the required side (#1718)', async () => {
  258. // Same fix as FilamentOverride: required-side label was rendering the
  259. // raw 3MF type ("PLA") and the generic getColorName bucket ("Black").
  260. // After the shared useFilamentLabels hook it must now resolve
  261. // tray_info_idx → "Bambu PLA Matte" and the material-disambiguated
  262. // colour catalogue → "Charcoal" — the Specific-Printer panel matched
  263. // the Any-Model panel that was already correct.
  264. server.use(
  265. http.get(
  266. '/api/v1/printers/:id/status',
  267. () =>
  268. HttpResponse.json(
  269. createStatus({
  270. fila_switch: null,
  271. ams_extruder_map: { '0': 1 },
  272. }),
  273. ),
  274. ),
  275. http.get('/api/v1/cloud/builtin-filaments', () =>
  276. HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
  277. ),
  278. http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
  279. http.get('/api/v1/inventory/colors/by-material', ({ request }) => {
  280. const url = new URL(request.url);
  281. if (url.searchParams.get('hex') === '#000000' && url.searchParams.get('material') === 'PLA Matte') {
  282. return HttpResponse.json({ color_name: 'Charcoal' });
  283. }
  284. return HttpResponse.json({ color_name: null });
  285. }),
  286. );
  287. const charcoalReqs = {
  288. filaments: [
  289. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, nozzle_id: 1, tray_info_idx: 'GFA01' },
  290. ],
  291. };
  292. render(
  293. <FilamentMapping
  294. printerId={1}
  295. filamentReqs={charcoalReqs}
  296. manualMappings={{}}
  297. onManualMappingChange={() => {}}
  298. currencySymbol="$"
  299. defaultCostPerKg={0}
  300. defaultExpanded
  301. />,
  302. );
  303. // Required-side type text picks up the resolved sub-brand.
  304. await waitFor(() => {
  305. expect(screen.getByText(/Bambu PLA Matte/)).toBeInTheDocument();
  306. });
  307. // The swatch tooltip carries the disambiguated "Charcoal" instead of
  308. // the generic "Black" bucket; check the title attr on the colour
  309. // circle's parent span.
  310. await waitFor(() => {
  311. const swatch = screen.getByTitle(/Required: Bambu PLA Matte - Charcoal/);
  312. expect(swatch).toBeInTheDocument();
  313. });
  314. });
  315. it('pins the gram usage so a long name cannot clip it (#2669)', async () => {
  316. // Long resolved name + gram usage. The name must be the truncating
  317. // element; the "(25g)" must sit in its own non-truncating, shrink-0 span
  318. // so it stays visible on narrow/mobile widths.
  319. server.use(
  320. http.get('/api/v1/printers/:id/status', () => HttpResponse.json(createStatus({}))),
  321. http.get('/api/v1/cloud/builtin-filaments', () =>
  322. HttpResponse.json([{ filament_id: 'GFA01', name: 'Polymaker PLA Matte' }]),
  323. ),
  324. http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
  325. http.get('/api/v1/inventory/colors/by-material', () => HttpResponse.json({ color_name: null })),
  326. );
  327. render(
  328. <FilamentMapping
  329. printerId={1}
  330. filamentReqs={{
  331. filaments: [
  332. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, nozzle_id: 1, tray_info_idx: 'GFA01' },
  333. ],
  334. }}
  335. manualMappings={{}}
  336. onManualMappingChange={() => {}}
  337. currencySymbol="$"
  338. defaultCostPerKg={0}
  339. defaultExpanded
  340. />,
  341. );
  342. const grams = await screen.findByText('(25g)');
  343. // The gram usage never truncates and never shrinks away.
  344. expect(grams.className).toContain('shrink-0');
  345. expect(grams.className).not.toContain('truncate');
  346. // The name is the element that truncates instead.
  347. const name = await screen.findByText('Polymaker PLA Matte');
  348. expect(name.className).toContain('truncate');
  349. // Name and grams are separate siblings, so the name shrinking can't take
  350. // the grams with it.
  351. expect(name).not.toBe(grams);
  352. expect(grams.parentElement).toBe(name.parentElement);
  353. });
  354. });
  355. describe('FilamentMapping — FTS same-inlet advisory', () => {
  356. // Bambu's own guidance: a change between two filaments on the SAME switch
  357. // inlet has to retract the outgoing one all the way back to its AMS before
  358. // the incoming one can be fed up the shared tube. A change across the two
  359. // inlets only retracts as far as the switch. When every filament a job needs
  360. // sits behind one inlet, every change in that job takes the slow path — the
  361. // one arrangement worth telling the operator about, since moving a single
  362. // spool fixes it.
  363. const twoFilamentReqs = {
  364. filaments: [
  365. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 20, used_meters: 7, nozzle_id: 0 },
  366. { slot_id: 2, type: 'PETG', color: '#00FF00', used_grams: 25, used_meters: 8.5, nozzle_id: 1 },
  367. ],
  368. };
  369. // Two AMS units, one filament matching in each, so the pick is unambiguous.
  370. const twoAmsStatus = (amsSwitchInlet: Record<string, 'A' | 'B'>): Partial<PrinterStatus> => ({
  371. ams: [
  372. { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'Bambu PLA' }] },
  373. { id: 1, tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFG00', tray_sub_brands: 'Bambu PETG' }] },
  374. ],
  375. fila_switch: { installed: true, in_slots: [-1, -1], out_extruders: [1, 0], stat: 0, info: 0 },
  376. ams_switch_inlet: amsSwitchInlet,
  377. } as Partial<PrinterStatus>);
  378. const renderWith = (amsSwitchInlet: Record<string, 'A' | 'B'>) => {
  379. server.use(
  380. http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])),
  381. http.get('/api/v1/printers/:id/status', () => HttpResponse.json(createStatus(twoAmsStatus(amsSwitchInlet)))),
  382. );
  383. render(
  384. <FilamentMapping
  385. printerId={1}
  386. filamentReqs={twoFilamentReqs}
  387. manualMappings={{}}
  388. onManualMappingChange={() => {}}
  389. currencySymbol="$"
  390. defaultCostPerKg={0}
  391. defaultExpanded
  392. />,
  393. );
  394. };
  395. it('warns when every filament for the print is behind one inlet', async () => {
  396. renderWith({ '0': 'A', '1': 'A' });
  397. // Names the inlet, so the operator knows which spool to move.
  398. expect(await screen.findByText(/on Filament Track Switch IN-A\./)).toBeInTheDocument();
  399. expect(screen.getByText(/same inlet is slower/i)).toBeInTheDocument();
  400. });
  401. it('stays quiet when the filaments are split across both inlets', async () => {
  402. renderWith({ '0': 'A', '1': 'B' });
  403. await waitFor(() => {
  404. expect(screen.getAllByText(/Bambu PETG/).length).toBeGreaterThan(0);
  405. });
  406. expect(screen.queryByText(/same inlet is slower/i)).not.toBeInTheDocument();
  407. });
  408. it('stays quiet when the bindings are not known', async () => {
  409. // No advisory can be justified without knowing where the spools actually are.
  410. renderWith({});
  411. await waitFor(() => {
  412. expect(screen.getAllByText(/Bambu PETG/).length).toBeGreaterThan(0);
  413. });
  414. expect(screen.queryByText(/same inlet is slower/i)).not.toBeInTheDocument();
  415. });
  416. });