FilamentMapping.test.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  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. }),
  72. ),
  73. ),
  74. );
  75. render(
  76. <FilamentMapping
  77. printerId={1}
  78. filamentReqs={mockFilamentReqs}
  79. manualMappings={{}}
  80. onManualMappingChange={() => {}}
  81. currencySymbol="$"
  82. defaultCostPerKg={0}
  83. defaultExpanded
  84. />,
  85. );
  86. // Both PLA and PETG slots must appear in the dropdown despite ams_extruder_map
  87. // being empty and the requirement asking for nozzle 1. Without the FTS guard
  88. // the dropdown would render only the "-- Select slot --" placeholder.
  89. await waitFor(() => {
  90. expect(screen.getByText(/Bambu PLA/)).toBeInTheDocument();
  91. });
  92. expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
  93. // The slot currently fed into a track gets an [L]/[R] badge. AMS-0 slot 1
  94. // (global tray ID 1) is in fila_switch.in_slots[1], whose track terminates
  95. // at extruder 1 → the LEFT-nozzle short label appears in that option.
  96. const petgOption = screen.getByText(/Bambu PETG/);
  97. expect(petgOption.textContent).toMatch(/\[L\]/);
  98. // AMS-0 slot 0 (global tray ID 0) is NOT currently fed into any track —
  99. // FTS routes it on demand, so no badge.
  100. const plaOption = screen.getByText(/Bambu PLA/);
  101. expect(plaOption.textContent).not.toMatch(/\[[LR]\]/);
  102. });
  103. it('renders the per-slot force-color-match checkbox in printer mode (#1717)', async () => {
  104. // Specific-printer assignment used to render FilamentMapping with no
  105. // force-color-match UI even though the dispatcher honours the flag. Pin
  106. // that the checkbox is now mounted and bubbles toggle events up.
  107. server.use(
  108. http.get(
  109. '/api/v1/printers/:id/status',
  110. () =>
  111. HttpResponse.json(
  112. createStatus({
  113. fila_switch: null,
  114. ams_extruder_map: { '0': 1 }, // AMS 0 → left nozzle, matching the requirement
  115. }),
  116. ),
  117. ),
  118. );
  119. const onForceColorMatchChange = vi.fn();
  120. render(
  121. <FilamentMapping
  122. printerId={1}
  123. filamentReqs={mockFilamentReqs}
  124. manualMappings={{}}
  125. onManualMappingChange={() => {}}
  126. currencySymbol="$"
  127. defaultCostPerKg={0}
  128. defaultExpanded
  129. forceColorMatch={{}}
  130. onForceColorMatchChange={onForceColorMatchChange}
  131. />,
  132. );
  133. const checkbox = await waitFor(() => {
  134. const cb = screen.getByLabelText(/Force color match/i) as HTMLInputElement;
  135. expect(cb).toBeInTheDocument();
  136. return cb;
  137. });
  138. expect(checkbox.checked).toBe(false);
  139. fireEvent.click(checkbox);
  140. expect(onForceColorMatchChange).toHaveBeenCalledTimes(1);
  141. expect(onForceColorMatchChange).toHaveBeenCalledWith(1, true);
  142. });
  143. it('omits the force-color-match checkbox when no handler is provided', async () => {
  144. // The checkbox is only meaningful when the caller is wired to persist the
  145. // toggle; absent a handler we must not render dead UI.
  146. server.use(
  147. http.get(
  148. '/api/v1/printers/:id/status',
  149. () =>
  150. HttpResponse.json(
  151. createStatus({
  152. fila_switch: null,
  153. ams_extruder_map: { '0': 1 },
  154. }),
  155. ),
  156. ),
  157. );
  158. render(
  159. <FilamentMapping
  160. printerId={1}
  161. filamentReqs={mockFilamentReqs}
  162. manualMappings={{}}
  163. onManualMappingChange={() => {}}
  164. currencySymbol="$"
  165. defaultCostPerKg={0}
  166. defaultExpanded
  167. />,
  168. );
  169. // Wait for the panel to finish mounting (Re-read button only renders once
  170. // printer status has loaded and the expanded view is open) before asserting
  171. // the checkbox is absent — otherwise the queryByLabelText would pass
  172. // trivially during the loading window.
  173. await waitFor(() => {
  174. expect(screen.getByText(/Re-read/i)).toBeInTheDocument();
  175. });
  176. expect(screen.queryByLabelText(/Force color match/i)).not.toBeInTheDocument();
  177. });
  178. it('offers cross-extruder slots in the dropdown without FTS (#1722)', async () => {
  179. // Before #1722 the dropdown filtered to only slots whose extruder matched
  180. // the filament's slicer-assigned nozzle. On a dual-nozzle printer with one
  181. // AMS per side, that prevented the user from picking a slot on the OTHER
  182. // extruder even when they'd intentionally loaded the required filament
  183. // there. The fix: trust the user, show every loaded slot regardless of
  184. // which extruder it's wired to. The L/R badge on the filament row still
  185. // tells the user what the slicer planned; the printer firmware accepts
  186. // or rejects the cross-extruder ams_mapping at start-print.
  187. server.use(
  188. http.get(
  189. '/api/v1/printers/:id/status',
  190. () =>
  191. HttpResponse.json(
  192. createStatus({
  193. fila_switch: null,
  194. ams_extruder_map: { '0': 0 }, // AMS 0 → right nozzle (extruder 0)
  195. }),
  196. ),
  197. ),
  198. );
  199. render(
  200. <FilamentMapping
  201. printerId={1}
  202. filamentReqs={mockFilamentReqs}
  203. manualMappings={{}}
  204. onManualMappingChange={() => {}}
  205. currencySymbol="$"
  206. defaultCostPerKg={0}
  207. defaultExpanded
  208. />,
  209. );
  210. // Required nozzle is 1 (LEFT) and AMS 0 is wired to extruder 0 (RIGHT).
  211. // Both slots must STILL appear so the user can pick them — explicitly the
  212. // cross-extruder scenario the #1722 fix unblocks.
  213. await waitFor(() => {
  214. expect(screen.getByText(/Bambu PLA/)).toBeInTheDocument();
  215. });
  216. expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
  217. });
  218. it('renders sub-brand + material-disambiguated colour on the required side (#1718)', async () => {
  219. // Same fix as FilamentOverride: required-side label was rendering the
  220. // raw 3MF type ("PLA") and the generic getColorName bucket ("Black").
  221. // After the shared useFilamentLabels hook it must now resolve
  222. // tray_info_idx → "Bambu PLA Matte" and the material-disambiguated
  223. // colour catalogue → "Charcoal" — the Specific-Printer panel matched
  224. // the Any-Model panel that was already correct.
  225. server.use(
  226. http.get(
  227. '/api/v1/printers/:id/status',
  228. () =>
  229. HttpResponse.json(
  230. createStatus({
  231. fila_switch: null,
  232. ams_extruder_map: { '0': 1 },
  233. }),
  234. ),
  235. ),
  236. http.get('/api/v1/cloud/builtin-filaments', () =>
  237. HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
  238. ),
  239. http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
  240. http.get('/api/v1/inventory/colors/by-material', ({ request }) => {
  241. const url = new URL(request.url);
  242. if (url.searchParams.get('hex') === '#000000' && url.searchParams.get('material') === 'PLA Matte') {
  243. return HttpResponse.json({ color_name: 'Charcoal' });
  244. }
  245. return HttpResponse.json({ color_name: null });
  246. }),
  247. );
  248. const charcoalReqs = {
  249. filaments: [
  250. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, nozzle_id: 1, tray_info_idx: 'GFA01' },
  251. ],
  252. };
  253. render(
  254. <FilamentMapping
  255. printerId={1}
  256. filamentReqs={charcoalReqs}
  257. manualMappings={{}}
  258. onManualMappingChange={() => {}}
  259. currencySymbol="$"
  260. defaultCostPerKg={0}
  261. defaultExpanded
  262. />,
  263. );
  264. // Required-side type text picks up the resolved sub-brand.
  265. await waitFor(() => {
  266. expect(screen.getByText(/Bambu PLA Matte/)).toBeInTheDocument();
  267. });
  268. // The swatch tooltip carries the disambiguated "Charcoal" instead of
  269. // the generic "Black" bucket; check the title attr on the colour
  270. // circle's parent span.
  271. await waitFor(() => {
  272. const swatch = screen.getByTitle(/Required: Bambu PLA Matte - Charcoal/);
  273. expect(swatch).toBeInTheDocument();
  274. });
  275. });
  276. it('pins the gram usage so a long name cannot clip it (#2669)', async () => {
  277. // Long resolved name + gram usage. The name must be the truncating
  278. // element; the "(25g)" must sit in its own non-truncating, shrink-0 span
  279. // so it stays visible on narrow/mobile widths.
  280. server.use(
  281. http.get('/api/v1/printers/:id/status', () => HttpResponse.json(createStatus({}))),
  282. http.get('/api/v1/cloud/builtin-filaments', () =>
  283. HttpResponse.json([{ filament_id: 'GFA01', name: 'Polymaker PLA Matte' }]),
  284. ),
  285. http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
  286. http.get('/api/v1/inventory/colors/by-material', () => HttpResponse.json({ color_name: null })),
  287. );
  288. render(
  289. <FilamentMapping
  290. printerId={1}
  291. filamentReqs={{
  292. filaments: [
  293. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, nozzle_id: 1, tray_info_idx: 'GFA01' },
  294. ],
  295. }}
  296. manualMappings={{}}
  297. onManualMappingChange={() => {}}
  298. currencySymbol="$"
  299. defaultCostPerKg={0}
  300. defaultExpanded
  301. />,
  302. );
  303. const grams = await screen.findByText('(25g)');
  304. // The gram usage never truncates and never shrinks away.
  305. expect(grams.className).toContain('shrink-0');
  306. expect(grams.className).not.toContain('truncate');
  307. // The name is the element that truncates instead.
  308. const name = await screen.findByText('Polymaker PLA Matte');
  309. expect(name.className).toContain('truncate');
  310. // Name and grams are separate siblings, so the name shrinking can't take
  311. // the grams with it.
  312. expect(name).not.toBe(grams);
  313. expect(grams.parentElement).toBe(name.parentElement);
  314. });
  315. });