SlicerSettingsPanel.test.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. import { describe, it, expect, vi } from 'vitest';
  2. import { screen, waitFor, within } from '@testing-library/react';
  3. import userEvent from '@testing-library/user-event';
  4. import { useState } from 'react';
  5. import { render } from '../utils';
  6. import SlicerSettingsPanel, { type FilamentChoice } from '../../components/SlicerSettingsPanel';
  7. import type { SettingValue } from '../../types/slicerSettings';
  8. import type { DesignOverride } from '../../types/plates';
  9. /**
  10. * The panel is a controlled component: it renders from the `values` prop and
  11. * reports edits upward. Driving it with a bare spy would leave every input
  12. * frozen at its initial value, so the harness holds state the way SliceModal
  13. * does and forwards each call to the spy for assertions.
  14. */
  15. function Harness({
  16. initial,
  17. onChange,
  18. sourceOverrides,
  19. initialSelected,
  20. filamentChoices,
  21. presetValues,
  22. presetValuesResolved,
  23. }: {
  24. initial: Record<string, SettingValue>;
  25. onChange: (v: Record<string, SettingValue>, s: Record<string, string | string[]>) => void;
  26. sourceOverrides?: DesignOverride[];
  27. initialSelected?: string[];
  28. filamentChoices?: FilamentChoice[];
  29. presetValues?: Record<string, SettingValue>;
  30. presetValuesResolved?: boolean;
  31. }) {
  32. const [values, setValues] = useState(initial);
  33. const [selected, setSelected] = useState(new Set(initialSelected ?? []));
  34. return (
  35. <SlicerSettingsPanel
  36. values={values}
  37. onChange={(v, s) => {
  38. setValues(v);
  39. onChange(v, s);
  40. }}
  41. filamentChoices={filamentChoices}
  42. presetValues={presetValues}
  43. presetValuesResolved={presetValuesResolved}
  44. sourceOverrides={sourceOverrides}
  45. sourceSelected={selected}
  46. onToggleSource={(key, on) =>
  47. setSelected((prev) => {
  48. const next = new Set(prev);
  49. if (on) next.add(key);
  50. else next.delete(key);
  51. return next;
  52. })
  53. }
  54. />
  55. );
  56. }
  57. /** Renders the panel and waits for its dynamically imported metadata. */
  58. async function renderPanel(
  59. initial: Record<string, SettingValue> = {},
  60. extra: {
  61. sourceOverrides?: DesignOverride[];
  62. initialSelected?: string[];
  63. filamentChoices?: FilamentChoice[];
  64. presetValues?: Record<string, SettingValue>;
  65. presetValuesResolved?: boolean;
  66. } = {},
  67. ) {
  68. const onChange = vi.fn();
  69. render(<Harness initial={initial} onChange={onChange} {...extra} />);
  70. await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
  71. return { onChange };
  72. }
  73. /**
  74. * Brings one option on screen regardless of which page or visibility tier it
  75. * belongs to. Searching spans every page, which is how a user would reach a
  76. * setting they know the name of.
  77. */
  78. async function showOption(user: ReturnType<typeof userEvent.setup>, label: string, search: string) {
  79. await user.click(screen.getByRole('button', { name: 'Expert' }));
  80. const box = screen.getByPlaceholderText('Search settings');
  81. await user.clear(box);
  82. await user.type(box, search);
  83. return waitFor(() => screen.getByLabelText(new RegExp(`^${label}`)));
  84. }
  85. describe('SlicerSettingsPanel', () => {
  86. it('opens on the first page of the slicer parameter tree', async () => {
  87. await renderPanel();
  88. expect(screen.getByRole('button', { name: 'Quality' })).toBeInTheDocument();
  89. expect(screen.getByRole('button', { name: 'Strength' })).toBeInTheDocument();
  90. expect(screen.getByLabelText(/^Layer height/)).toBeInTheDocument();
  91. });
  92. it('reveals more options as the visibility tier widens', async () => {
  93. const user = userEvent.setup();
  94. await renderPanel();
  95. // "Slice gap closing radius" is an advanced-tier Quality option.
  96. expect(screen.queryByLabelText(/^Slice gap closing radius/)).not.toBeInTheDocument();
  97. await user.click(screen.getByRole('button', { name: 'Advanced' }));
  98. await waitFor(() => expect(screen.getByLabelText(/^Slice gap closing radius/)).toBeInTheDocument());
  99. });
  100. it('searches across every page rather than only the open one', async () => {
  101. const user = userEvent.setup();
  102. await renderPanel();
  103. // Enable support lives on the Support page, not the Quality page shown.
  104. await user.type(screen.getByPlaceholderText('Search settings'), 'enable support');
  105. await waitFor(() => expect(screen.getByLabelText(/^Enable support/)).toBeInTheDocument());
  106. });
  107. it('reports an edit serialised the way a process preset stores it', async () => {
  108. const user = userEvent.setup();
  109. const { onChange } = await renderPanel();
  110. const input = screen.getByLabelText(/^Layer height/);
  111. await user.clear(input);
  112. await user.type(input, '0.16');
  113. await waitFor(() => {
  114. const [values, serialized] = onChange.mock.calls.at(-1)!;
  115. expect(values.layer_height).toBe('0.16');
  116. expect(serialized.layer_height).toBe('0.16');
  117. });
  118. });
  119. it('puts the percent sign back on a percent option', async () => {
  120. const user = userEvent.setup();
  121. const { onChange } = await renderPanel();
  122. const input = await showOption(user, 'Sparse infill density', 'sparse infill density');
  123. await user.clear(input);
  124. await user.type(input, '35');
  125. // "35" and "35%" are different values to the slicer; the schema decides.
  126. await waitFor(() => {
  127. const [, serialized] = onChange.mock.calls.at(-1)!;
  128. expect(serialized.sparse_infill_density).toBe('35%');
  129. });
  130. });
  131. it('sends nothing for a value that equals the preset default', async () => {
  132. const user = userEvent.setup();
  133. const { onChange } = await renderPanel();
  134. // wall_loops defaults to 2 — typing it back is not an override.
  135. const input = await showOption(user, 'Wall loops', 'wall loops');
  136. await user.clear(input);
  137. await user.type(input, '2');
  138. await waitFor(() => {
  139. const [values, serialized] = onChange.mock.calls.at(-1)!;
  140. expect(values.wall_loops).toBe('2');
  141. expect(serialized).not.toHaveProperty('wall_loops');
  142. });
  143. });
  144. it('greys out options the slicer disables at the current settings', async () => {
  145. // sparse_infill_density at 0 turns off have_infill, which gates the infill
  146. // pattern — the same rule the desktop slicer applies.
  147. const user = userEvent.setup();
  148. await renderPanel({ sparse_infill_density: '0%' });
  149. const pattern = await showOption(user, 'Sparse infill pattern', 'sparse infill pattern');
  150. expect(pattern).toBeDisabled();
  151. });
  152. it('keeps an option editable while infill is on', async () => {
  153. const user = userEvent.setup();
  154. await renderPanel({ sparse_infill_density: '15%' });
  155. const pattern = await showOption(user, 'Sparse infill pattern', 'sparse infill pattern');
  156. expect(pattern).not.toBeDisabled();
  157. });
  158. it('lets a field be emptied without snapping back to the default', async () => {
  159. // Regression: dropping the key on an empty input made the control fall
  160. // straight back to the preset default, so clearing a value to retype it
  161. // appended to the old one ("0.2" + "0.16" = "0.2016").
  162. const user = userEvent.setup();
  163. await renderPanel();
  164. const input = screen.getByLabelText(/^Layer height/);
  165. await user.clear(input);
  166. expect(input).toHaveValue(null);
  167. });
  168. it('lets a free-text field be emptied too', async () => {
  169. // coFloatOrPercent / coString / vector options render as text rather than
  170. // number inputs, and the same drop-the-key-on-empty bug lived on that
  171. // branch after the number branch was fixed.
  172. const user = userEvent.setup();
  173. await renderPanel();
  174. const input = await showOption(user, 'Default', 'line_width');
  175. await user.clear(input);
  176. expect(input).toHaveValue('');
  177. });
  178. it('clears every override from the header reset', async () => {
  179. const user = userEvent.setup();
  180. const { onChange } = await renderPanel({ layer_height: '0.16' });
  181. await user.click(await screen.findByRole('button', { name: /Reset 1/ }));
  182. const [values, serialized] = onChange.mock.calls.at(-1)!;
  183. expect(values).toEqual({});
  184. expect(serialized).toEqual({});
  185. });
  186. it('reverts a single option without touching the others', async () => {
  187. const user = userEvent.setup();
  188. const { onChange } = await renderPanel({ layer_height: '0.16', wall_loops: 4 });
  189. const row = screen.getByLabelText(/^Layer height/).closest('div.group') as HTMLElement;
  190. await user.click(within(row).getByRole('button', { name: 'Reset to default' }));
  191. const [values] = onChange.mock.calls.at(-1)!;
  192. expect(values).not.toHaveProperty('layer_height');
  193. expect(values.wall_loops).toBe(4);
  194. });
  195. });
  196. describe('SlicerSettingsPanel — search', () => {
  197. it('treats underscores and spaces alike so a key can be typed naturally', async () => {
  198. // outer_wall_speed's label is only "Outer wall" — the Speed page supplies
  199. // the rest — so the key is the only place the full phrase appears.
  200. const user = userEvent.setup();
  201. await renderPanel();
  202. await user.click(screen.getByRole('button', { name: 'Expert' }));
  203. await user.type(screen.getByPlaceholderText('Search settings'), 'outer wall speed');
  204. await waitFor(() => expect(screen.getByLabelText(/^Outer wall/)).toBeInTheDocument());
  205. });
  206. it('matches a page or group name, not just option labels', async () => {
  207. const user = userEvent.setup();
  208. await renderPanel();
  209. await user.click(screen.getByRole('button', { name: 'Expert' }));
  210. await user.type(screen.getByPlaceholderText('Search settings'), 'ironing');
  211. await waitFor(() => expect(screen.getByLabelText(/^Ironing type/)).toBeInTheDocument());
  212. });
  213. });
  214. describe("SlicerSettingsPanel — the source file's own settings", () => {
  215. const sourceOverrides: DesignOverride[] = [
  216. { key: 'wall_loops', value: '5', printer_coupled: false },
  217. { key: 'outer_wall_speed', value: '200', printer_coupled: true },
  218. // A key the vendored schema has no entry for. It still applies, so it must
  219. // not silently vanish from a panel that claims to show what will be used.
  220. { key: 'some_unlisted_key', value: '7', printer_coupled: false },
  221. ];
  222. it("shows the designer's value against the option once switched on", async () => {
  223. const user = userEvent.setup();
  224. await renderPanel({}, { sourceOverrides, initialSelected: ['wall_loops'] });
  225. const input = await showOption(user, 'Wall loops', 'wall loops');
  226. expect(input).toHaveValue(5);
  227. expect(screen.getByText('from file')).toBeInTheDocument();
  228. });
  229. it('falls back to the preset value when it is switched off', async () => {
  230. const user = userEvent.setup();
  231. await renderPanel({}, { sourceOverrides, initialSelected: [] });
  232. // wall_loops defaults to 2 in the schema.
  233. const input = await showOption(user, 'Wall loops', 'wall loops');
  234. expect(input).toHaveValue(2);
  235. });
  236. it("puts the file's tick before the control it qualifies", async () => {
  237. // A checkbox that gates a field belongs ahead of it. It used to render
  238. // after the unit, out at the row's right edge, reading as unrelated.
  239. const user = userEvent.setup();
  240. await renderPanel({}, { sourceOverrides, initialSelected: ['wall_loops'] });
  241. const control = await showOption(user, 'Wall loops', 'wall loops');
  242. const row = control.closest('div.group') as HTMLElement;
  243. const tick = within(row).getByRole('checkbox');
  244. const controlFollowsTick = tick.compareDocumentPosition(control) & Node.DOCUMENT_POSITION_FOLLOWING;
  245. expect(controlFollowsTick).toBeTruthy();
  246. });
  247. it('flags a machine-coupled setting rather than applying it quietly', async () => {
  248. const user = userEvent.setup();
  249. await renderPanel({}, { sourceOverrides, initialSelected: ['wall_loops'] });
  250. await user.click(screen.getByRole('button', { name: 'Expert' }));
  251. await user.type(screen.getByPlaceholderText('Search settings'), 'outer wall speed');
  252. await waitFor(() => expect(screen.getByText("designer's printer")).toBeInTheDocument());
  253. });
  254. it('lists source settings the schema has no entry for', async () => {
  255. await renderPanel({}, { sourceOverrides, initialSelected: ['some_unlisted_key'] });
  256. await waitFor(() => expect(screen.getByText('Other settings from this file')).toBeInTheDocument());
  257. expect(screen.getByText('some_unlisted_key')).toBeInTheDocument();
  258. expect(screen.getByText('7')).toBeInTheDocument();
  259. });
  260. it('keeps a typed value ahead of the file\'s', async () => {
  261. const user = userEvent.setup();
  262. const { onChange } = await renderPanel({}, { sourceOverrides, initialSelected: ['wall_loops'] });
  263. const input = await showOption(user, 'Wall loops', 'wall loops');
  264. expect(input).toHaveValue(5);
  265. await user.clear(input);
  266. await user.type(input, '3');
  267. // The typed value is what gets sent; the file's tick is unaffected and the
  268. // backend applies it first, so last-write-wins leaves 3 in the process JSON.
  269. await waitFor(() => {
  270. const [, serialized] = onChange.mock.calls.at(-1)!;
  271. expect(serialized.wall_loops).toBe('3');
  272. });
  273. });
  274. });
  275. describe('SlicerSettingsPanel — filament-slot options', () => {
  276. const filamentChoices: FilamentChoice[] = [
  277. { index: 1, label: 'Bambu PLA Basic', color: '#FF0000' },
  278. { index: 2, label: 'Bambu Support for PLA', color: '#FFFFFF' },
  279. ];
  280. it("follows the slicer's own gating rather than being live regardless", async () => {
  281. // The interface picker sits behind have_support_material, so it greys out
  282. // with supports off — becoming a dropdown must not exempt it from the
  283. // rules every other option obeys.
  284. const user = userEvent.setup();
  285. await renderPanel({}, { filamentChoices });
  286. const off = await showOption(user, 'Support/raft interface', 'support_interface_filament');
  287. expect(off).toBeDisabled();
  288. });
  289. it('is operable once supports are switched on', async () => {
  290. const user = userEvent.setup();
  291. await renderPanel({ enable_support: true }, { filamentChoices });
  292. const on = await showOption(user, 'Support/raft interface', 'support_interface_filament');
  293. expect(on).toBeEnabled();
  294. });
  295. it('offers the picked filaments instead of a bare number field', async () => {
  296. const user = userEvent.setup();
  297. await renderPanel({}, { filamentChoices });
  298. const control = await showOption(user, 'Support/raft base', 'support_filament');
  299. expect(control.tagName).toBe('SELECT');
  300. const labels = Array.from((control as HTMLSelectElement).options).map((o) => o.textContent);
  301. expect(labels).toEqual(['Default', '1: Bambu PLA Basic', '2: Bambu Support for PLA']);
  302. });
  303. it("defaults to the slicer's 0, meaning no specific filament", async () => {
  304. const user = userEvent.setup();
  305. await renderPanel({}, { filamentChoices });
  306. const control = await showOption(user, 'Support/raft base', 'support_filament');
  307. expect(control).toHaveValue('0');
  308. });
  309. it('sends the slot index the slicer expects', async () => {
  310. const user = userEvent.setup();
  311. const { onChange } = await renderPanel({ enable_support: true }, { filamentChoices });
  312. const control = await showOption(user, 'Support/raft interface', 'support_interface_filament');
  313. await user.selectOptions(control, '2');
  314. await waitFor(() => {
  315. const [, serialized] = onChange.mock.calls.at(-1)!;
  316. expect(serialized.support_interface_filament).toBe('2');
  317. });
  318. });
  319. it('stays a plain number field when no filaments have been picked', async () => {
  320. // STL sources and the pre-plate-analysis window have no slot list yet;
  321. // an empty dropdown would be worse than the number input it replaced.
  322. const user = userEvent.setup();
  323. await renderPanel({}, { filamentChoices: [] });
  324. const control = await showOption(user, 'Support/raft base', 'support_filament');
  325. expect(control.tagName).toBe('INPUT');
  326. });
  327. it('leaves unrelated integer options alone', async () => {
  328. const user = userEvent.setup();
  329. await renderPanel({}, { filamentChoices });
  330. const control = await showOption(user, 'Wall loops', 'wall loops');
  331. expect(control.tagName).toBe('INPUT');
  332. });
  333. });
  334. describe('SlicerSettingsPanel — the picked preset\'s values', () => {
  335. it('shows the preset value rather than the compiled-in default', async () => {
  336. // The reported bug: line_width defaults to 0 in OrcaSlicer's C++ (meaning
  337. // "derive from the nozzle"), so every Line width field read 0 regardless
  338. // of what the chosen preset actually sets.
  339. const user = userEvent.setup();
  340. await renderPanel({}, { presetValues: { line_width: '0.42' } });
  341. const input = await showOption(user, 'Default', 'line_width');
  342. expect(input).toHaveValue('0.42');
  343. });
  344. it('does not mark a preset value as a user change', async () => {
  345. // Comparing against the schema default would flag every field the preset
  346. // moved off the C++ default as edited, and send values nobody typed.
  347. const { onChange } = await renderPanel({}, { presetValues: { line_width: '0.42' } });
  348. await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
  349. expect(screen.queryByRole('button', { name: /Reset \d/ })).not.toBeInTheDocument();
  350. expect(onChange).not.toHaveBeenCalled();
  351. });
  352. it('sends an edit that differs from the preset', async () => {
  353. const user = userEvent.setup();
  354. const { onChange } = await renderPanel({}, { presetValues: { line_width: '0.42' } });
  355. const input = await showOption(user, 'Default', 'line_width');
  356. await user.clear(input);
  357. await user.type(input, '0.5');
  358. await waitFor(() => {
  359. const [, serialized] = onChange.mock.calls.at(-1)!;
  360. expect(serialized.line_width).toBe('0.5');
  361. });
  362. });
  363. it('sends nothing for a value retyped to match the preset', async () => {
  364. const user = userEvent.setup();
  365. const { onChange } = await renderPanel({}, { presetValues: { line_width: '0.42' } });
  366. const input = await showOption(user, 'Default', 'line_width');
  367. await user.clear(input);
  368. await user.type(input, '0.42');
  369. await waitFor(() => expect(onChange).toHaveBeenCalled());
  370. const [, serialized] = onChange.mock.calls.at(-1)!;
  371. expect(serialized).not.toHaveProperty('line_width');
  372. });
  373. it('reverts to the preset value, not the schema default', async () => {
  374. const user = userEvent.setup();
  375. await renderPanel({ line_width: '0.5' }, { presetValues: { line_width: '0.42' } });
  376. const input = await showOption(user, 'Default', 'line_width');
  377. expect(input).toHaveValue('0.5');
  378. const row = input.closest('div.group') as HTMLElement;
  379. await user.click(within(row).getByRole('button', { name: 'Reset to default' }));
  380. await waitFor(() => expect(screen.getByLabelText(/^Default/)).toHaveValue('0.42'));
  381. });
  382. it('says so when the preset values could not be read', async () => {
  383. await renderPanel({}, { presetValuesResolved: false });
  384. await waitFor(() => expect(screen.getByText(/Showing slicer defaults/)).toBeInTheDocument());
  385. });
  386. it('shows no such notice when they resolved', async () => {
  387. await renderPanel({}, { presetValues: { line_width: '0.42' }, presetValuesResolved: true });
  388. await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
  389. expect(screen.queryByText(/Showing slicer defaults/)).not.toBeInTheDocument();
  390. });
  391. });