FilamentMapping.test.tsx 16 KB

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