SlicerSettingsPanel.test.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  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. import type { SlicerPresetValuesReason } from '../../api/client';
  10. /**
  11. * The panel is a controlled component: it renders from the `values` prop and
  12. * reports edits upward. Driving it with a bare spy would leave every input
  13. * frozen at its initial value, so the harness holds state the way SliceModal
  14. * does and forwards each call to the spy for assertions.
  15. */
  16. function Harness({
  17. initial,
  18. onChange,
  19. sourceOverrides,
  20. initialSelected,
  21. filamentChoices,
  22. presetValues,
  23. presetValuesResolved,
  24. presetValuesReason,
  25. }: {
  26. initial: Record<string, SettingValue>;
  27. onChange: (v: Record<string, SettingValue>, s: Record<string, string | string[]>) => void;
  28. sourceOverrides?: DesignOverride[];
  29. initialSelected?: string[];
  30. filamentChoices?: FilamentChoice[];
  31. presetValues?: Record<string, SettingValue>;
  32. presetValuesResolved?: boolean;
  33. presetValuesReason?: SlicerPresetValuesReason;
  34. }) {
  35. const [values, setValues] = useState(initial);
  36. const [selected, setSelected] = useState(new Set(initialSelected ?? []));
  37. return (
  38. <SlicerSettingsPanel
  39. values={values}
  40. onChange={(v, s) => {
  41. setValues(v);
  42. onChange(v, s);
  43. }}
  44. filamentChoices={filamentChoices}
  45. presetValues={presetValues}
  46. presetValuesResolved={presetValuesResolved}
  47. presetValuesReason={presetValuesReason}
  48. sourceOverrides={sourceOverrides}
  49. sourceSelected={selected}
  50. onToggleSource={(key, on) =>
  51. setSelected((prev) => {
  52. const next = new Set(prev);
  53. if (on) next.add(key);
  54. else next.delete(key);
  55. return next;
  56. })
  57. }
  58. />
  59. );
  60. }
  61. /** Renders the panel and waits for its dynamically imported metadata. */
  62. async function renderPanel(
  63. initial: Record<string, SettingValue> = {},
  64. extra: {
  65. sourceOverrides?: DesignOverride[];
  66. initialSelected?: string[];
  67. filamentChoices?: FilamentChoice[];
  68. presetValues?: Record<string, SettingValue>;
  69. presetValuesResolved?: boolean;
  70. presetValuesReason?: SlicerPresetValuesReason;
  71. } = {},
  72. ) {
  73. const onChange = vi.fn();
  74. render(<Harness initial={initial} onChange={onChange} {...extra} />);
  75. await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
  76. return { onChange };
  77. }
  78. /**
  79. * Brings one option on screen regardless of which page or visibility tier it
  80. * belongs to. Searching spans every page, which is how a user would reach a
  81. * setting they know the name of.
  82. */
  83. async function showOption(user: ReturnType<typeof userEvent.setup>, label: string, search: string) {
  84. await user.click(screen.getByRole('button', { name: 'Expert' }));
  85. const box = screen.getByPlaceholderText('Search settings');
  86. await user.clear(box);
  87. await user.type(box, search);
  88. return waitFor(() => screen.getByLabelText(new RegExp(`^${label}`)));
  89. }
  90. describe('SlicerSettingsPanel', () => {
  91. it('opens on the first page of the slicer parameter tree', async () => {
  92. await renderPanel();
  93. expect(screen.getByRole('button', { name: 'Quality' })).toBeInTheDocument();
  94. expect(screen.getByRole('button', { name: 'Strength' })).toBeInTheDocument();
  95. expect(screen.getByLabelText(/^Layer height/)).toBeInTheDocument();
  96. });
  97. it('reveals more options as the visibility tier widens', async () => {
  98. const user = userEvent.setup();
  99. await renderPanel();
  100. // "Slice gap closing radius" is an advanced-tier Quality option.
  101. expect(screen.queryByLabelText(/^Slice gap closing radius/)).not.toBeInTheDocument();
  102. await user.click(screen.getByRole('button', { name: 'Advanced' }));
  103. await waitFor(() => expect(screen.getByLabelText(/^Slice gap closing radius/)).toBeInTheDocument());
  104. });
  105. it('searches across every page rather than only the open one', async () => {
  106. const user = userEvent.setup();
  107. await renderPanel();
  108. // Enable support lives on the Support page, not the Quality page shown.
  109. await user.type(screen.getByPlaceholderText('Search settings'), 'enable support');
  110. await waitFor(() => expect(screen.getByLabelText(/^Enable support/)).toBeInTheDocument());
  111. });
  112. it('reports an edit serialised the way a process preset stores it', async () => {
  113. const user = userEvent.setup();
  114. const { onChange } = await renderPanel();
  115. const input = screen.getByLabelText(/^Layer height/);
  116. await user.clear(input);
  117. await user.type(input, '0.16');
  118. await waitFor(() => {
  119. const [values, serialized] = onChange.mock.calls.at(-1)!;
  120. expect(values.layer_height).toBe('0.16');
  121. expect(serialized.layer_height).toBe('0.16');
  122. });
  123. });
  124. it('puts the percent sign back on a percent option', async () => {
  125. const user = userEvent.setup();
  126. const { onChange } = await renderPanel();
  127. const input = await showOption(user, 'Sparse infill density', 'sparse infill density');
  128. await user.clear(input);
  129. await user.type(input, '35');
  130. // "35" and "35%" are different values to the slicer; the schema decides.
  131. await waitFor(() => {
  132. const [, serialized] = onChange.mock.calls.at(-1)!;
  133. expect(serialized.sparse_infill_density).toBe('35%');
  134. });
  135. });
  136. it('sends nothing for a value that equals the preset default', async () => {
  137. const user = userEvent.setup();
  138. const { onChange } = await renderPanel();
  139. // wall_loops defaults to 2 — typing it back is not an override.
  140. const input = await showOption(user, 'Wall loops', 'wall loops');
  141. await user.clear(input);
  142. await user.type(input, '2');
  143. await waitFor(() => {
  144. const [values, serialized] = onChange.mock.calls.at(-1)!;
  145. expect(values.wall_loops).toBe('2');
  146. expect(serialized).not.toHaveProperty('wall_loops');
  147. });
  148. });
  149. it('greys out options the slicer disables at the current settings', async () => {
  150. // sparse_infill_density at 0 turns off have_infill, which gates the infill
  151. // pattern — the same rule the desktop slicer applies.
  152. const user = userEvent.setup();
  153. await renderPanel({ sparse_infill_density: '0%' });
  154. const pattern = await showOption(user, 'Sparse infill pattern', 'sparse infill pattern');
  155. expect(pattern).toBeDisabled();
  156. });
  157. it('keeps an option editable while infill is on', async () => {
  158. const user = userEvent.setup();
  159. await renderPanel({ sparse_infill_density: '15%' });
  160. const pattern = await showOption(user, 'Sparse infill pattern', 'sparse infill pattern');
  161. expect(pattern).not.toBeDisabled();
  162. });
  163. it('lets a field be emptied without snapping back to the default', async () => {
  164. // Regression: dropping the key on an empty input made the control fall
  165. // straight back to the preset default, so clearing a value to retype it
  166. // appended to the old one ("0.2" + "0.16" = "0.2016").
  167. const user = userEvent.setup();
  168. await renderPanel();
  169. const input = screen.getByLabelText(/^Layer height/);
  170. await user.clear(input);
  171. expect(input).toHaveValue(null);
  172. });
  173. it('lets a free-text field be emptied too', async () => {
  174. // coFloatOrPercent / coString / vector options render as text rather than
  175. // number inputs, and the same drop-the-key-on-empty bug lived on that
  176. // branch after the number branch was fixed.
  177. const user = userEvent.setup();
  178. await renderPanel();
  179. const input = await showOption(user, 'Default', 'line_width');
  180. await user.clear(input);
  181. expect(input).toHaveValue('');
  182. });
  183. it('clears every override from the header reset', async () => {
  184. const user = userEvent.setup();
  185. const { onChange } = await renderPanel({ layer_height: '0.16' });
  186. await user.click(await screen.findByRole('button', { name: /Reset 1/ }));
  187. const [values, serialized] = onChange.mock.calls.at(-1)!;
  188. expect(values).toEqual({});
  189. expect(serialized).toEqual({});
  190. });
  191. it('reverts a single option without touching the others', async () => {
  192. const user = userEvent.setup();
  193. const { onChange } = await renderPanel({ layer_height: '0.16', wall_loops: 4 });
  194. const row = screen.getByLabelText(/^Layer height/).closest('div.group') as HTMLElement;
  195. await user.click(within(row).getByRole('button', { name: 'Reset to default' }));
  196. const [values] = onChange.mock.calls.at(-1)!;
  197. expect(values).not.toHaveProperty('layer_height');
  198. expect(values.wall_loops).toBe(4);
  199. });
  200. });
  201. describe('SlicerSettingsPanel — search', () => {
  202. it('treats underscores and spaces alike so a key can be typed naturally', async () => {
  203. // outer_wall_speed's label is only "Outer wall" — the Speed page supplies
  204. // the rest — so the key is the only place the full phrase appears.
  205. const user = userEvent.setup();
  206. await renderPanel();
  207. await user.click(screen.getByRole('button', { name: 'Expert' }));
  208. await user.type(screen.getByPlaceholderText('Search settings'), 'outer wall speed');
  209. await waitFor(() => expect(screen.getByLabelText(/^Outer wall/)).toBeInTheDocument());
  210. });
  211. it('matches a page or group name, not just option labels', async () => {
  212. const user = userEvent.setup();
  213. await renderPanel();
  214. await user.click(screen.getByRole('button', { name: 'Expert' }));
  215. await user.type(screen.getByPlaceholderText('Search settings'), 'ironing');
  216. await waitFor(() => expect(screen.getByLabelText(/^Ironing type/)).toBeInTheDocument());
  217. });
  218. });
  219. describe("SlicerSettingsPanel — the source file's own settings", () => {
  220. const sourceOverrides: DesignOverride[] = [
  221. { key: 'wall_loops', value: '5', printer_coupled: false },
  222. { key: 'outer_wall_speed', value: '200', printer_coupled: true },
  223. // A key the vendored schema has no entry for. It still applies, so it must
  224. // not silently vanish from a panel that claims to show what will be used.
  225. { key: 'some_unlisted_key', value: '7', printer_coupled: false },
  226. ];
  227. it("shows the designer's value against the option once switched on", async () => {
  228. const user = userEvent.setup();
  229. await renderPanel({}, { sourceOverrides, initialSelected: ['wall_loops'] });
  230. const input = await showOption(user, 'Wall loops', 'wall loops');
  231. expect(input).toHaveValue(5);
  232. expect(screen.getByText('from file')).toBeInTheDocument();
  233. });
  234. it('falls back to the preset value when it is switched off', async () => {
  235. const user = userEvent.setup();
  236. await renderPanel({}, { sourceOverrides, initialSelected: [] });
  237. // wall_loops defaults to 2 in the schema.
  238. const input = await showOption(user, 'Wall loops', 'wall loops');
  239. expect(input).toHaveValue(2);
  240. });
  241. it("puts the file's tick before the control it qualifies", async () => {
  242. // A checkbox that gates a field belongs ahead of it. It used to render
  243. // after the unit, out at the row's right edge, reading as unrelated.
  244. const user = userEvent.setup();
  245. await renderPanel({}, { sourceOverrides, initialSelected: ['wall_loops'] });
  246. const control = await showOption(user, 'Wall loops', 'wall loops');
  247. const row = control.closest('div.group') as HTMLElement;
  248. const tick = within(row).getByRole('checkbox');
  249. const controlFollowsTick = tick.compareDocumentPosition(control) & Node.DOCUMENT_POSITION_FOLLOWING;
  250. expect(controlFollowsTick).toBeTruthy();
  251. });
  252. it('flags a machine-coupled setting rather than applying it quietly', async () => {
  253. const user = userEvent.setup();
  254. await renderPanel({}, { sourceOverrides, initialSelected: ['wall_loops'] });
  255. await user.click(screen.getByRole('button', { name: 'Expert' }));
  256. await user.type(screen.getByPlaceholderText('Search settings'), 'outer wall speed');
  257. await waitFor(() => expect(screen.getByText("designer's printer")).toBeInTheDocument());
  258. });
  259. it('lists source settings the schema has no entry for', async () => {
  260. await renderPanel({}, { sourceOverrides, initialSelected: ['some_unlisted_key'] });
  261. await waitFor(() => expect(screen.getByText('Other settings from this file')).toBeInTheDocument());
  262. expect(screen.getByText('some_unlisted_key')).toBeInTheDocument();
  263. expect(screen.getByText('7')).toBeInTheDocument();
  264. });
  265. it('keeps a typed value ahead of the file\'s', async () => {
  266. const user = userEvent.setup();
  267. const { onChange } = await renderPanel({}, { sourceOverrides, initialSelected: ['wall_loops'] });
  268. const input = await showOption(user, 'Wall loops', 'wall loops');
  269. expect(input).toHaveValue(5);
  270. await user.clear(input);
  271. await user.type(input, '3');
  272. // The typed value is what gets sent; the file's tick is unaffected and the
  273. // backend applies it first, so last-write-wins leaves 3 in the process JSON.
  274. await waitFor(() => {
  275. const [, serialized] = onChange.mock.calls.at(-1)!;
  276. expect(serialized.wall_loops).toBe('3');
  277. });
  278. });
  279. });
  280. describe('SlicerSettingsPanel — what the slicer\'s rules are read against (#2942)', () => {
  281. /** The designer's own support configuration, as a real file records it. */
  282. const supportSource: DesignOverride[] = [
  283. { key: 'support_type', value: 'normal(auto)', printer_coupled: false, preset_defining: false },
  284. { key: 'enable_support', value: '1', printer_coupled: false, preset_defining: false },
  285. ];
  286. it("honours the picked preset's value, not the compiled-in default", async () => {
  287. // The reporter's case: a process preset with supports on, nothing typed
  288. // into the panel. The rules used to be evaluated against the typed values
  289. // alone, which fell back to the schema's `enable_support: false` and
  290. // greyed out the whole Support page while the slice ran supports.
  291. const user = userEvent.setup();
  292. await renderPanel({}, { presetValues: { enable_support: '1' } });
  293. const type = await showOption(user, 'Type', 'support_type');
  294. expect(type).toBeEnabled();
  295. });
  296. it('still greys the page out when the preset really has supports off', async () => {
  297. const user = userEvent.setup();
  298. await renderPanel({}, { presetValues: { enable_support: '0' } });
  299. const type = await showOption(user, 'Type', 'support_type');
  300. expect(type).toBeDisabled();
  301. });
  302. it("counts a switched-on source setting as one of the slice's values", async () => {
  303. // Supports come from the file rather than the preset here. The rows they
  304. // gate are as live as if the preset had asked for them.
  305. const user = userEvent.setup();
  306. await renderPanel(
  307. {},
  308. { sourceOverrides: supportSource, initialSelected: ['enable_support'] },
  309. );
  310. const type = await showOption(user, 'Type', 'support_type');
  311. expect(type).toBeEnabled();
  312. });
  313. it('leaves the file\'s tick operable on a row the slicer has greyed out', async () => {
  314. // The two answer different questions -- "is this option in play" versus
  315. // "where does its value come from" -- and folding them together is what
  316. // left a ticked source setting applied to the slice with nothing on
  317. // screen able to clear it.
  318. const user = userEvent.setup();
  319. await renderPanel({}, { sourceOverrides: supportSource, initialSelected: ['support_type'] });
  320. const type = await showOption(user, 'Type', 'support_type');
  321. expect(type).toBeDisabled();
  322. const tick = screen.getByRole('checkbox', { name: /Use the source file's value for Type/ });
  323. expect(tick).toBeEnabled();
  324. expect(tick).toBeChecked();
  325. await user.click(tick);
  326. expect(tick).not.toBeChecked();
  327. });
  328. });
  329. describe('SlicerSettingsPanel — taking the file\'s settings in bulk (#2942)', () => {
  330. const mixed: DesignOverride[] = [
  331. { key: 'wall_loops', value: '5', printer_coupled: false, preset_defining: false },
  332. { key: 'outer_wall_speed', value: '200', printer_coupled: true, preset_defining: false },
  333. { key: 'layer_height', value: '0.2', printer_coupled: false, preset_defining: true },
  334. ];
  335. it('says how many the file changed', async () => {
  336. await renderPanel({}, { sourceOverrides: mixed });
  337. expect(screen.getByText(/The designer changed 3 process settings/)).toBeInTheDocument();
  338. });
  339. it('says nothing for a file that changed nothing', async () => {
  340. await renderPanel({});
  341. expect(screen.queryByText(/The designer changed/)).toBeNull();
  342. });
  343. it('ticks the ones that carry across printers, and only those', async () => {
  344. const user = userEvent.setup();
  345. await renderPanel({}, { sourceOverrides: mixed });
  346. await user.click(screen.getByRole('button', { name: /Use the designer's settings/ }));
  347. await showOption(user, 'Wall loops', 'wall_loops');
  348. expect(
  349. screen.getByRole('checkbox', { name: /Use the source file's value for Wall loops/ }),
  350. ).toBeChecked();
  351. // Tuned for the designer's machine, and the one that *is* the picked
  352. // preset: both stay a per-key decision.
  353. await showOption(user, 'Outer wall', 'outer_wall_speed');
  354. expect(
  355. screen.getByRole('checkbox', { name: /Use the source file's value for Outer wall/ }),
  356. ).not.toBeChecked();
  357. await showOption(user, 'Layer height', 'layer_height');
  358. expect(
  359. screen.getByRole('checkbox', { name: /Use the source file's value for Layer height/ }),
  360. ).not.toBeChecked();
  361. });
  362. it('clears every one of them, machine-coupled included', async () => {
  363. const user = userEvent.setup();
  364. await renderPanel({}, { sourceOverrides: mixed, initialSelected: ['wall_loops', 'outer_wall_speed'] });
  365. await user.click(screen.getByRole('button', { name: /Clear 2/ }));
  366. expect(screen.queryByRole('button', { name: /Clear/ })).toBeNull();
  367. await showOption(user, 'Wall loops', 'wall_loops');
  368. expect(
  369. screen.getByRole('checkbox', { name: /Use the source file's value for Wall loops/ }),
  370. ).not.toBeChecked();
  371. });
  372. });
  373. describe('SlicerSettingsPanel — filament-slot options', () => {
  374. const filamentChoices: FilamentChoice[] = [
  375. { index: 1, label: 'Bambu PLA Basic', color: '#FF0000' },
  376. { index: 2, label: 'Bambu Support for PLA', color: '#FFFFFF' },
  377. ];
  378. it("follows the slicer's own gating rather than being live regardless", async () => {
  379. // The interface picker sits behind have_support_material, so it greys out
  380. // with supports off — becoming a dropdown must not exempt it from the
  381. // rules every other option obeys.
  382. const user = userEvent.setup();
  383. await renderPanel({}, { filamentChoices });
  384. const off = await showOption(user, 'Support/raft interface', 'support_interface_filament');
  385. expect(off).toBeDisabled();
  386. });
  387. it('is operable once supports are switched on', async () => {
  388. const user = userEvent.setup();
  389. await renderPanel({ enable_support: true }, { filamentChoices });
  390. const on = await showOption(user, 'Support/raft interface', 'support_interface_filament');
  391. expect(on).toBeEnabled();
  392. });
  393. it('offers the picked filaments instead of a bare number field', async () => {
  394. const user = userEvent.setup();
  395. await renderPanel({}, { filamentChoices });
  396. const control = await showOption(user, 'Support/raft base', 'support_filament');
  397. expect(control.tagName).toBe('SELECT');
  398. const labels = Array.from((control as HTMLSelectElement).options).map((o) => o.textContent);
  399. expect(labels).toEqual(['Default', '1: Bambu PLA Basic', '2: Bambu Support for PLA']);
  400. });
  401. it("defaults to the slicer's 0, meaning no specific filament", async () => {
  402. const user = userEvent.setup();
  403. await renderPanel({}, { filamentChoices });
  404. const control = await showOption(user, 'Support/raft base', 'support_filament');
  405. expect(control).toHaveValue('0');
  406. });
  407. it('sends the slot index the slicer expects', async () => {
  408. const user = userEvent.setup();
  409. const { onChange } = await renderPanel({ enable_support: true }, { filamentChoices });
  410. const control = await showOption(user, 'Support/raft interface', 'support_interface_filament');
  411. await user.selectOptions(control, '2');
  412. await waitFor(() => {
  413. const [, serialized] = onChange.mock.calls.at(-1)!;
  414. expect(serialized.support_interface_filament).toBe('2');
  415. });
  416. });
  417. it('stays a plain number field when no filaments have been picked', async () => {
  418. // STL sources and the pre-plate-analysis window have no slot list yet;
  419. // an empty dropdown would be worse than the number input it replaced.
  420. const user = userEvent.setup();
  421. await renderPanel({}, { filamentChoices: [] });
  422. const control = await showOption(user, 'Support/raft base', 'support_filament');
  423. expect(control.tagName).toBe('INPUT');
  424. });
  425. it('leaves unrelated integer options alone', async () => {
  426. const user = userEvent.setup();
  427. await renderPanel({}, { filamentChoices });
  428. const control = await showOption(user, 'Wall loops', 'wall loops');
  429. expect(control.tagName).toBe('INPUT');
  430. });
  431. });
  432. describe('SlicerSettingsPanel — the picked preset\'s values', () => {
  433. it('shows the preset value rather than the compiled-in default', async () => {
  434. // The reported bug: line_width defaults to 0 in OrcaSlicer's C++ (meaning
  435. // "derive from the nozzle"), so every Line width field read 0 regardless
  436. // of what the chosen preset actually sets.
  437. const user = userEvent.setup();
  438. await renderPanel({}, { presetValues: { line_width: '0.42' } });
  439. const input = await showOption(user, 'Default', 'line_width');
  440. expect(input).toHaveValue('0.42');
  441. });
  442. it('does not mark a preset value as a user change', async () => {
  443. // Comparing against the schema default would flag every field the preset
  444. // moved off the C++ default as edited, and send values nobody typed.
  445. const { onChange } = await renderPanel({}, { presetValues: { line_width: '0.42' } });
  446. await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
  447. expect(screen.queryByRole('button', { name: /Reset \d/ })).not.toBeInTheDocument();
  448. expect(onChange).not.toHaveBeenCalled();
  449. });
  450. it('sends an edit that differs from the preset', async () => {
  451. const user = userEvent.setup();
  452. const { onChange } = await renderPanel({}, { presetValues: { line_width: '0.42' } });
  453. const input = await showOption(user, 'Default', 'line_width');
  454. await user.clear(input);
  455. await user.type(input, '0.5');
  456. await waitFor(() => {
  457. const [, serialized] = onChange.mock.calls.at(-1)!;
  458. expect(serialized.line_width).toBe('0.5');
  459. });
  460. });
  461. it('sends nothing for a value retyped to match the preset', async () => {
  462. const user = userEvent.setup();
  463. const { onChange } = await renderPanel({}, { presetValues: { line_width: '0.42' } });
  464. const input = await showOption(user, 'Default', 'line_width');
  465. await user.clear(input);
  466. await user.type(input, '0.42');
  467. await waitFor(() => expect(onChange).toHaveBeenCalled());
  468. const [, serialized] = onChange.mock.calls.at(-1)!;
  469. expect(serialized).not.toHaveProperty('line_width');
  470. });
  471. it('reverts to the preset value, not the schema default', async () => {
  472. const user = userEvent.setup();
  473. await renderPanel({ line_width: '0.5' }, { presetValues: { line_width: '0.42' } });
  474. const input = await showOption(user, 'Default', 'line_width');
  475. expect(input).toHaveValue('0.5');
  476. const row = input.closest('div.group') as HTMLElement;
  477. await user.click(within(row).getByRole('button', { name: 'Reset to default' }));
  478. await waitFor(() => expect(screen.getByLabelText(/^Default/)).toHaveValue('0.42'));
  479. });
  480. it('says so when the preset values could not be read', async () => {
  481. await renderPanel({}, { presetValuesResolved: false });
  482. await waitFor(() => expect(screen.getByText(/Showing slicer defaults/)).toBeInTheDocument());
  483. });
  484. it('names the fix when the sidecar predates the endpoint', async () => {
  485. // The dominant case: an install pulls SIDECAR_TAG:-latest regardless of
  486. // its own release channel, so a current Bambuddy against an old sidecar is
  487. // normal. A generic "could not be read" sends that user hunting.
  488. await renderPanel({}, { presetValuesResolved: false, presetValuesReason: 'sidecar_outdated' });
  489. await waitFor(() => expect(screen.getByText(/Update the sidecar image/)).toBeInTheDocument());
  490. });
  491. it('distinguishes a sidecar that is missing from one that is merely old', async () => {
  492. await renderPanel({}, { presetValuesResolved: false, presetValuesReason: 'not_configured' });
  493. await waitFor(() => expect(screen.getByText(/no slicer sidecar is configured/)).toBeInTheDocument());
  494. expect(screen.queryByText(/Update the sidecar image/)).not.toBeInTheDocument();
  495. });
  496. it('distinguishes a sidecar that did not answer', async () => {
  497. await renderPanel({}, { presetValuesResolved: false, presetValuesReason: 'sidecar_unavailable' });
  498. await waitFor(() => expect(screen.getByText(/did not answer/)).toBeInTheDocument());
  499. expect(screen.queryByText(/Update the sidecar image/)).not.toBeInTheDocument();
  500. });
  501. it('shows no such notice when they resolved', async () => {
  502. await renderPanel({}, { presetValues: { line_width: '0.42' }, presetValuesResolved: true });
  503. await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
  504. expect(screen.queryByText(/Showing slicer defaults/)).not.toBeInTheDocument();
  505. });
  506. });