FilamentMappingRackPicker.test.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. /**
  2. * The per-group rack position picker in the print dialog (#1784).
  3. *
  4. * On an H2C the rack carriage hosts six hotends and the 3MF does not say which
  5. * one a filament group should use — proven by dispatching one plate twice from
  6. * BambuStudio with different picks and finding the two files identical bar
  7. * float noise. So the dialog has to ask, and the answer travels to the printer
  8. * as `nozzle_mapping`.
  9. *
  10. * The picker is per *group*, not per slot: two slots sharing a group share one
  11. * hotend and cannot point at different positions.
  12. */
  13. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  14. import { screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
  15. import { http, HttpResponse } from 'msw';
  16. import { render } from '../utils';
  17. import { server } from '../mocks/server';
  18. import { FilamentMapping } from '../../components/PrintModal/FilamentMapping';
  19. import type { PrinterStatus } from '../../api/client';
  20. // The maintainer's own plate: three PLA filaments in groups 2/0/1, with groups
  21. // 1 and 2 on the rack and group 0 on the fixed hotend.
  22. const group = (over = {}) => ({
  23. on_rack: true,
  24. nozzle_diameter: '0.40',
  25. volume_type: 'High Flow',
  26. filament_color: '',
  27. ...over,
  28. });
  29. const benchyReqs = {
  30. filaments: [
  31. { slot_id: 1, type: 'PLA', color: '#DE4343', used_grams: 7, used_meters: 2.1,
  32. group_id: 2, group: group({ filament_color: '#DE4343' }) },
  33. { slot_id: 2, type: 'PLA', color: '#F4EE2A', used_grams: 7, used_meters: 2.2,
  34. group_id: 0, group: group({ on_rack: false, filament_color: '#F4EE2A' }) },
  35. { slot_id: 3, type: 'PLA', color: '#0078BF', used_grams: 10, used_meters: 3.3,
  36. group_id: 1, group: group({ filament_color: '#0078BF' }) },
  37. ],
  38. };
  39. const rackSlot = (id: number, over = {}) => ({
  40. id,
  41. nozzle_type: 'HH01',
  42. nozzle_diameter: '0.4',
  43. wear: null,
  44. stat: null,
  45. max_temp: 300,
  46. serial_number: '',
  47. filament_color: '',
  48. filament_id: '',
  49. filament_type: '',
  50. ...over,
  51. });
  52. function createStatus(nozzleRack: unknown[]): PrinterStatus {
  53. return {
  54. id: 1,
  55. name: 'H2C-1',
  56. connected: true,
  57. state: 'IDLE',
  58. ams: [
  59. {
  60. id: 0,
  61. tray: [
  62. { id: 0, tray_type: 'PLA', tray_color: 'DE4343', tray_info_idx: 'GFA01' },
  63. { id: 1, tray_type: 'PLA', tray_color: 'F4EE2A', tray_info_idx: 'GFA00' },
  64. { id: 2, tray_type: 'PLA', tray_color: '0078BF', tray_info_idx: 'GFA01' },
  65. ],
  66. },
  67. ],
  68. vt_tray: [],
  69. ams_extruder_map: {},
  70. nozzle_rack: nozzleRack,
  71. ...{},
  72. } as unknown as PrinterStatus;
  73. }
  74. /** Full rack, plus the fixed carriage the printer always reports. */
  75. const fullRack = [1, 2, 3, 4, 5, 6].map((p) => rackSlot(15 + p)).concat(rackSlot(1));
  76. function mount(props: Record<string, unknown> = {}, rack = fullRack) {
  77. server.use(
  78. http.get('/api/v1/printers/:id/status', () => HttpResponse.json(createStatus(rack))),
  79. http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])),
  80. );
  81. return render(
  82. <FilamentMapping
  83. printerId={1}
  84. filamentReqs={benchyReqs}
  85. manualMappings={{}}
  86. onManualMappingChange={() => {}}
  87. currencySymbol="$"
  88. defaultCostPerKg={20}
  89. defaultExpanded
  90. {...props}
  91. />,
  92. );
  93. }
  94. /** The rack pickers, in slot order. */
  95. async function pickers() {
  96. return await waitFor(async () => {
  97. const found = await screen.findAllByLabelText('Rack position');
  98. expect(found.length).toBeGreaterThan(0);
  99. return found as HTMLSelectElement[];
  100. });
  101. }
  102. afterEach(() => {
  103. cleanup();
  104. vi.clearAllMocks();
  105. });
  106. describe('FilamentMapping — nozzle rack position picker', () => {
  107. beforeEach(() => {
  108. server.use(http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])));
  109. });
  110. it('offers a picker for each rack-bound group and none for the fixed one', async () => {
  111. mount();
  112. const selects = await pickers();
  113. // Groups 2 and 1 are on the rack; group 0 is the fixed hotend and gets the
  114. // plain L badge instead.
  115. expect(selects).toHaveLength(2);
  116. expect(screen.getByText('L')).toBeInTheDocument();
  117. });
  118. it('offers all six positions so a missing one is greyed out, not absent', async () => {
  119. mount({}, [rackSlot(16), rackSlot(17), rackSlot(1)]);
  120. const [first] = await pickers();
  121. expect(first.querySelectorAll('option')).toHaveLength(6);
  122. const disabled = [...first.querySelectorAll('option')].filter((o) => (o as HTMLOptionElement).disabled);
  123. expect(disabled.map((o) => (o as HTMLOptionElement).value)).toEqual(['3', '4', '5', '6']);
  124. });
  125. it('disables a position holding the wrong nozzle', async () => {
  126. mount({}, [rackSlot(16), rackSlot(17, { nozzle_diameter: '0.6' }), rackSlot(1)]);
  127. const [first] = await pickers();
  128. const option = [...first.querySelectorAll('option')].find(
  129. (o) => (o as HTMLOptionElement).value === '2',
  130. ) as HTMLOptionElement;
  131. expect(option.disabled).toBe(true);
  132. expect(option.title).toBe('Holds a 0.6 HH01 nozzle; this filament needs 0.40 High Flow');
  133. });
  134. it('pre-selects the position already loaded with the group colour', async () => {
  135. // Reproduces BambuStudio's own pick for this plate — dispatched [16, 1, 17]
  136. // on 2026-08-14: red group to R1, blue group to R2.
  137. const coloured = [
  138. rackSlot(16, { filament_color: 'DE4343FF' }),
  139. rackSlot(17, { filament_color: '0078BFFF' }),
  140. ...[3, 4, 5, 6].map((p) => rackSlot(15 + p)),
  141. rackSlot(1),
  142. ];
  143. mount({}, coloured);
  144. const [red, blue] = await pickers();
  145. expect(red.value).toBe('1');
  146. expect(blue.value).toBe('2');
  147. });
  148. it('reports every group when one is changed, not just the edited one', async () => {
  149. // Sending only the edited group would let the dispatcher re-assign the
  150. // others around it and silently move a hotend the operator had accepted.
  151. const onChange = vi.fn();
  152. mount({ onNozzleRackChoiceChange: onChange });
  153. const [red] = await pickers();
  154. fireEvent.change(red, { target: { value: '4' } });
  155. expect(onChange).toHaveBeenCalledTimes(1);
  156. expect(onChange.mock.calls[0][0]).toEqual({ 1: 1, 2: 4 });
  157. });
  158. it('shows a saved pick rather than re-deriving one', async () => {
  159. mount({ nozzleRackChoice: { 2: 5, 1: 6 }, onNozzleRackChoiceChange: vi.fn() });
  160. const [red, blue] = await pickers();
  161. expect(red.value).toBe('5');
  162. expect(blue.value).toBe('6');
  163. });
  164. it('offers the mounted nozzle, which telemetry omits entirely', async () => {
  165. // #943: the firmware drops a rack id while that nozzle is on the carriage.
  166. // It is the likeliest pick of all — the last print left it there.
  167. const mounted = [
  168. ...[1, 3, 4, 5, 6].map((p) => rackSlot(15 + p)),
  169. rackSlot(1),
  170. rackSlot(0), // the rack carriage, holding position 2's nozzle
  171. ];
  172. mount({}, mounted);
  173. const [first] = await pickers();
  174. const option = [...first.querySelectorAll('option')].find(
  175. (o) => (o as HTMLOptionElement).value === '2',
  176. ) as HTMLOptionElement;
  177. expect(option.disabled).toBe(false);
  178. });
  179. it('renders no picker on a printer with no rack', async () => {
  180. // Two carriages and nothing at 16..21 -- an H2D, or an H2C mid-report.
  181. // The panel still maps filaments; it just offers no rack position, and
  182. // no L/R badge either, since this plate carries no nozzle_id.
  183. mount({}, [rackSlot(0), rackSlot(1)]);
  184. await waitFor(() => expect(screen.getAllByText(/PLA/).length).toBeGreaterThan(0));
  185. expect(screen.queryAllByLabelText('Rack position')).toHaveLength(0);
  186. expect(screen.queryByText('R')).not.toBeInTheDocument();
  187. });
  188. it('leaves a dual-nozzle plate on its L/R badges', async () => {
  189. // No group data at all — an H2D file. The pre-existing badge must survive.
  190. const h2dReqs = {
  191. filaments: [
  192. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 5, used_meters: 2, nozzle_id: 1 },
  193. { slot_id: 2, type: 'PLA', color: '#00FF00', used_grams: 5, used_meters: 2, nozzle_id: 0 },
  194. ],
  195. };
  196. mount({ filamentReqs: h2dReqs });
  197. await screen.findByText('L');
  198. expect(screen.getByText('R')).toBeInTheDocument();
  199. expect(screen.queryAllByLabelText('Rack position')).toHaveLength(0);
  200. });
  201. });