PrinterProfilesSection.test.tsx 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701
  1. /**
  2. * The spool form's Printers tab.
  3. *
  4. * Two things are keyed differently and the UI has to keep them apart: the
  5. * filament preset belongs to the printer MODEL (an "@BBL X1C" preset is the
  6. * same preset on every X1C), while a K profile is measured on one individual
  7. * hotend and stays per printer, per extruder, per nozzle diameter.
  8. *
  9. * The layout is a model list plus a detail pane so that fleet size stops
  10. * mattering -- these drive it with a deliberately mixed fleet: two machines of
  11. * one model, a dual-nozzle machine with two different diameters fitted, and an
  12. * offline printer.
  13. */
  14. import React from 'react';
  15. import { describe, it, expect, vi } from 'vitest';
  16. import { screen, fireEvent, within } from '@testing-library/react';
  17. import { render } from '../utils';
  18. import { PrinterProfilesSection } from '../../components/spool-form/PrinterProfilesSection';
  19. import { presetKey, hotendKey } from '../../components/spool-form/utils';
  20. import { defaultFormData } from '../../components/spool-form/types';
  21. import type {
  22. CalibrationProfile,
  23. FilamentOption,
  24. PresetChoice,
  25. PrinterWithCalibrations,
  26. } from '../../components/spool-form/types';
  27. const OPTIONS: FilamentOption[] = [
  28. { code: 'GFSA00', name: 'Bambu PLA Basic @BBL X1C', displayName: 'Bambu PLA Basic @BBL X1C', isCustom: false, allCodes: ['GFSA00'], source: 'cloud' },
  29. { code: 'GFSA09', name: 'Bambu PLA Basic @BBL H2C', displayName: 'Bambu PLA Basic @BBL H2C', isCustom: false, allCodes: ['GFSA09'], source: 'cloud' },
  30. { code: 'GFSA21', name: 'Bambu PLA Basic @BBL H2C 0.2 nozzle', displayName: 'Bambu PLA Basic @BBL H2C 0.2 nozzle', isCustom: false, allCodes: ['GFSA21'], source: 'orca_cloud' },
  31. // Names no model at all -- the shape most user-authored and Orca presets
  32. // have. Must stay offered everywhere: hiding what cannot be classified
  33. // would hide most third-party profiles.
  34. { code: 'LOCAL1', name: 'eSUN PETG Basic', displayName: 'eSUN PETG Basic (Local)', isCustom: true, allCodes: ['LOCAL1'], source: 'local' },
  35. ];
  36. /** The backend registry shape: "Bambu Lab <long>" -> short code. */
  37. const PRINTER_MODELS: Record<string, string> = {
  38. 'Bambu Lab X1 Carbon': 'X1C',
  39. 'Bambu Lab H2C': 'H2C',
  40. 'Bambu Lab P1S': 'P1S',
  41. 'Bambu Lab H2D': 'H2D',
  42. };
  43. function cal(overrides: Partial<CalibrationProfile>): CalibrationProfile {
  44. return {
  45. cali_idx: 1,
  46. filament_id: 'GFL99',
  47. setting_id: 'GFSL99',
  48. name: 'PLA Basic',
  49. k_value: 0.02,
  50. n_coef: 1.0,
  51. extruder_id: 0,
  52. nozzle_diameter: '0.4',
  53. nozzle_id: '',
  54. ...overrides,
  55. };
  56. }
  57. function printer(
  58. id: number,
  59. name: string,
  60. model: string | null,
  61. opts: {
  62. connected?: boolean;
  63. nozzleCount?: number;
  64. calibrations?: CalibrationProfile[];
  65. nozzles?: { nozzle_diameter?: string; nozzle_type?: string }[];
  66. } = {},
  67. ): PrinterWithCalibrations {
  68. return {
  69. printer: {
  70. id,
  71. name,
  72. model,
  73. connected: opts.connected ?? true,
  74. nozzle_count: opts.nozzleCount ?? 1,
  75. } as PrinterWithCalibrations['printer'],
  76. calibrations: opts.calibrations ?? [cal({})],
  77. nozzles: opts.nozzles,
  78. };
  79. }
  80. /** Two X1Cs, one dual-nozzle H2C with 0.4 + 0.2 fitted, one offline P1S. */
  81. function fleet(): PrinterWithCalibrations[] {
  82. return [
  83. printer(1, 'X1C-1', 'X1C'),
  84. printer(2, 'X1C-2', 'X1C', { calibrations: [cal({ cali_idx: 2, k_value: 0.019 })] }),
  85. printer(3, 'H2C-1', 'H2C', {
  86. nozzleCount: 2,
  87. nozzles: [{ nozzle_diameter: '0.4' }, { nozzle_diameter: '0.2' }],
  88. calibrations: [
  89. cal({ cali_idx: 3, extruder_id: 0, nozzle_diameter: '0.4', k_value: 0.021 }),
  90. cal({ cali_idx: 16, extruder_id: 1, nozzle_diameter: '0.2', k_value: 0.014, name: 'PLA 0.2' }),
  91. ],
  92. }),
  93. printer(4, 'P1S-1', 'P1S', { connected: false }),
  94. ];
  95. }
  96. interface HarnessProps {
  97. printers?: PrinterWithCalibrations[];
  98. presets?: Map<string, PresetChoice>;
  99. profiles?: Map<string, CalibrationProfile>;
  100. onPresets?: (next: Map<string, PresetChoice>) => void;
  101. onProfiles?: (next: Map<string, CalibrationProfile>) => void;
  102. slicerFilament?: string;
  103. }
  104. /**
  105. * Renders the section with real state, so a click is asserted through the
  106. * component's own update path rather than against a spy on the setter.
  107. */
  108. function Harness({
  109. printers = fleet(),
  110. presets = new Map(),
  111. profiles = new Map(),
  112. onPresets,
  113. onProfiles,
  114. slicerFilament = 'GFSA00',
  115. }: HarnessProps) {
  116. const [modelPresets, setModelPresets] = React.useState(presets);
  117. const [selectedProfiles, setSelectedProfiles] = React.useState(profiles);
  118. const [selectedGroupId, setSelectedGroupId] = React.useState('');
  119. React.useEffect(() => {
  120. onPresets?.(modelPresets);
  121. }, [modelPresets, onPresets]);
  122. React.useEffect(() => {
  123. onProfiles?.(selectedProfiles);
  124. }, [selectedProfiles, onProfiles]);
  125. return (
  126. <PrinterProfilesSection
  127. formData={{ ...defaultFormData, material: 'PLA', brand: 'Bambu', slicer_filament: slicerFilament }}
  128. printersWithCalibrations={printers}
  129. filamentOptions={OPTIONS}
  130. modelPresets={modelPresets}
  131. setModelPresets={setModelPresets}
  132. selectedProfiles={selectedProfiles}
  133. setSelectedProfiles={setSelectedProfiles}
  134. selectedGroupId={selectedGroupId}
  135. setSelectedGroupId={setSelectedGroupId}
  136. printerModels={PRINTER_MODELS}
  137. />
  138. );
  139. }
  140. /**
  141. * The preset pickers in the detail pane, in render order.
  142. *
  143. * Each is a button labelled "<model> <size>mm Filament preset" -- not a native
  144. * <select>, because every option carries an origin badge (Bambu Cloud / Orca
  145. * Cloud / Local / Built-in) and a <select> cannot render one.
  146. */
  147. function presetPickers(): HTMLElement[] {
  148. return screen.getAllByRole('button', { name: /Filament preset$/ });
  149. }
  150. function presetPicker(model: string, diameter: string): HTMLElement {
  151. return screen.getByRole('button', { name: `${model} ${diameter}mm Filament preset` });
  152. }
  153. /** Open one picker and return the options it is offering. */
  154. function openPicker(picker: HTMLElement): HTMLElement[] {
  155. fireEvent.click(picker);
  156. return screen.getAllByRole('option');
  157. }
  158. function optionNamed(pattern: RegExp | string): HTMLElement {
  159. return screen.getByRole('option', { name: pattern });
  160. }
  161. /**
  162. * A model's row in the left rail. By role: the model name also appears as the
  163. * detail pane's heading, and the pane is full of other buttons.
  164. */
  165. function modelRow(model: string): HTMLElement {
  166. return screen.getByRole('tab', { name: new RegExp(`^\\s*${model}\\b`) });
  167. }
  168. /** The detail pane's heading, i.e. which model is currently open. */
  169. function openModel(): string {
  170. return screen.getByRole('heading').textContent ?? '';
  171. }
  172. describe('PrinterProfilesSection — spool identity', () => {
  173. it('names the spool being configured, colour included', () => {
  174. // This tab is the one place you read printer names rather than filament,
  175. // and the K lists below are filtered by exactly these fields -- so the line
  176. // also explains an empty list.
  177. const Named = () => {
  178. const [presets, setPresets] = React.useState(new Map<string, PresetChoice>());
  179. const [profiles, setProfiles] = React.useState(new Map<string, CalibrationProfile>());
  180. const [group, setGroup] = React.useState('');
  181. return (
  182. <PrinterProfilesSection
  183. formData={{
  184. ...defaultFormData,
  185. brand: 'Bambu',
  186. material: 'PLA',
  187. subtype: 'Matte',
  188. color_name: 'Scarlet Red',
  189. rgba: 'DE4343FF',
  190. }}
  191. printersWithCalibrations={fleet()}
  192. filamentOptions={OPTIONS}
  193. modelPresets={presets}
  194. setModelPresets={setPresets}
  195. selectedProfiles={profiles}
  196. setSelectedProfiles={setProfiles}
  197. selectedGroupId={group}
  198. setSelectedGroupId={setGroup}
  199. />
  200. );
  201. };
  202. render(<Named />);
  203. expect(screen.getByText('Bambu PLA Matte')).toBeInTheDocument();
  204. expect(screen.getByText('Scarlet Red')).toBeInTheDocument();
  205. });
  206. it('renders no identity bar at all when the spool has nothing to name yet', () => {
  207. const Blank = () => {
  208. const [presets, setPresets] = React.useState(new Map<string, PresetChoice>());
  209. const [profiles, setProfiles] = React.useState(new Map<string, CalibrationProfile>());
  210. const [group, setGroup] = React.useState('');
  211. return (
  212. <PrinterProfilesSection
  213. formData={{ ...defaultFormData, brand: '', material: '', subtype: '', color_name: '' }}
  214. printersWithCalibrations={fleet()}
  215. filamentOptions={OPTIONS}
  216. modelPresets={presets}
  217. setModelPresets={setPresets}
  218. selectedProfiles={profiles}
  219. setSelectedProfiles={setProfiles}
  220. selectedGroupId={group}
  221. setSelectedGroupId={setGroup}
  222. />
  223. );
  224. };
  225. render(<Blank />);
  226. // An empty bar, or one repeating the K section's "select a material first",
  227. // would be noise -- the K section already says it once.
  228. expect(screen.getAllByText(/select a material first/i)).toHaveLength(1);
  229. });
  230. });
  231. describe('PrinterProfilesSection — model list', () => {
  232. it('lists each model once, however many machines it has', () => {
  233. render(<Harness />);
  234. expect(modelRow('X1C')).toBeInTheDocument();
  235. expect(modelRow('H2C')).toBeInTheDocument();
  236. expect(modelRow('P1S')).toBeInTheDocument();
  237. // Two X1Cs, one row -- the rail is per model, not per machine.
  238. expect(within(modelRow('X1C')).getByText('2 printers')).toBeInTheDocument();
  239. });
  240. it('shows the first model by default and switches on click', () => {
  241. render(<Harness />);
  242. // Alphabetical: H2C first. Its machine is named in the detail pane.
  243. expect(openModel()).toBe('H2C');
  244. // Named twice on purpose: once in the pane subtitle, once on its own card.
  245. expect(screen.getAllByText('H2C-1').length).toBeGreaterThan(0);
  246. fireEvent.click(modelRow('X1C'));
  247. expect(openModel()).toBe('X1C');
  248. expect(screen.getByText('X1C-1, X1C-2')).toBeInTheDocument();
  249. });
  250. it('counts hotends with no K profile chosen', () => {
  251. render(<Harness />);
  252. // H2C has two hotends and nothing chosen yet.
  253. expect(within(modelRow('H2C')).getByText('2')).toBeInTheDocument();
  254. });
  255. });
  256. describe('PrinterProfilesSection — filament preset', () => {
  257. it('starts inherited and stores an override when one is picked', () => {
  258. let latest: Map<string, PresetChoice> = new Map();
  259. render(<Harness onPresets={next => { latest = next; }} />);
  260. // One badge per preset row -- one row per nozzle size.
  261. expect(screen.getAllByText('inherited')).toHaveLength(presetPickers().length);
  262. fireEvent.click(presetPicker('H2C', '0.4'));
  263. fireEvent.click(optionNamed('Bambu PLA Basic @BBL H2C Bambu Cloud'));
  264. expect(latest.get(presetKey('H2C', '0.4'))).toEqual({
  265. code: 'GFSA09',
  266. name: 'Bambu PLA Basic @BBL H2C',
  267. });
  268. expect(screen.getByText('override')).toBeInTheDocument();
  269. });
  270. it('clearing an override removes the row rather than storing the spool value', () => {
  271. // A row repeating the spool's own preset would freeze it: later edits to
  272. // the spool preset would stop reaching this model. Absent means inherit.
  273. let latest: Map<string, PresetChoice> = new Map();
  274. render(
  275. <Harness
  276. presets={new Map([[presetKey('H2C', '0.4'), { code: 'GFSA09', name: 'Bambu PLA Basic @BBL H2C' }]])}
  277. onPresets={next => { latest = next; }}
  278. />,
  279. );
  280. fireEvent.click(presetPicker('H2C', '0.4'));
  281. fireEvent.click(optionNamed(/use the spool's preset/i));
  282. expect(latest.has(presetKey('H2C', '0.4'))).toBe(false);
  283. });
  284. it('offers one preset row per nozzle size and nothing above them', () => {
  285. render(<Harness />);
  286. // Every standard size, not only the ones fitted: a spool is configured
  287. // once and nozzles get swapped. No model-wide row -- the preset lands on
  288. // an AMS slot and a slot feeds exactly one nozzle.
  289. expect(presetPickers()).toHaveLength(4);
  290. // (Each size also labels a K-profile grid row, hence getAll.)
  291. expect(screen.getAllByText('0.4mm').length).toBeGreaterThan(0);
  292. expect(screen.getAllByText('0.2mm').length).toBeGreaterThan(0);
  293. // Same for a model with a single diameter fitted across both machines.
  294. fireEvent.click(modelRow('X1C'));
  295. expect(presetPickers()).toHaveLength(4);
  296. });
  297. it('a pick is stored under the size it was made on', () => {
  298. let latest: Map<string, PresetChoice> = new Map();
  299. render(<Harness onPresets={next => { latest = next; }} />);
  300. fireEvent.click(presetPicker('H2C', '0.2'));
  301. fireEvent.click(optionNamed(/0\.2 nozzle/));
  302. expect(latest.get(presetKey('H2C', '0.2'))?.code).toBe('GFSA21');
  303. // Only that size -- the other rows are untouched.
  304. expect(latest.size).toBe(1);
  305. });
  306. it('refuses to offer a preset for a printer whose model is unknown', () => {
  307. render(<Harness printers={[printer(9, 'Mystery', null)]} />);
  308. expect(screen.getByText(/has not reported its model/i)).toBeInTheDocument();
  309. });
  310. });
  311. describe('PrinterProfilesSection — presets offered per model', () => {
  312. function optionNames(picker: HTMLElement): string[] {
  313. return openPicker(picker).map(o => o.textContent ?? '');
  314. }
  315. it('offers a model only the presets that name it', () => {
  316. render(<Harness />);
  317. const names = optionNames(presetPicker('H2C', '0.4'));
  318. expect(names.some(n => n.includes('Bambu PLA Basic @BBL H2C'))).toBe(true);
  319. expect(names.some(n => n.includes('Bambu PLA Basic @BBL X1C'))).toBe(false);
  320. });
  321. it('keeps presets whose model cannot be read from the name', () => {
  322. render(<Harness />);
  323. const names = optionNames(presetPicker('H2C', '0.4'));
  324. expect(names.some(n => n.includes('eSUN PETG Basic (Local)'))).toBe(true);
  325. });
  326. it('switching model switches which presets are offered', () => {
  327. render(<Harness />);
  328. fireEvent.click(modelRow('X1C'));
  329. const names = optionNames(presetPicker('X1C', '0.4'));
  330. expect(names.some(n => n.includes('Bambu PLA Basic @BBL X1C'))).toBe(true);
  331. expect(names.some(n => n.includes('Bambu PLA Basic @BBL H2C'))).toBe(false);
  332. });
  333. it('never hides an override that is already saved', () => {
  334. // The stored value may name another model -- picked before the filter
  335. // existed, or by hand. Dropping it from the list would blank the control
  336. // and quietly change what gets saved.
  337. render(
  338. <Harness
  339. presets={new Map([[presetKey('X1C', '0.4'), { code: 'GFSA09', name: 'Bambu PLA Basic @BBL H2C' }]])}
  340. />,
  341. );
  342. fireEvent.click(modelRow('X1C'));
  343. const picker = presetPicker('X1C', '0.4');
  344. expect(picker.textContent).toContain('Bambu PLA Basic @BBL H2C');
  345. expect(optionNames(picker).some(n => n.includes('Bambu PLA Basic @BBL H2C'))).toBe(true);
  346. });
  347. it('badges each preset with the source it came from', () => {
  348. // The same filament exists as a cloud preset, an imported one and a
  349. // built-in, and which one is picked decides what reaches the printer --
  350. // so the origin is shown here exactly as the Configure AMS Slot modal
  351. // shows it.
  352. render(<Harness />);
  353. const options = openPicker(presetPicker('H2C', '0.4'));
  354. const cloud = options.find(o => o.textContent?.includes('@BBL H2C'));
  355. expect(cloud?.textContent).toContain('Bambu Cloud');
  356. const local = options.find(o => o.textContent?.includes('eSUN PETG Basic'));
  357. expect(local?.textContent).toContain('Local');
  358. });
  359. it('filters the list as you type', () => {
  360. render(<Harness />);
  361. fireEvent.click(presetPicker('H2C', '0.4'));
  362. fireEvent.change(screen.getByPlaceholderText(/search filament presets/i), {
  363. target: { value: 'esun' },
  364. });
  365. const names = screen.getAllByRole('option').map(o => o.textContent ?? '');
  366. expect(names.some(n => n.includes('eSUN'))).toBe(true);
  367. expect(names.some(n => n.includes('Bambu PLA Basic'))).toBe(false);
  368. });
  369. });
  370. describe('PrinterProfilesSection — every nozzle size', () => {
  371. it('offers a preset row for all four standard sizes, fitted or not', () => {
  372. // A spool is configured once and nozzles get swapped. X1C has only a 0.4
  373. // fitted, and must still offer 0.2 / 0.6 / 0.8.
  374. render(<Harness />);
  375. fireEvent.click(modelRow('X1C'));
  376. expect(presetPickers()).toHaveLength(4);
  377. for (const size of ['0.2mm', '0.4mm', '0.6mm', '0.8mm']) {
  378. expect(screen.getAllByText(size).length).toBeGreaterThan(0);
  379. }
  380. });
  381. it('stores a preset for a size that is not currently fitted', () => {
  382. let latest: Map<string, PresetChoice> = new Map();
  383. render(<Harness onPresets={next => { latest = next; }} />);
  384. fireEvent.click(modelRow('X1C'));
  385. fireEvent.click(presetPicker('X1C', '0.6'));
  386. fireEvent.click(optionNamed('Bambu PLA Basic @BBL X1C Bambu Cloud'));
  387. expect(latest.get(presetKey('X1C', '0.6'))?.code).toBe('GFSA00');
  388. });
  389. it('offers a K profile for a size the printer has profiles for but has not fitted', () => {
  390. // fetchPrinterCalibrations now asks for every standard size, so a 0.6
  391. // profile reaches the picker without a 0.6 being screwed in.
  392. let latest: Map<string, CalibrationProfile> = new Map();
  393. const withSpare = [
  394. printer(1, 'X1C-1', 'X1C', {
  395. nozzles: [{ nozzle_diameter: '0.4' }],
  396. calibrations: [
  397. cal({ cali_idx: 1, nozzle_diameter: '0.4' }),
  398. cal({ cali_idx: 7, nozzle_diameter: '0.6', k_value: 0.026, name: 'PLA 0.6' }),
  399. ],
  400. }),
  401. ];
  402. render(<Harness printers={withSpare} onProfiles={next => { latest = next; }} />);
  403. fireEvent.change(screen.getByLabelText('X1C-1 Nozzle 0.6mm'), { target: { value: '7' } });
  404. expect(latest.get(hotendKey(1, 0, '0.6'))?.cali_idx).toBe(7);
  405. });
  406. });
  407. describe('PrinterProfilesSection — auto-match', () => {
  408. it('fills every nozzle size of each model, preferring the variant for that size', () => {
  409. let latest: Map<string, PresetChoice> = new Map();
  410. render(<Harness onPresets={next => { latest = next; }} />);
  411. fireEvent.click(screen.getByRole('button', { name: /auto-match/i }));
  412. // The H2C list holds a plain "@BBL H2C" and an "@BBL H2C 0.2 nozzle". The
  413. // 0.2 row takes the sized variant; the rest take the unsized one.
  414. expect(latest.get(presetKey('H2C', '0.2'))?.code).toBe('GFSA21');
  415. expect(latest.get(presetKey('H2C', '0.4'))?.code).toBe('GFSA09');
  416. expect(latest.get(presetKey('H2C', '0.8'))?.code).toBe('GFSA09');
  417. // Nothing is written to the model-wide key -- there is no row that shows
  418. // it, and a stored value nobody can see is a value nobody can undo.
  419. expect(latest.has(presetKey('H2C', ''))).toBe(false);
  420. });
  421. it('leaves a model with no matching variant inherited', () => {
  422. let latest: Map<string, PresetChoice> = new Map();
  423. render(<Harness onPresets={next => { latest = next; }} />);
  424. fireEvent.click(screen.getByRole('button', { name: /auto-match/i }));
  425. // P1S has no preset of this filament in the list, and an approximate one
  426. // is worse than falling back to the spool's own.
  427. for (const size of ['', '0.2', '0.4', '0.6', '0.8']) {
  428. expect(latest.has(presetKey('P1S', size))).toBe(false);
  429. }
  430. });
  431. });
  432. describe('PrinterProfilesSection — K profiles', () => {
  433. it('keys a chosen profile by printer, extruder and diameter', () => {
  434. let latest: Map<string, CalibrationProfile> = new Map();
  435. render(<Harness onProfiles={next => { latest = next; }} />);
  436. // Addressed by cell rather than by DOM order: the grid is size down the
  437. // side and hotend across, and only cells the printer has a calibration for
  438. // hold a dropdown at all.
  439. fireEvent.change(screen.getByLabelText('H2C-1 Left Nozzle 0.2mm'), {
  440. target: { value: '16' },
  441. });
  442. // Extruder 1 at 0.2mm -- not the 0.4 hotend, and not keyed by cali_idx,
  443. // which is numbered per nozzle and repeats across hotends.
  444. expect(latest.get(hotendKey(3, 1, '0.2'))?.cali_idx).toBe(16);
  445. expect(latest.has(hotendKey(3, 0, '0.4'))).toBe(false);
  446. });
  447. it('lays the grid out as nozzle size by hotend, with a dash where the printer has nothing', () => {
  448. render(<Harness />);
  449. // The H2C has a 0.2 profile on the left hotend and a 0.4 on the right.
  450. expect(screen.getByLabelText('H2C-1 Left Nozzle 0.2mm')).toBeInTheDocument();
  451. expect(screen.getByLabelText('H2C-1 Right Nozzle 0.4mm')).toBeInTheDocument();
  452. // The other six cells have no calibration to offer, so no control.
  453. expect(screen.queryByLabelText('H2C-1 Left Nozzle 0.4mm')).not.toBeInTheDocument();
  454. expect(screen.queryByLabelText('H2C-1 Right Nozzle 0.8mm')).not.toBeInTheDocument();
  455. // Every size is still listed down the side rather than looking forgotten.
  456. for (const size of ['0.2mm', '0.4mm', '0.6mm', '0.8mm']) {
  457. expect(screen.getAllByText(size).length).toBeGreaterThan(0);
  458. }
  459. });
  460. it('gives a single-nozzle machine one unnamed column', () => {
  461. render(<Harness />);
  462. fireEvent.click(modelRow('X1C'));
  463. expect(screen.getByLabelText('X1C-1 Nozzle 0.4mm')).toBeInTheDocument();
  464. expect(screen.queryByLabelText(/X1C-1 (Left|Right) Nozzle/)).not.toBeInTheDocument();
  465. });
  466. it('choosing again on one hotend replaces rather than accumulates', () => {
  467. let latest: Map<string, CalibrationProfile> = new Map();
  468. render(<Harness onProfiles={next => { latest = next; }} />);
  469. fireEvent.click(modelRow('X1C'));
  470. const kSelect = screen.getByLabelText('X1C-1 Nozzle 0.4mm');
  471. fireEvent.change(kSelect, { target: { value: '1' } });
  472. fireEvent.change(kSelect, { target: { value: '' } });
  473. expect(latest.size).toBe(0);
  474. });
  475. it('says an offline printer cannot be configured instead of showing empty rows', () => {
  476. render(<Harness />);
  477. fireEvent.click(modelRow('P1S'));
  478. expect(screen.getByText(/printer is offline/i)).toBeInTheDocument();
  479. });
  480. it('labels the hotends of a dual-nozzle machine by side', () => {
  481. render(<Harness />);
  482. expect(screen.getByText('Right Nozzle')).toBeInTheDocument();
  483. expect(screen.getByText('Left Nozzle')).toBeInTheDocument();
  484. // A single-nozzle machine has no side to name.
  485. fireEvent.click(modelRow('X1C'));
  486. expect(screen.queryByText('Right Nozzle')).not.toBeInTheDocument();
  487. expect(screen.getAllByText('Nozzle').length).toBeGreaterThan(0);
  488. });
  489. });
  490. describe('PrinterProfilesSection — nozzle flow type', () => {
  491. function flowFleet() {
  492. return [
  493. printer(1, 'H2D-1', 'H2D', {
  494. nozzleCount: 2,
  495. nozzles: [
  496. { nozzle_diameter: '0.4', nozzle_type: 'HH01' },
  497. { nozzle_diameter: '0.4', nozzle_type: 'HH01' },
  498. ],
  499. calibrations: [
  500. cal({ cali_idx: 4, nozzle_id: 'HH00-0.4', name: 'High Flow_PLA' }),
  501. cal({ cali_idx: 5, nozzle_id: 'HS00-0.4', name: 'Standard_PLA', k_value: 0.019 }),
  502. ],
  503. }),
  504. ];
  505. }
  506. it('labels each profile with the flow it was measured on', () => {
  507. // A printer can hold both for one diameter -- this H2D has 102 high-flow
  508. // entries and 6 standard -- and the same filament reads a different K
  509. // through each, so the list has to say which is which.
  510. render(<Harness printers={flowFleet()} />);
  511. const options = within(screen.getByLabelText('H2D-1 Right Nozzle 0.4mm'))
  512. .getAllByRole('option')
  513. .map(o => o.textContent ?? '');
  514. expect(options.some(o => o.startsWith('[HF]'))).toBe(true);
  515. expect(options.some(o => o.startsWith('[S]'))).toBe(true);
  516. });
  517. it('says nothing about flow when the printer declares none', () => {
  518. // Measured on an X1C: every profile comes back with nozzle_id ''. A label
  519. // there would be invented rather than reported.
  520. render(<Harness />);
  521. const options = within(screen.getByLabelText('H2C-1 Right Nozzle 0.4mm'))
  522. .getAllByRole('option')
  523. .map(o => o.textContent ?? '');
  524. expect(options.some(o => o.includes('[HF]') || o.includes('[S]'))).toBe(false);
  525. });
  526. it('marks a chosen profile that does not match the fitted nozzle', () => {
  527. // Standard profile chosen, high-flow nozzle fitted: the backend will not
  528. // apply it, so the control must not look quietly configured.
  529. const chosen = new Map([
  530. [
  531. hotendKey(1, 0, '0.4'),
  532. cal({ cali_idx: 5, nozzle_id: 'HS00-0.4', name: 'Standard_PLA' }),
  533. ],
  534. ]);
  535. render(<Harness printers={flowFleet()} profiles={chosen} />);
  536. const select = screen.getByLabelText('H2D-1 Right Nozzle 0.4mm');
  537. expect(select.getAttribute('title')).toMatch(/will not be applied/i);
  538. expect(select.className).toContain('border-amber');
  539. });
  540. it('does not mark a profile whose flow agrees', () => {
  541. const chosen = new Map([
  542. [hotendKey(1, 0, '0.4'), cal({ cali_idx: 4, nozzle_id: 'HH00-0.4' })],
  543. ]);
  544. render(<Harness printers={flowFleet()} profiles={chosen} />);
  545. const select = screen.getByLabelText('H2D-1 Right Nozzle 0.4mm');
  546. expect(select.getAttribute('title')).toBeNull();
  547. });
  548. });
  549. describe('PrinterProfilesSection — empty fleet', () => {
  550. it('says so rather than rendering an empty two-pane layout', () => {
  551. render(<Harness printers={[]} />);
  552. expect(screen.getByText(/no printers configured/i)).toBeInTheDocument();
  553. });
  554. it('waits rather than claiming there are no printers while still loading', () => {
  555. // Reading each printer's calibration table is several MQTT round trips, so
  556. // this gap is seconds. Saying "no printers configured" during it is a wrong
  557. // answer about the user's setup, not a slow one.
  558. const Loading = () => {
  559. const [presets, setPresets] = React.useState(new Map<string, PresetChoice>());
  560. const [profiles, setProfiles] = React.useState(new Map<string, CalibrationProfile>());
  561. const [group, setGroup] = React.useState('');
  562. return (
  563. <PrinterProfilesSection
  564. formData={{ ...defaultFormData, material: 'PLA' }}
  565. printersWithCalibrations={[]}
  566. filamentOptions={OPTIONS}
  567. modelPresets={presets}
  568. setModelPresets={setPresets}
  569. selectedProfiles={profiles}
  570. setSelectedProfiles={setProfiles}
  571. selectedGroupId={group}
  572. setSelectedGroupId={setGroup}
  573. isLoading
  574. />
  575. );
  576. };
  577. render(<Loading />);
  578. expect(screen.queryByText(/no printers configured/i)).not.toBeInTheDocument();
  579. expect(screen.getByText(/loading/i)).toBeInTheDocument();
  580. });
  581. });
  582. describe('PrinterProfilesSection — no material yet', () => {
  583. it('asks for a material before offering K profiles', () => {
  584. const Bare = () => {
  585. const [presets, setPresets] = React.useState(new Map<string, PresetChoice>());
  586. const [profiles, setProfiles] = React.useState(new Map<string, CalibrationProfile>());
  587. const [group, setGroup] = React.useState('');
  588. return (
  589. <PrinterProfilesSection
  590. formData={{ ...defaultFormData, material: '' }}
  591. printersWithCalibrations={fleet()}
  592. filamentOptions={OPTIONS}
  593. modelPresets={presets}
  594. setModelPresets={setPresets}
  595. selectedProfiles={profiles}
  596. setSelectedProfiles={setProfiles}
  597. selectedGroupId={group}
  598. setSelectedGroupId={setGroup}
  599. />
  600. );
  601. };
  602. render(<Bare />);
  603. expect(screen.getByText(/select a material first/i)).toBeInTheDocument();
  604. // The preset half still works -- it does not depend on the material.
  605. expect(screen.getByText('Filament preset')).toBeInTheDocument();
  606. });
  607. });
  608. describe('PrinterProfilesSection — vi sanity', () => {
  609. it('does not call any API of its own', () => {
  610. // The section is presentational: everything it changes goes through the
  611. // props, and SpoolFormModal is what persists it on save.
  612. const spy = vi.fn();
  613. render(<Harness onPresets={spy} />);
  614. expect(spy).toHaveBeenCalled();
  615. });
  616. });