SliceModal.test.tsx 104 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719
  1. /**
  2. * Tests for SliceModal.
  3. *
  4. * The modal handles preset selection across three tiers (cloud / local /
  5. * standard) + enqueueing a slice job. After enqueue success it hands the
  6. * job_id off to SliceJobTrackerProvider (which lives at app level) and
  7. * calls onClose. Polling, toasts, and query invalidation all happen in
  8. * the tracker — not here.
  9. */
  10. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  11. import { fireEvent, screen, waitFor, within } from '@testing-library/react';
  12. import userEvent from '@testing-library/user-event';
  13. import { render } from '../utils';
  14. import { SliceModal } from '../../components/SliceModal';
  15. import { pickFilamentForSlot } from '../../utils/slicePresetPicker';
  16. import { buildCompatibilityIndex } from '../../utils/slicerPrinterMatch';
  17. import { SliceJobTrackerProvider } from '../../contexts/SliceJobTrackerContext';
  18. import { api, type UnifiedPresetsResponse } from '../../api/client';
  19. vi.mock('../../api/client', () => ({
  20. api: {
  21. getSlicerPresets: vi.fn(),
  22. sliceLibraryFile: vi.fn(),
  23. sliceArchive: vi.fn(),
  24. getSliceJob: vi.fn(),
  25. getLibraryFilePlates: vi.fn(),
  26. getArchivePlates: vi.fn(),
  27. getLibraryFileFilamentRequirements: vi.fn(),
  28. getArchiveFilamentRequirements: vi.fn(),
  29. getSettings: vi.fn().mockResolvedValue({}),
  30. updateSettings: vi.fn().mockResolvedValue({}),
  31. // Slicer Pipelines (#1425)
  32. listSlicerPipelines: vi.fn(),
  33. createSlicerPipeline: vi.fn(),
  34. getSlicerPrinterModels: vi.fn(),
  35. getSlicerPresetValues: vi.fn(),
  36. getSlicerLoadedSpools: vi.fn(),
  37. },
  38. }));
  39. const mockApi = api as unknown as {
  40. getSlicerPresets: ReturnType<typeof vi.fn>;
  41. sliceLibraryFile: ReturnType<typeof vi.fn>;
  42. sliceArchive: ReturnType<typeof vi.fn>;
  43. getSliceJob: ReturnType<typeof vi.fn>;
  44. getLibraryFilePlates: ReturnType<typeof vi.fn>;
  45. getArchivePlates: ReturnType<typeof vi.fn>;
  46. getLibraryFileFilamentRequirements: ReturnType<typeof vi.fn>;
  47. getArchiveFilamentRequirements: ReturnType<typeof vi.fn>;
  48. listSlicerPipelines: ReturnType<typeof vi.fn>;
  49. createSlicerPipeline: ReturnType<typeof vi.fn>;
  50. getSlicerPrinterModels: ReturnType<typeof vi.fn>;
  51. getSlicerPresetValues: ReturnType<typeof vi.fn>;
  52. getSlicerLoadedSpools: ReturnType<typeof vi.fn>;
  53. };
  54. function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
  55. return {
  56. orca_cloud: { printer: [], process: [], filament: [] },
  57. cloud: { printer: [], process: [], filament: [] },
  58. local: { printer: [], process: [], filament: [] },
  59. standard: { printer: [], process: [], filament: [] },
  60. cloud_status: 'ok',
  61. orca_cloud_status: 'ok',
  62. ...overrides,
  63. };
  64. }
  65. const fullThreeTier: UnifiedPresetsResponse = makeUnified({
  66. cloud: {
  67. printer: [{ id: 'PFUcloud-printer', name: 'My Custom X1C', source: 'cloud' }],
  68. process: [{ id: 'PFUcloud-process', name: 'My 0.16mm Tweaked', source: 'cloud' }],
  69. filament: [{ id: 'PFUcloud-filament', name: 'My PLA Black', source: 'cloud' }],
  70. },
  71. local: {
  72. printer: [{ id: '1', name: 'Imported X1C 0.4', source: 'local' }],
  73. process: [{ id: '2', name: 'Imported 0.20mm', source: 'local' }],
  74. filament: [{ id: '3', name: 'Imported PLA Basic', source: 'local' }],
  75. },
  76. standard: {
  77. printer: [{ id: 'Bambu Lab X1 Carbon 0.4 nozzle', name: 'Bambu Lab X1 Carbon 0.4 nozzle', source: 'standard' }],
  78. process: [{ id: '0.20mm Standard', name: '0.20mm Standard', source: 'standard' }],
  79. filament: [{ id: 'Bambu PLA Basic', name: 'Bambu PLA Basic', source: 'standard' }],
  80. },
  81. });
  82. function renderWithTracker(props: Parameters<typeof SliceModal>[0]) {
  83. return render(
  84. <SliceJobTrackerProvider>
  85. <SliceModal {...props} />
  86. </SliceJobTrackerProvider>,
  87. );
  88. }
  89. // SliceModal renders one extra combobox for the Slicer Pipelines (#1425)
  90. // "Apply pipeline" dropdown above the preset slots. Tests written before
  91. // pipelines landed assume selects[0] = printer; this helper drops the
  92. // pipeline combobox so those indices stay stable.
  93. function presetSelects(): HTMLSelectElement[] {
  94. return (screen.getAllByRole('combobox') as HTMLSelectElement[]).filter(
  95. (el) => el.getAttribute('aria-label') !== 'Apply pipeline',
  96. );
  97. }
  98. describe('SliceModal', () => {
  99. beforeEach(() => {
  100. vi.clearAllMocks();
  101. mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
  102. mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {}, reason: 'ok' });
  103. mockApi.getSliceJob.mockResolvedValue({
  104. job_id: 42,
  105. status: 'running',
  106. kind: 'library_file',
  107. source_id: 100,
  108. source_name: 'Cube.stl',
  109. created_at: new Date().toISOString(),
  110. started_at: null,
  111. completed_at: null,
  112. });
  113. // Default: single-plate (or non-3MF). Multi-plate tests override this.
  114. mockApi.getLibraryFilePlates.mockResolvedValue({
  115. file_id: 100,
  116. filename: 'Cube.stl',
  117. plates: [],
  118. is_multi_plate: false,
  119. });
  120. mockApi.getArchivePlates.mockResolvedValue({
  121. archive_id: 100,
  122. filename: 'Cube.3mf',
  123. plates: [],
  124. is_multi_plate: false,
  125. });
  126. // Default: no per-plate filament metadata available (mirrors STL or
  127. // unsliced source). Multi-color tests override this.
  128. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
  129. file_id: 100,
  130. filename: 'Cube.stl',
  131. plate_id: 1,
  132. filaments: [],
  133. });
  134. mockApi.getArchiveFilamentRequirements.mockResolvedValue({
  135. archive_id: 100,
  136. filename: 'Cube.3mf',
  137. plate_id: 1,
  138. filaments: [],
  139. });
  140. // Default: no saved pipelines. Tests opt in by overriding this.
  141. mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
  142. // Default: no printer online (#3172), so the dialog's filters do nothing.
  143. mockApi.getSlicerLoadedSpools.mockResolvedValue({ printers: [] });
  144. });
  145. it('auto-selects the highest-priority tier per slot on first load', async () => {
  146. renderWithTracker({
  147. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  148. onClose: vi.fn(),
  149. });
  150. // SliceModal-specific tier priority: imported (local) wins over cloud
  151. // and standard so the user's curated picks come first.
  152. await waitFor(() => {
  153. expect(screen.getByText('My Custom X1C')).toBeDefined();
  154. });
  155. // 4 selects: printer, process, bed-type (#1337), filament. bed-type sits
  156. // between process and filament — it overrides curr_bed_type on the
  157. // process preset so the related controls cluster — and defaults to "".
  158. const selects = presetSelects();
  159. expect(selects).toHaveLength(4);
  160. expect(selects[0].value).toBe('local:1');
  161. expect(selects[1].value).toBe('local:2');
  162. expect(selects[2].value).toBe('');
  163. expect(selects[3].value).toBe('local:3');
  164. // Slice button is enabled because all three slots auto-defaulted and
  165. // the preview-slice query has resolved (mock returns immediately).
  166. const sliceBtn = screen.getByRole('button', { name: /^Slice$/ });
  167. expect((sliceBtn as HTMLButtonElement).disabled).toBe(false);
  168. });
  169. it('renders Imported / Cloud / Standard sections via <optgroup>', async () => {
  170. renderWithTracker({
  171. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  172. onClose: vi.fn(),
  173. });
  174. await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
  175. const printerSelect = presetSelects()[0];
  176. const groups = printerSelect.querySelectorAll('optgroup');
  177. expect(Array.from(groups).map((g) => g.label)).toEqual([
  178. 'Imported',
  179. 'Bambu Cloud',
  180. 'Standard',
  181. ]);
  182. // Each entry sits inside its own tier's group — pin the assignment so
  183. // a future render-shape change can't quietly mix them. Order matches
  184. // SLICE_MODAL_TIER_ORDER (local → cloud → standard).
  185. const localGroup = groups[0];
  186. expect(within(localGroup as HTMLElement).getByText('Imported X1C 0.4')).toBeDefined();
  187. const cloudGroup = groups[1];
  188. expect(within(cloudGroup as HTMLElement).getByText('My Custom X1C')).toBeDefined();
  189. const standardGroup = groups[2];
  190. expect(within(standardGroup as HTMLElement).getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined();
  191. });
  192. it('falls back to local when cloud is empty (auto-pick respects priority)', async () => {
  193. mockApi.getSlicerPresets.mockResolvedValue(
  194. makeUnified({
  195. local: fullThreeTier.local,
  196. standard: fullThreeTier.standard,
  197. }),
  198. );
  199. renderWithTracker({
  200. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  201. onClose: vi.fn(),
  202. });
  203. await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
  204. const selects = presetSelects();
  205. expect(selects[0].value).toBe('local:1');
  206. });
  207. it('falls back to standard when both cloud and local are empty', async () => {
  208. mockApi.getSlicerPresets.mockResolvedValue(
  209. makeUnified({ standard: fullThreeTier.standard }),
  210. );
  211. renderWithTracker({
  212. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  213. onClose: vi.fn(),
  214. });
  215. await waitFor(() => expect(screen.getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined());
  216. const selects = presetSelects();
  217. expect(selects[0].value).toBe('standard:Bambu Lab X1 Carbon 0.4 nozzle');
  218. });
  219. it('sends source-aware refs (not legacy bare ints) on submit', async () => {
  220. const onClose = vi.fn();
  221. mockApi.sliceLibraryFile.mockResolvedValue({
  222. job_id: 42,
  223. status: 'pending',
  224. status_url: '/api/v1/slice-jobs/42',
  225. });
  226. renderWithTracker({
  227. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  228. onClose,
  229. });
  230. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  231. const user = userEvent.setup();
  232. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  233. await waitFor(() => {
  234. // SliceModal-specific tier priority puts imported (local) above cloud,
  235. // so the auto-pick lands on the local entries even when a cloud entry
  236. // with the same slot is also available in the listing.
  237. expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(100, {
  238. printer_preset: { source: 'local', id: '1' },
  239. process_preset: { source: 'local', id: '2' },
  240. filament_preset: { source: 'local', id: '3' },
  241. filament_presets: [{ source: 'local', id: '3' }],
  242. // An STL has no designed colour and the swatch was not touched, so
  243. // the slot is handed back to the backend's fallback chain rather
  244. // than being pinned to the picker's displayed default (#2977).
  245. filament_colours: [''],
  246. });
  247. });
  248. await waitFor(() => expect(onClose).toHaveBeenCalled());
  249. });
  250. it('offers "use the file\'s built-in settings" when the printer matches the design, and sends the flag (#2611)', async () => {
  251. const onClose = vi.fn();
  252. mockApi.sliceLibraryFile.mockResolvedValue({
  253. job_id: 42,
  254. status: 'pending',
  255. status_url: '/api/v1/slice-jobs/42',
  256. });
  257. // A project 3MF whose embedded printer matches a listed preset — the
  258. // printer pre-pick lands on it, so selectedPrinterName === embedded and
  259. // the "slice as designed" toggle is offered.
  260. mockApi.getLibraryFilePlates.mockResolvedValue({
  261. file_id: 100,
  262. filename: 'Designed.3mf',
  263. plates: [],
  264. is_multi_plate: false,
  265. embedded_printer: 'Bambu Lab X1 Carbon 0.4 nozzle',
  266. embedded_process: '0.20mm Standard',
  267. });
  268. renderWithTracker({
  269. source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
  270. onClose,
  271. });
  272. const user = userEvent.setup();
  273. const toggle = (await screen.findByLabelText(
  274. /Use the file's built-in settings/,
  275. )) as HTMLInputElement;
  276. expect(toggle.checked).toBe(false);
  277. // All preset dropdowns are live until the toggle is on, then bypassed —
  278. // the printer included, so changing it can't silently drop the mode.
  279. const printerSelect = presetSelects()[0];
  280. const processSelect = presetSelects()[1];
  281. expect(printerSelect.disabled).toBe(false);
  282. expect(processSelect.disabled).toBe(false);
  283. await user.click(toggle);
  284. expect(printerSelect.disabled).toBe(true);
  285. expect(processSelect.disabled).toBe(true);
  286. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  287. await waitFor(() => {
  288. expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
  289. 100,
  290. expect.objectContaining({ use_embedded_settings: true }),
  291. );
  292. });
  293. await waitFor(() => expect(onClose).toHaveBeenCalled());
  294. });
  295. it('hides the embedded-settings toggle when the picked printer differs from the design (#2611)', async () => {
  296. // Embedded target is a model with no matching preset in the listing, so
  297. // the printer pre-pick falls back to the local default (Imported X1C),
  298. // which does not match — honouring embedded settings would risk the
  299. // wrong bed, so the toggle stays hidden.
  300. mockApi.getLibraryFilePlates.mockResolvedValue({
  301. file_id: 100,
  302. filename: 'Designed.3mf',
  303. plates: [],
  304. is_multi_plate: false,
  305. embedded_printer: 'Bambu Lab P1S 0.4 nozzle',
  306. embedded_process: '0.20mm Standard',
  307. });
  308. renderWithTracker({
  309. source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
  310. onClose: vi.fn(),
  311. });
  312. await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
  313. expect(screen.queryByLabelText(/Use the file's built-in settings/)).toBeNull();
  314. });
  315. // #2622: a MakerWorld 3MF designed for another printer carries the author's
  316. // own process tweaks. BambuStudio records which keys deviate from the stock
  317. // preset inside the file, so a cross-printer re-slice can carry them instead
  318. // of losing them to the picked process profile.
  319. const designedFor = {
  320. file_id: 100,
  321. filename: 'Designed.3mf',
  322. plates: [],
  323. is_multi_plate: false,
  324. embedded_printer: 'Bambu Lab A1 0.4 nozzle',
  325. embedded_process: '0.20mm Standard @BBL A1',
  326. design_overrides: [
  327. { key: 'wall_loops', value: '5', printer_coupled: false, preset_defining: false },
  328. { key: 'sparse_infill_density', value: '100%', printer_coupled: false, preset_defining: false },
  329. { key: 'outer_wall_speed', value: '200', printer_coupled: true, preset_defining: false },
  330. ],
  331. };
  332. // The designer's settings are shown inside the process-settings panel now,
  333. // against the options they belong to, rather than in a list of their own.
  334. // The payload contract below is unchanged: their *values* still travel as
  335. // design_overrides keys, read from the file by the backend.
  336. async function openDesignSection() {
  337. const user = userEvent.setup();
  338. await user.click(await screen.findByRole('button', { name: /Process settings/ }));
  339. await screen.findByPlaceholderText('Search settings');
  340. // Every designer key must be reachable, including expert-tier ones.
  341. await user.click(screen.getByRole('button', { name: 'Expert' }));
  342. return user;
  343. }
  344. /** The panel's per-option "use the file's value" checkbox, by option key. */
  345. function sourceCheckbox(key: string): HTMLInputElement {
  346. const boxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
  347. const found = boxes.find((b) => (b.getAttribute('aria-label') ?? '').includes(key));
  348. if (!found) throw new Error(`no source checkbox for ${key}`);
  349. return found;
  350. }
  351. it('carries nothing out of the file until it is asked to (#2942)', async () => {
  352. mockApi.sliceLibraryFile.mockResolvedValue({
  353. job_id: 42,
  354. status: 'pending',
  355. status_url: '/api/v1/slice-jobs/42',
  356. });
  357. mockApi.getLibraryFilePlates.mockResolvedValue(designedFor);
  358. renderWithTracker({
  359. source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
  360. onClose: vi.fn(),
  361. });
  362. // Nothing pre-ticked: "Use the file's built-in settings" is off, so the
  363. // slice runs on the picked preset and the file's own values wait to be
  364. // asked for by name.
  365. await waitFor(() => expect(screen.getByRole('button', { name: /^Slice$/ })).toBeEnabled());
  366. const user = userEvent.setup();
  367. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  368. await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled());
  369. const payload = mockApi.sliceLibraryFile.mock.calls[0][1] as { design_overrides?: string[] };
  370. // Empty, not absent: the backend reads the difference. A list that is
  371. // there and empty says the user was shown the file's settings and took
  372. // none of them, which also stands the support carry-over down (#1881).
  373. expect(payload.design_overrides).toEqual([]);
  374. });
  375. // The file's layer height is the one deviation that must not ride along: it
  376. // *is* the process preset the user picked, so carrying it silently sliced a
  377. // file at 0.2 while the process dropdown still read "0.08mm High Quality".
  378. const designedWithLayerHeight = {
  379. ...designedFor,
  380. design_overrides: [
  381. { key: 'wall_loops', value: '5', printer_coupled: false, preset_defining: false },
  382. { key: 'layer_height', value: '0.2', printer_coupled: false, preset_defining: true },
  383. ],
  384. };
  385. it("leaves the file's layer height off even in bulk, so the picked preset wins", async () => {
  386. mockApi.sliceLibraryFile.mockResolvedValue({
  387. job_id: 42,
  388. status: 'pending',
  389. status_url: '/api/v1/slice-jobs/42',
  390. });
  391. mockApi.getLibraryFilePlates.mockResolvedValue(designedWithLayerHeight);
  392. renderWithTracker({
  393. source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
  394. onClose: vi.fn(),
  395. });
  396. await waitFor(() => expect(screen.getByRole('button', { name: /^Slice$/ })).toBeEnabled());
  397. const user = await openDesignSection();
  398. await user.click(screen.getByRole('button', { name: /Use the designer's settings/ }));
  399. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  400. await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled());
  401. const payload = mockApi.sliceLibraryFile.mock.calls[0][1] as { design_overrides?: string[] };
  402. // The bulk action takes the keys that carry across printers. Layer height
  403. // *is* the preset that was picked, so it stays a per-key decision.
  404. expect(payload.design_overrides).toEqual(['wall_loops']);
  405. });
  406. it('still applies the file\'s layer height when the user ticks it', async () => {
  407. mockApi.sliceLibraryFile.mockResolvedValue({
  408. job_id: 42,
  409. status: 'pending',
  410. status_url: '/api/v1/slice-jobs/42',
  411. });
  412. mockApi.getLibraryFilePlates.mockResolvedValue(designedWithLayerHeight);
  413. renderWithTracker({
  414. source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
  415. onClose: vi.fn(),
  416. });
  417. await waitFor(() => expect(screen.getByRole('button', { name: /^Slice$/ })).toBeEnabled());
  418. const user = await openDesignSection();
  419. // Search rather than page-hop: it cuts across every page. The tick's
  420. // aria-label carries the option's schema label, not its key.
  421. await user.type(screen.getByPlaceholderText('Search settings'), 'layer height');
  422. await waitFor(() => expect(sourceCheckbox('Layer height')).toBeInTheDocument());
  423. await user.click(sourceCheckbox('Layer height'));
  424. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  425. await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled());
  426. const payload = mockApi.sliceLibraryFile.mock.calls[0][1] as { design_overrides?: string[] };
  427. expect(payload.design_overrides).toEqual(['layer_height']);
  428. });
  429. it('lists every changed setting with its value and flags the machine-coupled ones (#2622)', async () => {
  430. mockApi.getLibraryFilePlates.mockResolvedValue(designedFor);
  431. renderWithTracker({
  432. source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
  433. onClose: vi.fn(),
  434. });
  435. const user = await openDesignSection();
  436. // Offered but not taken: the row is flagged and the control still shows
  437. // the baseline, until the tick says the file's value should win.
  438. await user.type(screen.getByPlaceholderText('Search settings'), 'wall loops');
  439. await waitFor(() => expect(sourceCheckbox('Wall loops')).toBeInTheDocument());
  440. expect(sourceCheckbox('Wall loops').checked).toBe(false);
  441. expect(screen.getByLabelText(/^Wall loops/)).not.toHaveValue(5);
  442. await user.click(sourceCheckbox('Wall loops'));
  443. await waitFor(() => expect(screen.getByLabelText(/^Wall loops/)).toHaveValue(5));
  444. await user.clear(screen.getByPlaceholderText('Search settings'));
  445. await user.type(screen.getByPlaceholderText('Search settings'), 'outer wall speed');
  446. // The machine-coupled one is present and flagged, just not pre-ticked.
  447. await waitFor(() => expect(screen.getAllByText("designer's printer").length).toBeGreaterThan(0));
  448. expect(sourceCheckbox('Outer wall').checked).toBe(false);
  449. });
  450. it('lets the user opt a machine-coupled setting in on its own (#2622)', async () => {
  451. mockApi.sliceLibraryFile.mockResolvedValue({
  452. job_id: 42,
  453. status: 'pending',
  454. status_url: '/api/v1/slice-jobs/42',
  455. });
  456. mockApi.getLibraryFilePlates.mockResolvedValue(designedFor);
  457. renderWithTracker({
  458. source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
  459. onClose: vi.fn(),
  460. });
  461. const user = await openDesignSection();
  462. await user.type(screen.getByPlaceholderText('Search settings'), 'outer wall speed');
  463. await waitFor(() => expect(sourceCheckbox('Outer wall')).toBeInTheDocument());
  464. await user.click(sourceCheckbox('Outer wall'));
  465. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  466. await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled());
  467. const payload = mockApi.sliceLibraryFile.mock.calls[0][1] as { design_overrides?: string[] };
  468. // Only the key that was asked for -- the two printer-independent ones are
  469. // still on offer and still untouched.
  470. expect(payload.design_overrides).toEqual(['outer_wall_speed']);
  471. });
  472. it('omits design_overrides entirely for a file that offered nothing (#2942)', async () => {
  473. // The other half of the distinction the backend reads: no list at all
  474. // means there was nothing to decide, which leaves #1881's support
  475. // carry-over unconditional for sources -- an OrcaSlicer export, say --
  476. // that record no deviations to tick in the first place.
  477. mockApi.sliceLibraryFile.mockResolvedValue({
  478. job_id: 42,
  479. status: 'pending',
  480. status_url: '/api/v1/slice-jobs/42',
  481. });
  482. mockApi.getLibraryFilePlates.mockResolvedValue({ ...designedFor, design_overrides: [] });
  483. renderWithTracker({
  484. source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
  485. onClose: vi.fn(),
  486. });
  487. await waitFor(() => expect(screen.getByRole('button', { name: /^Slice$/ })).toBeEnabled());
  488. const user = userEvent.setup();
  489. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  490. await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled());
  491. expect(mockApi.sliceLibraryFile.mock.calls[0][1]).not.toHaveProperty('design_overrides');
  492. });
  493. // #2942: the per-key ticks answer the same question the toggle above them
  494. // does -- where do this slice's settings come from -- so one governs the
  495. // other. They used to be pre-ticked whatever it said, which is how a slice
  496. // with the toggle deliberately off still took sixteen values from the file.
  497. const designedForThisPrinter = {
  498. ...designedFor,
  499. embedded_printer: 'Bambu Lab X1 Carbon 0.4 nozzle',
  500. embedded_process: '0.20mm Standard',
  501. // Seam position sits on the panel's opening page at the simple tier, so
  502. // its tick can be read without driving a panel the toggle has disabled.
  503. design_overrides: [
  504. ...designedFor.design_overrides,
  505. { key: 'seam_position', value: 'rear', printer_coupled: false, preset_defining: false },
  506. ],
  507. };
  508. it('shows every setting as coming from the file while the built-in toggle is on (#2942)', async () => {
  509. mockApi.sliceLibraryFile.mockResolvedValue({
  510. job_id: 42,
  511. status: 'pending',
  512. status_url: '/api/v1/slice-jobs/42',
  513. });
  514. mockApi.getLibraryFilePlates.mockResolvedValue(designedForThisPrinter);
  515. renderWithTracker({
  516. source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
  517. onClose: vi.fn(),
  518. });
  519. const user = userEvent.setup();
  520. await user.click(await screen.findByLabelText(/Use the file's built-in settings/));
  521. await user.click(await screen.findByRole('button', { name: /Process settings/ }));
  522. await screen.findByPlaceholderText('Search settings');
  523. // A readout, not a control: on this path the file drives the whole slice,
  524. // so a tick that said otherwise would be describing the wrong run. The
  525. // panel is inactive throughout -- nothing here is sent.
  526. await waitFor(() => expect(sourceCheckbox('Seam position').checked).toBe(true));
  527. expect(sourceCheckbox('Seam position').disabled).toBe(true);
  528. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  529. await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled());
  530. const payload = mockApi.sliceLibraryFile.mock.calls[0][1] as {
  531. design_overrides?: string[];
  532. use_embedded_settings?: boolean;
  533. };
  534. expect(payload.use_embedded_settings).toBe(true);
  535. expect(payload).not.toHaveProperty('design_overrides');
  536. });
  537. it('clears them again when the built-in toggle goes back off (#2942)', async () => {
  538. mockApi.sliceLibraryFile.mockResolvedValue({
  539. job_id: 42,
  540. status: 'pending',
  541. status_url: '/api/v1/slice-jobs/42',
  542. });
  543. mockApi.getLibraryFilePlates.mockResolvedValue(designedForThisPrinter);
  544. renderWithTracker({
  545. source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
  546. onClose: vi.fn(),
  547. });
  548. const user = userEvent.setup();
  549. const toggle = await screen.findByLabelText(/Use the file's built-in settings/);
  550. await user.click(toggle);
  551. await user.click(toggle);
  552. await openDesignSection();
  553. await user.type(screen.getByPlaceholderText('Search settings'), 'wall loops');
  554. await waitFor(() => expect(sourceCheckbox('Wall loops').checked).toBe(false));
  555. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  556. await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled());
  557. const payload = mockApi.sliceLibraryFile.mock.calls[0][1] as {
  558. design_overrides?: string[];
  559. use_embedded_settings?: boolean;
  560. };
  561. expect(payload.design_overrides).toEqual([]);
  562. expect(payload).not.toHaveProperty('use_embedded_settings');
  563. });
  564. it("takes the designer's settings in bulk, without the machine-coupled ones (#2942)", async () => {
  565. mockApi.sliceLibraryFile.mockResolvedValue({
  566. job_id: 42,
  567. status: 'pending',
  568. status_url: '/api/v1/slice-jobs/42',
  569. });
  570. mockApi.getLibraryFilePlates.mockResolvedValue(designedFor);
  571. renderWithTracker({
  572. source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
  573. onClose: vi.fn(),
  574. });
  575. const user = await openDesignSection();
  576. // Without this the file's settings would be reachable only by hunting for
  577. // chips across six pages of 348 options.
  578. expect(screen.getByText(/The designer changed 3 process settings/)).toBeInTheDocument();
  579. await user.click(screen.getByRole('button', { name: /Use the designer's settings/ }));
  580. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  581. await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled());
  582. const payload = mockApi.sliceLibraryFile.mock.calls[0][1] as { design_overrides?: string[] };
  583. expect([...(payload.design_overrides ?? [])].sort()).toEqual([
  584. 'sparse_infill_density',
  585. 'wall_loops',
  586. ]);
  587. });
  588. it('hides the section for a file that changes nothing (#2622)', async () => {
  589. mockApi.getLibraryFilePlates.mockResolvedValue({ ...designedFor, design_overrides: [] });
  590. renderWithTracker({
  591. source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
  592. onClose: vi.fn(),
  593. });
  594. await waitFor(() => expect(screen.getByRole('button', { name: /^Slice$/ })).toBeEnabled());
  595. // The panel still exists — it is the editor — but nothing is marked as
  596. // coming from the file.
  597. expect(screen.queryByText('from file')).toBeNull();
  598. expect(screen.queryByText("designer's printer")).toBeNull();
  599. });
  600. it('includes bed_type in the request when the user picks a non-auto plate (#1337)', async () => {
  601. const onClose = vi.fn();
  602. mockApi.sliceLibraryFile.mockResolvedValue({
  603. job_id: 42,
  604. status: 'pending',
  605. status_url: '/api/v1/slice-jobs/42',
  606. });
  607. renderWithTracker({
  608. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  609. onClose,
  610. });
  611. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  612. const user = userEvent.setup();
  613. // Order with the dropdown now sits between Process and Filament:
  614. // printer (0), process (1), bed-type (2), filament (3+). Find the
  615. // bed-type select by name rather than positional index so this stays
  616. // green if the layout adds another control around it.
  617. const bedSelect = presetSelects().find((el) =>
  618. (el as HTMLSelectElement).options[0]?.textContent?.toLowerCase().includes('auto'),
  619. ) as HTMLSelectElement;
  620. expect(bedSelect).toBeDefined();
  621. await user.selectOptions(bedSelect, 'Textured PEI Plate');
  622. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  623. await waitFor(() => {
  624. expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
  625. 100,
  626. expect.objectContaining({ bed_type: 'Textured PEI Plate' }),
  627. );
  628. });
  629. });
  630. it('omits bed_type when the user leaves it on Auto (no override)', async () => {
  631. const onClose = vi.fn();
  632. mockApi.sliceLibraryFile.mockResolvedValue({
  633. job_id: 42,
  634. status: 'pending',
  635. status_url: '/api/v1/slice-jobs/42',
  636. });
  637. renderWithTracker({
  638. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  639. onClose,
  640. });
  641. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  642. const user = userEvent.setup();
  643. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  644. await waitFor(() => {
  645. const [, body] = vi.mocked(mockApi.sliceLibraryFile).mock.calls[0];
  646. expect(body).not.toHaveProperty('bed_type');
  647. });
  648. });
  649. it('sends the layout flags only for the boxes the user ticked (#2548)', async () => {
  650. mockApi.sliceLibraryFile.mockResolvedValue({
  651. job_id: 42,
  652. status: 'pending',
  653. status_url: '/api/v1/slice-jobs/42',
  654. });
  655. renderWithTracker({
  656. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  657. onClose: vi.fn(),
  658. });
  659. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  660. const user = userEvent.setup();
  661. await user.click(screen.getByRole('checkbox', { name: /Auto-orient objects/ }));
  662. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  663. await waitFor(() => {
  664. const [, body] = vi.mocked(mockApi.sliceLibraryFile).mock.calls[0];
  665. expect(body).toHaveProperty('auto_orient', true);
  666. // The untouched box is omitted, not sent as false. The sidecar reads
  667. // any present value as truthy, so a literal false would arrange every
  668. // slice — the flag has to travel by absence.
  669. expect(body).not.toHaveProperty('auto_arrange');
  670. });
  671. });
  672. it('starts with auto-arrange ticked for a freshly combined file', async () => {
  673. mockApi.sliceLibraryFile.mockResolvedValue({
  674. job_id: 42,
  675. status: 'pending',
  676. status_url: '/api/v1/slice-jobs/42',
  677. });
  678. renderWithTracker({
  679. source: { kind: 'libraryFile', id: 100, filename: 'Combined.3mf' },
  680. onClose: vi.fn(),
  681. defaultAutoArrange: true,
  682. });
  683. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  684. expect(screen.getByRole('checkbox', { name: /Auto-arrange/ })).toBeChecked();
  685. await userEvent.setup().click(screen.getByRole('button', { name: /^Slice$/ }));
  686. await waitFor(() => {
  687. const [, body] = vi.mocked(mockApi.sliceLibraryFile).mock.calls[0];
  688. expect(body).toHaveProperty('auto_arrange', true);
  689. });
  690. });
  691. it('omits both layout flags when neither box is ticked (#2548)', async () => {
  692. mockApi.sliceLibraryFile.mockResolvedValue({
  693. job_id: 42,
  694. status: 'pending',
  695. status_url: '/api/v1/slice-jobs/42',
  696. });
  697. renderWithTracker({
  698. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  699. onClose: vi.fn(),
  700. });
  701. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  702. await userEvent.setup().click(screen.getByRole('button', { name: /^Slice$/ }));
  703. await waitFor(() => {
  704. const [, body] = vi.mocked(mockApi.sliceLibraryFile).mock.calls[0];
  705. expect(body).not.toHaveProperty('auto_orient');
  706. expect(body).not.toHaveProperty('auto_arrange');
  707. });
  708. });
  709. it('lets the user override the default and pick a Standard preset', async () => {
  710. const onClose = vi.fn();
  711. mockApi.sliceLibraryFile.mockResolvedValue({
  712. job_id: 42,
  713. status: 'pending',
  714. status_url: '/api/v1/slice-jobs/42',
  715. });
  716. renderWithTracker({
  717. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  718. onClose,
  719. });
  720. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  721. const user = userEvent.setup();
  722. const selects = presetSelects();
  723. await user.selectOptions(selects[0], 'standard:Bambu Lab X1 Carbon 0.4 nozzle');
  724. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  725. await waitFor(() => {
  726. expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
  727. 100,
  728. expect.objectContaining({
  729. printer_preset: { source: 'standard', id: 'Bambu Lab X1 Carbon 0.4 nozzle' },
  730. }),
  731. );
  732. });
  733. });
  734. it('routes archive sources to sliceArchive instead of sliceLibraryFile', async () => {
  735. const onClose = vi.fn();
  736. mockApi.sliceArchive.mockResolvedValue({
  737. job_id: 7,
  738. status: 'pending',
  739. status_url: '/api/v1/slice-jobs/7',
  740. });
  741. renderWithTracker({
  742. source: { kind: 'archive', id: 86, filename: 'orca.3mf' },
  743. onClose,
  744. });
  745. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  746. const user = userEvent.setup();
  747. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  748. await waitFor(() => {
  749. expect(mockApi.sliceArchive).toHaveBeenCalledWith(86, expect.any(Object));
  750. expect(mockApi.sliceLibraryFile).not.toHaveBeenCalled();
  751. });
  752. });
  753. it('surfaces enqueue errors inline and keeps the modal open', async () => {
  754. const onClose = vi.fn();
  755. mockApi.sliceLibraryFile.mockRejectedValue(new Error('Server says no'));
  756. renderWithTracker({
  757. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  758. onClose,
  759. });
  760. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  761. const user = userEvent.setup();
  762. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  763. await waitFor(() => {
  764. expect(screen.getByRole('alert')).toHaveTextContent('Server says no');
  765. });
  766. expect(onClose).not.toHaveBeenCalled();
  767. });
  768. it('shows a friendly notice when getSlicerPresets fails', async () => {
  769. mockApi.getSlicerPresets.mockRejectedValue(new Error('500'));
  770. renderWithTracker({
  771. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  772. onClose: vi.fn(),
  773. });
  774. await waitFor(() => {
  775. expect(screen.getByRole('alert')).toHaveTextContent(/Failed to load presets/i);
  776. });
  777. });
  778. it('omits the cloud banner when status is not_authenticated (#1712)', async () => {
  779. // A signed-out user (Bambu or Orca) shouldn't get a permanent "sign in"
  780. // nag at the top of every slice. Sign-in lives on the Profiles page; the
  781. // modal stays silent unless a previously-signed-in session actually broke
  782. // (expired / unreachable).
  783. mockApi.getSlicerPresets.mockResolvedValue(
  784. makeUnified({
  785. cloud_status: 'not_authenticated',
  786. orca_cloud_status: 'not_authenticated',
  787. local: fullThreeTier.local,
  788. standard: fullThreeTier.standard,
  789. }),
  790. );
  791. renderWithTracker({
  792. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  793. onClose: vi.fn(),
  794. });
  795. await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
  796. expect(screen.queryByRole('status')).toBeNull();
  797. });
  798. it('renders an "expired" banner when cloud_status is expired', async () => {
  799. mockApi.getSlicerPresets.mockResolvedValue(
  800. makeUnified({
  801. cloud_status: 'expired',
  802. local: fullThreeTier.local,
  803. }),
  804. );
  805. renderWithTracker({
  806. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  807. onClose: vi.fn(),
  808. });
  809. await waitFor(() => {
  810. expect(screen.getByRole('status')).toHaveTextContent(/expired/i);
  811. });
  812. });
  813. it('omits the banner entirely when cloud_status is ok', async () => {
  814. renderWithTracker({
  815. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  816. onClose: vi.fn(),
  817. });
  818. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  819. // No status-role banner should be rendered on the happy path.
  820. expect(screen.queryByRole('status')).toBeNull();
  821. });
  822. // ----- Multi-plate flow -----------------------------------------------
  823. function makeMultiPlateLibraryResponse() {
  824. return {
  825. file_id: 100,
  826. filename: 'Multi.3mf',
  827. is_multi_plate: true,
  828. plates: [
  829. {
  830. index: 1,
  831. name: 'Plate 1',
  832. objects: ['Cube'],
  833. object_count: 1,
  834. has_thumbnail: false,
  835. thumbnail_url: null,
  836. print_time_seconds: 600,
  837. filament_used_grams: 10,
  838. filaments: [],
  839. },
  840. {
  841. index: 2,
  842. name: 'Plate 2',
  843. objects: ['Pyramid'],
  844. object_count: 1,
  845. has_thumbnail: false,
  846. thumbnail_url: null,
  847. print_time_seconds: 800,
  848. filament_used_grams: 12,
  849. filaments: [],
  850. },
  851. ],
  852. };
  853. }
  854. it('shows the plate picker first for multi-plate library files', async () => {
  855. mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiPlateLibraryResponse());
  856. renderWithTracker({
  857. source: { kind: 'libraryFile', id: 100, filename: 'Multi.3mf' },
  858. onClose: vi.fn(),
  859. });
  860. // Plate picker renders one button per plate — the accessible name
  861. // joins the heading ("Plate N — name") with the object summary line.
  862. await screen.findByRole('button', { name: /Plate 1.*Cube/ });
  863. expect(screen.getByRole('button', { name: /Plate 2.*Pyramid/ })).toBeDefined();
  864. // Profile dropdowns must NOT be visible yet — the user has to pick a
  865. // plate first.
  866. expect(screen.queryByRole('combobox')).toBeNull();
  867. });
  868. it('skips the plate picker for single-plate sources', async () => {
  869. mockApi.getLibraryFilePlates.mockResolvedValue({
  870. file_id: 100,
  871. filename: 'Single.3mf',
  872. is_multi_plate: false,
  873. plates: [
  874. {
  875. index: 1,
  876. name: 'Plate 1',
  877. objects: [],
  878. has_thumbnail: false,
  879. thumbnail_url: null,
  880. print_time_seconds: null,
  881. filament_used_grams: null,
  882. filaments: [],
  883. },
  884. ],
  885. });
  886. renderWithTracker({
  887. source: { kind: 'libraryFile', id: 100, filename: 'Single.3mf' },
  888. onClose: vi.fn(),
  889. });
  890. // Should jump straight to the profile dropdowns.
  891. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  892. });
  893. it('passes the picked plate to the slice request', async () => {
  894. mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiPlateLibraryResponse());
  895. mockApi.sliceLibraryFile.mockResolvedValue({
  896. job_id: 42,
  897. status: 'pending',
  898. status_url: '/api/v1/slice-jobs/42',
  899. });
  900. renderWithTracker({
  901. source: { kind: 'libraryFile', id: 100, filename: 'Multi.3mf' },
  902. onClose: vi.fn(),
  903. });
  904. const user = userEvent.setup();
  905. // Step 1: pick Plate 2.
  906. const plate2Button = await screen.findByRole('button', { name: /Plate 2.*Pyramid/ });
  907. await user.click(plate2Button);
  908. // Step 2: profile dropdowns are now visible.
  909. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  910. // Step 3: submit and verify the plate index made it into the body.
  911. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  912. await waitFor(() => {
  913. expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
  914. 100,
  915. expect.objectContaining({ plate: 2 }),
  916. );
  917. });
  918. });
  919. it('"Slice all plates" toggle sends plate=0 sentinel to the backend (#1493)', async () => {
  920. mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiPlateLibraryResponse());
  921. mockApi.sliceLibraryFile.mockResolvedValue({
  922. job_id: 42,
  923. status: 'pending',
  924. status_url: '/api/v1/slice-jobs/42',
  925. });
  926. renderWithTracker({
  927. source: { kind: 'libraryFile', id: 100, filename: 'Multi.3mf' },
  928. onClose: vi.fn(),
  929. });
  930. const user = userEvent.setup();
  931. const plate1Button = await screen.findByRole('button', { name: /Plate 1.*Cube/ });
  932. await user.click(plate1Button);
  933. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  934. // The "Slice all plates" checkbox only appears for multi-plate sources.
  935. const toggle = await screen.findByRole('checkbox', { name: /Slice all 2 plates/i });
  936. await user.click(toggle);
  937. // The action button's label flips to the "Slice all" form. Click it.
  938. await user.click(screen.getByRole('button', { name: /Slice all 2 plates/i }));
  939. await waitFor(() => {
  940. expect(mockApi.sliceLibraryFile).toHaveBeenCalledTimes(1);
  941. });
  942. const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
  943. // ``plate=0`` is the BS CLI's all-plates sentinel — one slice call,
  944. // one output 3MF with every plate's gcode inside, one archive.
  945. expect((body as { plate?: number }).plate).toBe(0);
  946. });
  947. it('"Slice all plates" toggle is hidden for single-plate sources', async () => {
  948. mockApi.getLibraryFilePlates.mockResolvedValue({
  949. file_id: 100,
  950. filename: 'Single.3mf',
  951. is_multi_plate: false,
  952. plates: [
  953. {
  954. index: 1,
  955. name: 'Plate 1',
  956. objects: [],
  957. has_thumbnail: false,
  958. thumbnail_url: null,
  959. print_time_seconds: null,
  960. filament_used_grams: null,
  961. filaments: [],
  962. },
  963. ],
  964. });
  965. renderWithTracker({
  966. source: { kind: 'libraryFile', id: 100, filename: 'Single.3mf' },
  967. onClose: vi.fn(),
  968. });
  969. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  970. expect(screen.queryByRole('checkbox', { name: /Slice all/i })).toBeNull();
  971. });
  972. it('routes the plate fetch through getArchivePlates for archive sources', async () => {
  973. mockApi.getArchivePlates.mockResolvedValue({
  974. ...makeMultiPlateLibraryResponse(),
  975. archive_id: 100,
  976. filename: 'Multi.3mf',
  977. });
  978. renderWithTracker({
  979. source: { kind: 'archive', id: 100, filename: 'Multi.3mf' },
  980. onClose: vi.fn(),
  981. });
  982. await screen.findByRole('button', { name: /Plate 1.*Cube/ });
  983. expect(mockApi.getArchivePlates).toHaveBeenCalledWith(100);
  984. expect(mockApi.getLibraryFilePlates).not.toHaveBeenCalled();
  985. });
  986. it('cancelling the plate picker closes the entire slice flow', async () => {
  987. const onClose = vi.fn();
  988. mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiPlateLibraryResponse());
  989. renderWithTracker({
  990. source: { kind: 'libraryFile', id: 100, filename: 'Multi.3mf' },
  991. onClose,
  992. });
  993. await screen.findByRole('button', { name: /Plate 1.*Cube/ });
  994. const user = userEvent.setup();
  995. await user.click(screen.getByRole('button', { name: /^Close$/i }));
  996. expect(onClose).toHaveBeenCalled();
  997. });
  998. it('omits the plate field when the source is single-plate', async () => {
  999. mockApi.sliceLibraryFile.mockResolvedValue({
  1000. job_id: 42,
  1001. status: 'pending',
  1002. status_url: '/api/v1/slice-jobs/42',
  1003. });
  1004. renderWithTracker({
  1005. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  1006. onClose: vi.fn(),
  1007. });
  1008. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  1009. const user = userEvent.setup();
  1010. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  1011. await waitFor(() => {
  1012. const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
  1013. expect(body).not.toHaveProperty('plate');
  1014. });
  1015. });
  1016. // ----- Multi-color flow ------------------------------------------------
  1017. function makeMultiColorPlateResponse() {
  1018. // Single-plate 3MF that uses two filament slots — mirrors the realistic
  1019. // "I have a multi-color file with one plate" case. Multi-plate is a
  1020. // separate axis that's already covered above.
  1021. return {
  1022. file_id: 100,
  1023. filename: 'TwoColor.3mf',
  1024. is_multi_plate: false,
  1025. plates: [
  1026. {
  1027. index: 1,
  1028. name: 'Plate 1',
  1029. objects: ['Logo'],
  1030. object_count: 1,
  1031. has_thumbnail: false,
  1032. thumbnail_url: null,
  1033. print_time_seconds: 600,
  1034. filament_used_grams: 20,
  1035. filaments: [],
  1036. },
  1037. ],
  1038. };
  1039. }
  1040. function makeMultiColorRequirementsResponse() {
  1041. return {
  1042. file_id: 100,
  1043. filename: 'TwoColor.3mf',
  1044. plate_id: 1,
  1045. filaments: [
  1046. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, used_meters: 3 },
  1047. { slot_id: 2, type: 'PLA', color: '#FFFFFF', used_grams: 10, used_meters: 3 },
  1048. ],
  1049. };
  1050. }
  1051. function makeColorAwarePresets(): UnifiedPresetsResponse {
  1052. // Two filament presets in cloud: one black PLA, one white PLA. Pre-pick
  1053. // should match each plate slot to the same-colour preset so the user
  1054. // doesn't have to manually align them.
  1055. return {
  1056. orca_cloud: { printer: [], process: [], filament: [] },
  1057. cloud: {
  1058. printer: [{ id: 'P1', name: 'X1C', source: 'cloud' }],
  1059. process: [{ id: 'PR1', name: '0.20mm', source: 'cloud' }],
  1060. filament: [
  1061. { id: 'F-BLACK', name: 'Cloud PLA Black', source: 'cloud', filament_type: 'PLA', filament_colour: '#000000' },
  1062. { id: 'F-WHITE', name: 'Cloud PLA White', source: 'cloud', filament_type: 'PLA', filament_colour: '#FFFFFF' },
  1063. ],
  1064. },
  1065. local: { printer: [], process: [], filament: [] },
  1066. standard: { printer: [], process: [], filament: [] },
  1067. cloud_status: 'ok',
  1068. orca_cloud_status: 'ok',
  1069. };
  1070. }
  1071. it('renders one filament dropdown per plate slot when the source is multi-color', async () => {
  1072. mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse());
  1073. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse());
  1074. mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets());
  1075. renderWithTracker({
  1076. source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' },
  1077. onClose: vi.fn(),
  1078. });
  1079. await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
  1080. // 1 printer + 1 process + 2 filament + 1 bed-type (#1337) = 5 dropdowns.
  1081. expect(presetSelects()).toHaveLength(5);
  1082. });
  1083. it('pre-picks each filament slot by matching colour metadata', async () => {
  1084. mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse());
  1085. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse());
  1086. mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets());
  1087. mockApi.sliceLibraryFile.mockResolvedValue({
  1088. job_id: 42,
  1089. status: 'pending',
  1090. status_url: '/api/v1/slice-jobs/42',
  1091. });
  1092. renderWithTracker({
  1093. source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' },
  1094. onClose: vi.fn(),
  1095. });
  1096. await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
  1097. const user = userEvent.setup();
  1098. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  1099. await waitFor(() => {
  1100. const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
  1101. // Slot 1 was black plate → cloud black preset; slot 2 was white →
  1102. // cloud white preset. Pre-pick aligns them by metadata so the user
  1103. // doesn't have to swap them manually.
  1104. expect(body.filament_presets).toEqual([
  1105. { source: 'cloud', id: 'F-BLACK' },
  1106. { source: 'cloud', id: 'F-WHITE' },
  1107. ]);
  1108. });
  1109. });
  1110. it('still sends the legacy filament_preset for single-color flows', async () => {
  1111. // Backwards-compat with backends / proxies that read the singular field.
  1112. mockApi.sliceLibraryFile.mockResolvedValue({
  1113. job_id: 42,
  1114. status: 'pending',
  1115. status_url: '/api/v1/slice-jobs/42',
  1116. });
  1117. renderWithTracker({
  1118. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  1119. onClose: vi.fn(),
  1120. });
  1121. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  1122. const user = userEvent.setup();
  1123. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  1124. await waitFor(() => {
  1125. const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
  1126. // Single-color path mirrors the array's first entry into the legacy
  1127. // singular so older backend clients that only know about
  1128. // `filament_preset` still work.
  1129. expect(body.filament_preset).toEqual(body.filament_presets[0]);
  1130. expect(body.filament_presets).toHaveLength(1);
  1131. });
  1132. });
  1133. it('lets the user override a pre-picked filament slot', async () => {
  1134. mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse());
  1135. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse());
  1136. mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets());
  1137. mockApi.sliceLibraryFile.mockResolvedValue({
  1138. job_id: 42,
  1139. status: 'pending',
  1140. status_url: '/api/v1/slice-jobs/42',
  1141. });
  1142. renderWithTracker({
  1143. source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' },
  1144. onClose: vi.fn(),
  1145. });
  1146. await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
  1147. const user = userEvent.setup();
  1148. const selects = presetSelects();
  1149. // Order: 0 printer, 1 process, 2 bed-type, 3 filament-1, 4 filament-2
  1150. // (#1337). Auto-picks land on printer/process/filaments; bed-type
  1151. // defaults to "". Swap filament-1 (index 3) from the auto-picked black
  1152. // to white.
  1153. await user.selectOptions(selects[3], 'cloud:F-WHITE');
  1154. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  1155. await waitFor(() => {
  1156. const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
  1157. expect(body.filament_presets[0]).toEqual({ source: 'cloud', id: 'F-WHITE' });
  1158. // Slot 1 stayed at the auto-picked white.
  1159. expect(body.filament_presets[1]).toEqual({ source: 'cloud', id: 'F-WHITE' });
  1160. });
  1161. });
  1162. // Cross-printer re-slicing is a normal, supported operation as of
  1163. // 2026-05-20 (Step 0 empirical test: sidecar overrides printer / process
  1164. // / bed / kinematics from the picked profile triplet, producing valid
  1165. // target-printer G-code). No banner, no warning — the picker UI already
  1166. // shows which printer the user picked, and that's enough.
  1167. it('does not surface any cross-printer banner and keeps Slice enabled when models differ', async () => {
  1168. mockApi.getLibraryFilePlates.mockResolvedValue({
  1169. file_id: 100,
  1170. filename: 'A1Original.3mf',
  1171. is_multi_plate: false,
  1172. plates: [
  1173. {
  1174. index: 1,
  1175. name: 'Plate 1',
  1176. objects: [],
  1177. has_thumbnail: false,
  1178. thumbnail_url: null,
  1179. print_time_seconds: null,
  1180. filament_used_grams: null,
  1181. filaments: [],
  1182. },
  1183. ],
  1184. });
  1185. // Standard tier offers an X1C profile — the user picks (auto-picks) it.
  1186. mockApi.getSlicerPresets.mockResolvedValue(makeUnified({
  1187. standard: {
  1188. printer: [{ id: 'Bambu Lab X1 Carbon 0.4 nozzle', name: 'Bambu Lab X1 Carbon 0.4 nozzle', source: 'standard' }],
  1189. process: [{ id: '0.20mm Standard', name: '0.20mm Standard', source: 'standard' }],
  1190. filament: [{ id: 'Bambu PLA Basic', name: 'Bambu PLA Basic', source: 'standard' }],
  1191. },
  1192. }));
  1193. renderWithTracker({
  1194. source: { kind: 'libraryFile', id: 100, filename: 'A1Original.3mf' },
  1195. onClose: vi.fn(),
  1196. });
  1197. await waitFor(() =>
  1198. expect(screen.getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined(),
  1199. );
  1200. // No banner, no alert — re-slicing across printers is just a normal slice now.
  1201. expect(screen.queryByRole('alert')).toBeNull();
  1202. const sliceButton = screen.getByRole('button', { name: /^Slice$/ }) as HTMLButtonElement;
  1203. expect(sliceButton.disabled).toBe(false);
  1204. });
  1205. // The `used_in_plate` flag tells the modal which AMS slots are
  1206. // actually consumed by the picked plate. Slots flagged as unused
  1207. // are still rendered (the slicer CLI needs a profile per project
  1208. // slot, otherwise it silently fills the gap from embedded defaults
  1209. // and unwanted colours leak into the output) but disabled in the UI
  1210. // so the user only interacts with the dropdowns that matter.
  1211. it('disables filament dropdowns for slots not used by the picked plate', async () => {
  1212. mockApi.getLibraryFilePlates.mockResolvedValue({
  1213. file_id: 100,
  1214. filename: 'Helmet.3mf',
  1215. is_multi_plate: false,
  1216. plates: [
  1217. {
  1218. index: 1,
  1219. name: 'Plate 1',
  1220. objects: ['Helmet'],
  1221. has_thumbnail: false,
  1222. thumbnail_url: null,
  1223. print_time_seconds: 1200,
  1224. filament_used_grams: 80,
  1225. filaments: [],
  1226. },
  1227. ],
  1228. });
  1229. // Project has 2 AMS slots configured (white + grey support), but
  1230. // plate 1 only paints with white (slot 1). The backend now returns
  1231. // BOTH slots with used_in_plate flagging the difference.
  1232. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
  1233. file_id: 100,
  1234. filename: 'Helmet.3mf',
  1235. plate_id: 1,
  1236. filaments: [
  1237. { slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 80, used_meters: 27, used_in_plate: true },
  1238. { slot_id: 2, type: 'PLA', color: '#808080', used_grams: 0, used_meters: 0, used_in_plate: false },
  1239. ],
  1240. });
  1241. mockApi.getSlicerPresets.mockResolvedValue({
  1242. cloud: {
  1243. printer: [{ id: 'P1', name: 'X1C', source: 'cloud' }],
  1244. process: [{ id: 'PR1', name: '0.20mm', source: 'cloud' }],
  1245. filament: [
  1246. { id: 'F-WHITE', name: 'Cloud PLA White', source: 'cloud', filament_type: 'PLA', filament_colour: '#FFFFFF' },
  1247. { id: 'F-GREY', name: 'Cloud PLA Grey', source: 'cloud', filament_type: 'PLA', filament_colour: '#808080' },
  1248. ],
  1249. },
  1250. local: { printer: [], process: [], filament: [] },
  1251. standard: { printer: [], process: [], filament: [] },
  1252. cloud_status: 'ok',
  1253. orca_cloud: { printer: [], process: [], filament: [] },
  1254. orca_cloud_status: 'ok',
  1255. });
  1256. renderWithTracker({
  1257. source: { kind: 'libraryFile', id: 100, filename: 'Helmet.3mf' },
  1258. onClose: vi.fn(),
  1259. });
  1260. await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
  1261. // Both filament rows render — 1 printer + 1 process + 1 bed-type +
  1262. // 2 filament (#1337) = 5. bed-type sits at index 2, filament slots
  1263. // follow at 3 and 4.
  1264. const selects = presetSelects();
  1265. expect(selects).toHaveLength(5);
  1266. // Slot 1 (used) is editable, slot 2 (not used) is disabled.
  1267. expect(selects[3].disabled).toBe(false);
  1268. expect(selects[4].disabled).toBe(true);
  1269. // The disabled row's label calls out why it's disabled.
  1270. expect(screen.getByText(/not used by this plate/i)).toBeDefined();
  1271. });
  1272. it('still sends both filaments to the backend even when one slot is disabled', async () => {
  1273. // The auto-pick scoring fills the disabled slot from project
  1274. // metadata — the slicer CLI requires a profile for every project
  1275. // slot, otherwise it silently fills the gap. The disabled UI is
  1276. // purely cosmetic; the wire format must include the full list.
  1277. mockApi.getLibraryFilePlates.mockResolvedValue({
  1278. file_id: 100,
  1279. filename: 'Helmet.3mf',
  1280. is_multi_plate: false,
  1281. plates: [
  1282. {
  1283. index: 1,
  1284. name: 'Plate 1',
  1285. objects: ['Helmet'],
  1286. has_thumbnail: false,
  1287. thumbnail_url: null,
  1288. print_time_seconds: 1200,
  1289. filament_used_grams: 80,
  1290. filaments: [],
  1291. },
  1292. ],
  1293. });
  1294. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
  1295. file_id: 100,
  1296. filename: 'Helmet.3mf',
  1297. plate_id: 1,
  1298. filaments: [
  1299. { slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 80, used_meters: 27, used_in_plate: true },
  1300. { slot_id: 2, type: 'PLA', color: '#808080', used_grams: 0, used_meters: 0, used_in_plate: false },
  1301. ],
  1302. });
  1303. mockApi.getSlicerPresets.mockResolvedValue({
  1304. cloud: {
  1305. printer: [{ id: 'P1', name: 'X1C', source: 'cloud' }],
  1306. process: [{ id: 'PR1', name: '0.20mm', source: 'cloud' }],
  1307. filament: [
  1308. { id: 'F-WHITE', name: 'Cloud PLA White', source: 'cloud', filament_type: 'PLA', filament_colour: '#FFFFFF' },
  1309. { id: 'F-GREY', name: 'Cloud PLA Grey', source: 'cloud', filament_type: 'PLA', filament_colour: '#808080' },
  1310. ],
  1311. },
  1312. local: { printer: [], process: [], filament: [] },
  1313. standard: { printer: [], process: [], filament: [] },
  1314. cloud_status: 'ok',
  1315. orca_cloud: { printer: [], process: [], filament: [] },
  1316. orca_cloud_status: 'ok',
  1317. });
  1318. mockApi.sliceLibraryFile.mockResolvedValue({
  1319. job_id: 50,
  1320. status: 'pending',
  1321. status_url: '/api/v1/slice-jobs/50',
  1322. });
  1323. renderWithTracker({
  1324. source: { kind: 'libraryFile', id: 100, filename: 'Helmet.3mf' },
  1325. onClose: vi.fn(),
  1326. });
  1327. await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
  1328. const user = userEvent.setup();
  1329. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  1330. await waitFor(() => {
  1331. const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
  1332. // Both slots populated: slot 1 with the user's white pick, slot
  1333. // 2 auto-picked with grey from the colour-match scoring.
  1334. expect(body.filament_presets).toHaveLength(2);
  1335. expect(body.filament_presets[0]).toEqual({ source: 'cloud', id: 'F-WHITE' });
  1336. expect(body.filament_presets[1]).toEqual({ source: 'cloud', id: 'F-GREY' });
  1337. });
  1338. });
  1339. // #2712: the filament list is positional the whole way down — index 0 is
  1340. // slot 1, and the backend forwards it as filament_1.json..filament_N.json.
  1341. // A MakerWorld source that ships slice_info and paints with slot 4 alone
  1342. // used to yield a one-row list, so the user's only pick was bound to slot 1
  1343. // and slot 4 sliced with the source's embedded default: picking PETG gave a
  1344. // PLA print. The modal now asks for every project slot so the positions
  1345. // line up.
  1346. it('requests every project slot, not just the ones the plate prints with', async () => {
  1347. renderWithTracker({
  1348. source: { kind: 'libraryFile', id: 100, filename: 'Tunnel.3mf' },
  1349. onClose: vi.fn(),
  1350. });
  1351. await waitFor(() => expect(mockApi.getLibraryFileFilamentRequirements).toHaveBeenCalled());
  1352. const [, plateArg, , fullSlots] = mockApi.getLibraryFileFilamentRequirements.mock.calls[0];
  1353. expect(plateArg).toBe(1);
  1354. expect(fullSlots).toBe(true);
  1355. });
  1356. it('sends the pick for a high-numbered slot at its own index', async () => {
  1357. // Four project slots, only slot 4 printed. The pick must arrive as the
  1358. // FOURTH entry; anywhere else and the slicer binds it to the wrong slot.
  1359. mockApi.getLibraryFilePlates.mockResolvedValue({
  1360. file_id: 100,
  1361. filename: 'Tunnel.3mf',
  1362. is_multi_plate: false,
  1363. plates: [
  1364. {
  1365. index: 1,
  1366. name: 'Plate 1',
  1367. objects: ['Tunnel'],
  1368. has_thumbnail: false,
  1369. thumbnail_url: null,
  1370. print_time_seconds: 1200,
  1371. filament_used_grams: 105,
  1372. filaments: [],
  1373. },
  1374. ],
  1375. });
  1376. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
  1377. file_id: 100,
  1378. filename: 'Tunnel.3mf',
  1379. plate_id: 1,
  1380. filaments: [
  1381. { slot_id: 1, type: 'PLA', color: '#38CC0A', used_grams: 0, used_meters: 0, used_in_plate: false },
  1382. { slot_id: 2, type: 'PLA', color: '#161616', used_grams: 0, used_meters: 0, used_in_plate: false },
  1383. { slot_id: 3, type: 'PLA', color: '#898989', used_grams: 0, used_meters: 0, used_in_plate: false },
  1384. { slot_id: 4, type: 'PLA', color: '#898989', used_grams: 105, used_meters: 35, used_in_plate: true },
  1385. ],
  1386. });
  1387. mockApi.getSlicerPresets.mockResolvedValue({
  1388. cloud: {
  1389. printer: [{ id: 'P1', name: 'X1C', source: 'cloud' }],
  1390. process: [{ id: 'PR1', name: '0.20mm', source: 'cloud' }],
  1391. filament: [
  1392. { id: 'F-PLA', name: 'Cloud PLA Grey', source: 'cloud', filament_type: 'PLA', filament_colour: '#898989' },
  1393. { id: 'F-PETG', name: 'Cloud PETG', source: 'cloud', filament_type: 'PETG', filament_colour: '#00FF00' },
  1394. ],
  1395. },
  1396. local: { printer: [], process: [], filament: [] },
  1397. standard: { printer: [], process: [], filament: [] },
  1398. cloud_status: 'ok',
  1399. orca_cloud: { printer: [], process: [], filament: [] },
  1400. orca_cloud_status: 'ok',
  1401. });
  1402. mockApi.sliceLibraryFile.mockResolvedValue({
  1403. job_id: 51,
  1404. status: 'pending',
  1405. status_url: '/api/v1/slice-jobs/51',
  1406. });
  1407. renderWithTracker({
  1408. source: { kind: 'libraryFile', id: 100, filename: 'Tunnel.3mf' },
  1409. onClose: vi.fn(),
  1410. });
  1411. await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
  1412. // 1 printer + 1 process + 1 bed-type + 4 filament rows.
  1413. const selects = presetSelects();
  1414. expect(selects).toHaveLength(7);
  1415. // Only slot 4 is selectable — the other three are the padding.
  1416. expect([selects[3].disabled, selects[4].disabled, selects[5].disabled]).toEqual([true, true, true]);
  1417. expect(selects[6].disabled).toBe(false);
  1418. const user = userEvent.setup();
  1419. await user.selectOptions(selects[6], 'cloud:F-PETG');
  1420. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  1421. await waitFor(() => {
  1422. const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
  1423. expect(body.filament_presets).toHaveLength(4);
  1424. expect(body.filament_presets[3]).toEqual({ source: 'cloud', id: 'F-PETG' });
  1425. });
  1426. });
  1427. // ------------------------------------------------------------------
  1428. // Slicer Pipelines (#1425) — Apply / Save integration in SliceModal
  1429. // ------------------------------------------------------------------
  1430. it('Apply pipeline dropdown is disabled and shows empty hint when no pipelines exist', async () => {
  1431. mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
  1432. renderWithTracker({
  1433. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  1434. onClose: vi.fn(),
  1435. });
  1436. await waitFor(() => {
  1437. const select = screen.getByLabelText(/Apply pipeline/i) as HTMLSelectElement;
  1438. expect(select.disabled).toBe(true);
  1439. expect(select.querySelector('option')?.textContent).toMatch(/No saved pipelines/i);
  1440. });
  1441. });
  1442. it('applies a saved pipeline to printer, process, and bed_type slots on selection', async () => {
  1443. mockApi.listSlicerPipelines.mockResolvedValue({
  1444. pipelines: [
  1445. {
  1446. id: 7,
  1447. name: 'Production Batch',
  1448. description: null,
  1449. printer_preset: { source: 'local', id: '1' },
  1450. process_preset: { source: 'local', id: '2' },
  1451. filament_presets: [{ source: 'local', id: '3' }],
  1452. bed_type: 'Textured PEI Plate',
  1453. target_kind: 'printer_class',
  1454. target_printer_id: null,
  1455. target_model_class: null,
  1456. fanout_strategy: 'max_parallel',
  1457. created_by: null,
  1458. created_at: '2026-06-27T00:00:00Z',
  1459. updated_at: '2026-06-27T00:00:00Z',
  1460. },
  1461. ],
  1462. });
  1463. renderWithTracker({
  1464. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  1465. onClose: vi.fn(),
  1466. });
  1467. // Wait for presets + pipelines listing to populate the modal.
  1468. await waitFor(() => {
  1469. const select = screen.getByLabelText(/Apply pipeline/i) as HTMLSelectElement;
  1470. expect(select.disabled).toBe(false);
  1471. expect(within(select).getByText('Production Batch')).toBeDefined();
  1472. });
  1473. const user = userEvent.setup();
  1474. await user.selectOptions(screen.getByLabelText(/Apply pipeline/i), '7');
  1475. // After applying, submitting the slice request should carry the
  1476. // pipeline's preset refs end-to-end.
  1477. mockApi.sliceLibraryFile.mockResolvedValue({
  1478. job_id: 42,
  1479. status: 'queued',
  1480. status_url: '/api/v1/slice-jobs/42',
  1481. });
  1482. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  1483. await waitFor(() => {
  1484. expect(mockApi.sliceLibraryFile).toHaveBeenCalled();
  1485. const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
  1486. expect(body.printer_preset).toEqual({ source: 'local', id: '1' });
  1487. expect(body.process_preset).toEqual({ source: 'local', id: '2' });
  1488. expect(body.filament_presets[0]).toEqual({ source: 'local', id: '3' });
  1489. expect(body.bed_type).toBe('Textured PEI Plate');
  1490. });
  1491. });
  1492. it('saves the current four-slot selection as a new pipeline when the user clicks Save as pipeline', async () => {
  1493. mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
  1494. mockApi.createSlicerPipeline.mockResolvedValue({
  1495. id: 99,
  1496. name: 'My Default',
  1497. description: null,
  1498. printer_preset: { source: 'local', id: '1' },
  1499. process_preset: { source: 'local', id: '2' },
  1500. filament_presets: [{ source: 'local', id: '3' }],
  1501. bed_type: null,
  1502. target_kind: 'printer_class',
  1503. target_printer_id: null,
  1504. target_model_class: null,
  1505. fanout_strategy: 'max_parallel',
  1506. created_by: null,
  1507. created_at: '2026-06-27T00:00:00Z',
  1508. updated_at: '2026-06-27T00:00:00Z',
  1509. });
  1510. renderWithTracker({
  1511. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  1512. onClose: vi.fn(),
  1513. });
  1514. // Wait for auto-pick to populate all four slots from the fullThreeTier
  1515. // listing — then Save as pipeline becomes enabled.
  1516. const user = userEvent.setup();
  1517. let saveBtn: HTMLButtonElement;
  1518. await waitFor(() => {
  1519. saveBtn = screen.getByRole('button', { name: /^Save as pipeline$/ }) as HTMLButtonElement;
  1520. expect(saveBtn.disabled).toBe(false);
  1521. });
  1522. await user.click(saveBtn!);
  1523. const nameInput = screen.getByLabelText(/New pipeline name/i);
  1524. await user.type(nameInput, 'My Default');
  1525. await user.click(screen.getByRole('button', { name: /^Save$/ }));
  1526. await waitFor(() => {
  1527. expect(mockApi.createSlicerPipeline).toHaveBeenCalledTimes(1);
  1528. const body = mockApi.createSlicerPipeline.mock.calls[0][0];
  1529. expect(body.name).toBe('My Default');
  1530. // The four slots come from the auto-picked unified-presets listing —
  1531. // local tier wins per SLICE_MODAL_TIER_ORDER.
  1532. expect(body.printer_preset.source).toBe('local');
  1533. expect(body.process_preset.source).toBe('local');
  1534. expect(body.filament_presets[0].source).toBe('local');
  1535. });
  1536. });
  1537. /**
  1538. * Per-slot filament colour (#2977).
  1539. *
  1540. * A filament preset carries no colour in either slicer -- colour is a
  1541. * per-project property their GUIs set from the plate -- so a slice that
  1542. * supplies none records the CLI's compiled-in #00AE42 for every slot. That
  1543. * is what made every internal-slicer thumbnail Bambu green regardless of
  1544. * the filament picked, and what made the print dialog report a colour
  1545. * mismatch against the AMS slot it had just correctly mapped to.
  1546. *
  1547. * The swatch is the only place the colour can come from for an STL, which
  1548. * has none anywhere else, so it is offered for single-slot sources too.
  1549. */
  1550. describe('filament colour swatch', () => {
  1551. function colourInputs(): HTMLInputElement[] {
  1552. return screen
  1553. .getAllByLabelText('Filament colour')
  1554. .filter((el): el is HTMLInputElement => el instanceof HTMLInputElement);
  1555. }
  1556. it('shows the hex beside the swatch so it reads as a control, not a decoration', async () => {
  1557. // The reason this exists: a bare dot next to a label was taken for the
  1558. // read-only swatch multi-colour rows already had, so on the STL -- the
  1559. // one source with no colour to inherit -- nothing said it was settable.
  1560. renderWithTracker({
  1561. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  1562. onClose: vi.fn(),
  1563. });
  1564. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  1565. expect(screen.getByText('#00AE42')).toBeDefined();
  1566. });
  1567. it('puts the whole control on the dropdown row, not in the label', async () => {
  1568. // Sitting beside the <select> and styled like it is what makes it read
  1569. // as a control. In the label row it read as a caption on the label.
  1570. renderWithTracker({
  1571. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  1572. onClose: vi.fn(),
  1573. });
  1574. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  1575. const row = colourInputs()[0].closest('div');
  1576. expect(row?.querySelector('select')).not.toBeNull();
  1577. });
  1578. it('wraps the swatch and its hex in one label bound to the input', async () => {
  1579. // So a click anywhere on the pill opens the picker, rather than only a
  1580. // 16px dot being live.
  1581. renderWithTracker({
  1582. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  1583. onClose: vi.fn(),
  1584. });
  1585. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  1586. const pill = screen.getByText('#00AE42').closest('label');
  1587. expect(pill?.getAttribute('for')).toBe(colourInputs()[0].id);
  1588. expect(pill?.contains(colourInputs()[0])).toBe(true);
  1589. });
  1590. it('the hex follows the swatch when it is changed', async () => {
  1591. renderWithTracker({
  1592. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  1593. onClose: vi.fn(),
  1594. });
  1595. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  1596. fireEvent.change(colourInputs()[0], { target: { value: '#e8b00c' } });
  1597. await waitFor(() => expect(screen.getByText('#E8B00C')).toBeDefined());
  1598. });
  1599. it('paints the colour on the input itself, not only via the native swatch', async () => {
  1600. // An engine that does not paint ::-webkit-color-swatch would otherwise
  1601. // leave an empty ring, which is indistinguishable from no swatch.
  1602. renderWithTracker({
  1603. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  1604. onClose: vi.fn(),
  1605. });
  1606. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  1607. expect(colourInputs()[0].style.backgroundColor).not.toBe('');
  1608. });
  1609. it('offers a colour swatch for a single-slot STL, which has no colour of its own', async () => {
  1610. renderWithTracker({
  1611. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  1612. onClose: vi.fn(),
  1613. });
  1614. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  1615. expect(colourInputs()).toHaveLength(1);
  1616. });
  1617. it("shows the slicer's own default for a source that carries no colour", async () => {
  1618. // Not an invented placeholder: #00AE42 is exactly what the slice would
  1619. // record if nothing were sent, so the swatch tells the truth about the
  1620. // file that is about to be produced.
  1621. renderWithTracker({
  1622. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  1623. onClose: vi.fn(),
  1624. });
  1625. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  1626. expect(colourInputs()[0].value).toBe('#00ae42');
  1627. });
  1628. it("pre-fills each slot from the source plate's designed colour", async () => {
  1629. mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse());
  1630. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse());
  1631. mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets());
  1632. renderWithTracker({
  1633. source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' },
  1634. onClose: vi.fn(),
  1635. });
  1636. await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
  1637. expect(colourInputs().map((i) => i.value)).toEqual(['#000000', '#ffffff']);
  1638. });
  1639. it("sends the source plate's colours when the swatches are left alone", async () => {
  1640. mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse());
  1641. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse());
  1642. mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets());
  1643. mockApi.sliceLibraryFile.mockResolvedValue({
  1644. job_id: 42,
  1645. status: 'pending',
  1646. status_url: '/api/v1/slice-jobs/42',
  1647. });
  1648. renderWithTracker({
  1649. source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' },
  1650. onClose: vi.fn(),
  1651. });
  1652. await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
  1653. await userEvent.setup().click(screen.getByRole('button', { name: /^Slice$/ }));
  1654. await waitFor(() => {
  1655. expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
  1656. 100,
  1657. expect.objectContaining({ filament_colours: ['#000000', '#FFFFFF'] }),
  1658. );
  1659. });
  1660. });
  1661. it('sends an empty string for a slot with no colour, so the backend fallbacks still run', async () => {
  1662. // A sent colour outranks the preset's own default_filament_colour, so
  1663. // pinning the picker's displayed #00AE42 here would silently discard
  1664. // the real colour of an Orca-imported profile that carries one.
  1665. mockApi.sliceLibraryFile.mockResolvedValue({
  1666. job_id: 42,
  1667. status: 'pending',
  1668. status_url: '/api/v1/slice-jobs/42',
  1669. });
  1670. renderWithTracker({
  1671. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  1672. onClose: vi.fn(),
  1673. });
  1674. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  1675. await userEvent.setup().click(screen.getByRole('button', { name: /^Slice$/ }));
  1676. await waitFor(() => {
  1677. expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
  1678. 100,
  1679. expect.objectContaining({ filament_colours: [''] }),
  1680. );
  1681. });
  1682. });
  1683. it('sends the user\'s pick, upper-cased, once a swatch is changed', async () => {
  1684. mockApi.sliceLibraryFile.mockResolvedValue({
  1685. job_id: 42,
  1686. status: 'pending',
  1687. status_url: '/api/v1/slice-jobs/42',
  1688. });
  1689. renderWithTracker({
  1690. source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
  1691. onClose: vi.fn(),
  1692. });
  1693. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  1694. const user = userEvent.setup();
  1695. // A colour input has no text entry; fire the change the picker would.
  1696. fireEvent.change(colourInputs()[0], { target: { value: '#e8b00c' } });
  1697. await waitFor(() => expect(colourInputs()[0].value).toBe('#e8b00c'));
  1698. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  1699. await waitFor(() => {
  1700. expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
  1701. 100,
  1702. expect.objectContaining({ filament_colours: ['#E8B00C'] }),
  1703. );
  1704. });
  1705. });
  1706. it('only overrides the slot that was changed', async () => {
  1707. mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse());
  1708. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse());
  1709. mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets());
  1710. mockApi.sliceLibraryFile.mockResolvedValue({
  1711. job_id: 42,
  1712. status: 'pending',
  1713. status_url: '/api/v1/slice-jobs/42',
  1714. });
  1715. renderWithTracker({
  1716. source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' },
  1717. onClose: vi.fn(),
  1718. });
  1719. await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
  1720. fireEvent.change(colourInputs()[1], { target: { value: '#112233' } });
  1721. await userEvent.setup().click(screen.getByRole('button', { name: /^Slice$/ }));
  1722. await waitFor(() => {
  1723. expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
  1724. 100,
  1725. expect.objectContaining({ filament_colours: ['#000000', '#112233'] }),
  1726. );
  1727. });
  1728. });
  1729. it('trims the alpha byte for display but submits the colour whole', async () => {
  1730. // The AMS reports colours with an alpha byte and a source 3MF can carry
  1731. // one; <input type="color"> accepts only the 6-digit form.
  1732. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
  1733. file_id: 100,
  1734. filename: 'Alpha.3mf',
  1735. plate_id: 1,
  1736. filaments: [{ slot_id: 1, type: 'PLA', color: '#E8B00CFF', used_grams: 10, used_meters: 3 }],
  1737. });
  1738. mockApi.sliceLibraryFile.mockResolvedValue({
  1739. job_id: 42,
  1740. status: 'pending',
  1741. status_url: '/api/v1/slice-jobs/42',
  1742. });
  1743. renderWithTracker({
  1744. source: { kind: 'libraryFile', id: 100, filename: 'Alpha.3mf' },
  1745. onClose: vi.fn(),
  1746. });
  1747. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  1748. expect(colourInputs()[0].value).toBe('#e8b00c');
  1749. await userEvent.setup().click(screen.getByRole('button', { name: /^Slice$/ }));
  1750. await waitFor(() => {
  1751. expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
  1752. 100,
  1753. expect.objectContaining({ filament_colours: ['#E8B00CFF'] }),
  1754. );
  1755. });
  1756. });
  1757. it('is disabled in "slice as designed" mode, which sends no filament profiles', async () => {
  1758. mockApi.getLibraryFilePlates.mockResolvedValue({
  1759. file_id: 100,
  1760. filename: 'Designed.3mf',
  1761. plates: [],
  1762. is_multi_plate: false,
  1763. embedded_printer: 'Imported X1C 0.4',
  1764. embedded_process: '0.20mm Standard',
  1765. });
  1766. renderWithTracker({
  1767. source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
  1768. onClose: vi.fn(),
  1769. });
  1770. await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
  1771. const toggle = screen.getByRole('checkbox', { name: /built-in settings/i });
  1772. expect(colourInputs()[0].disabled).toBe(false);
  1773. await userEvent.setup().click(toggle);
  1774. expect(colourInputs()[0].disabled).toBe(true);
  1775. });
  1776. });
  1777. });
  1778. // Pure-function tests for the filament slot picker. Pinned as a separate
  1779. // describe so the contract is visible without needing the modal mount.
  1780. /**
  1781. * The slice dialog switches to a two-column layout once there is room for it,
  1782. * and the process-settings panel then owns the right-hand column. The global
  1783. * test setup pins matchMedia to `matches: false`, so every other test in this
  1784. * file exercises the narrow single-stack path; these override it.
  1785. */
  1786. describe('SliceModal — process settings in "slice as designed" mode', () => {
  1787. beforeEach(() => {
  1788. vi.clearAllMocks();
  1789. mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
  1790. mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {}, reason: 'ok' });
  1791. mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
  1792. mockApi.getSlicerLoadedSpools.mockResolvedValue({ printers: [] });
  1793. mockApi.getSlicerPrinterModels.mockResolvedValue({});
  1794. mockApi.getLibraryFilePlates.mockResolvedValue({
  1795. file_id: 100,
  1796. filename: 'Designed.3mf',
  1797. plates: [],
  1798. is_multi_plate: false,
  1799. embedded_printer: 'Bambu Lab X1 Carbon 0.4 nozzle',
  1800. embedded_process: '0.20mm Standard',
  1801. });
  1802. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
  1803. file_id: 100, filename: 'Designed.3mf', plate_id: 1, filaments: [],
  1804. });
  1805. });
  1806. it('disables the panel rather than removing it', async () => {
  1807. const user = userEvent.setup();
  1808. renderWithTracker({
  1809. source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
  1810. onClose: vi.fn(),
  1811. });
  1812. const toggle = (await screen.findByLabelText(/Use the file's built-in settings/)) as HTMLInputElement;
  1813. const header = await screen.findByRole('button', { name: /Process settings/ });
  1814. await user.click(header);
  1815. const search = await screen.findByPlaceholderText('Search settings');
  1816. expect(search).toBeEnabled();
  1817. await user.click(toggle);
  1818. // Still on screen — hiding it made the dialog look like it had lost a
  1819. // feature — but nothing in it can be operated, because nothing in it is
  1820. // sent on this path.
  1821. expect(screen.getByPlaceholderText('Search settings')).toBeDisabled();
  1822. expect(screen.getByRole('button', { name: 'Expert' })).toBeDisabled();
  1823. expect(screen.getByText(/Not used while/)).toBeInTheDocument();
  1824. expect(screen.getByText('Inactive')).toBeInTheDocument();
  1825. });
  1826. it('sends no process overrides once the file drives the slice', async () => {
  1827. mockApi.sliceLibraryFile.mockResolvedValue({ job_id: 42, status: 'pending', status_url: '/x' });
  1828. const user = userEvent.setup();
  1829. renderWithTracker({
  1830. source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
  1831. onClose: vi.fn(),
  1832. });
  1833. // Edit something, then hand the slice over to the file's own settings.
  1834. await user.click(await screen.findByRole('button', { name: /Process settings/ }));
  1835. const input = await screen.findByLabelText(/^Layer height/);
  1836. await user.clear(input);
  1837. await user.type(input, '0.16');
  1838. await user.click(screen.getByLabelText(/Use the file's built-in settings/));
  1839. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  1840. await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled());
  1841. const payload = mockApi.sliceLibraryFile.mock.calls[0][1] as Record<string, unknown>;
  1842. expect(payload.use_embedded_settings).toBe(true);
  1843. expect(payload).not.toHaveProperty('process_overrides');
  1844. });
  1845. });
  1846. describe('SliceModal — process settings layout', () => {
  1847. const setViewport = (wide: boolean) => {
  1848. Object.defineProperty(window, 'matchMedia', {
  1849. writable: true,
  1850. value: (query: string) => ({
  1851. matches: wide,
  1852. media: query,
  1853. onchange: null,
  1854. addListener: () => {},
  1855. removeListener: () => {},
  1856. addEventListener: () => {},
  1857. removeEventListener: () => {},
  1858. dispatchEvent: () => true,
  1859. }),
  1860. });
  1861. };
  1862. beforeEach(() => {
  1863. vi.clearAllMocks();
  1864. mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
  1865. mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {}, reason: 'ok' });
  1866. mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
  1867. mockApi.getSlicerLoadedSpools.mockResolvedValue({ printers: [] });
  1868. mockApi.getLibraryFilePlates.mockResolvedValue({
  1869. file_id: 100,
  1870. filename: 'Cube.stl',
  1871. plates: [],
  1872. is_multi_plate: false,
  1873. });
  1874. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
  1875. file_id: 100,
  1876. filename: 'Cube.stl',
  1877. plate_id: 1,
  1878. filaments: [],
  1879. });
  1880. });
  1881. afterEach(() => setViewport(false));
  1882. it('keeps the panel collapsed behind a disclosure in the narrow layout', async () => {
  1883. setViewport(false);
  1884. renderWithTracker({ source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, onClose: vi.fn() });
  1885. const header = await screen.findByRole('button', { name: /Process settings/ });
  1886. expect(header).toBeEnabled();
  1887. expect(screen.queryByPlaceholderText('Search settings')).not.toBeInTheDocument();
  1888. await userEvent.setup().click(header);
  1889. await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
  1890. });
  1891. it('opens the panel without a click once it has a column of its own', async () => {
  1892. setViewport(true);
  1893. renderWithTracker({ source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, onClose: vi.fn() });
  1894. // No disclosure to operate: the panel is the column, so its header is
  1895. // inert rather than offering to collapse something that has room.
  1896. await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
  1897. expect(screen.getByRole('button', { name: /Process settings/ })).toBeDisabled();
  1898. });
  1899. });
  1900. /**
  1901. * Process and filament lists hold back presets that resolve to a *different*
  1902. * printer, behind a per-slot "Show all". Two things must never be hidden: a
  1903. * preset whose compatibility is merely unknown, and whatever is currently
  1904. * selected.
  1905. */
  1906. describe('SliceModal — presets filtered by the selected printer', () => {
  1907. const presets: UnifiedPresetsResponse = {
  1908. cloud: { printer: [], process: [], filament: [] },
  1909. orca_cloud: { printer: [], process: [], filament: [] },
  1910. local: { printer: [], process: [], filament: [] },
  1911. standard: {
  1912. printer: [
  1913. { id: 'Bambu Lab X1 Carbon 0.4 nozzle', name: 'Bambu Lab X1 Carbon 0.4 nozzle', source: 'standard' },
  1914. ],
  1915. process: [
  1916. { id: 'p-x1c', name: '0.20mm Standard @BBL X1C', source: 'standard' },
  1917. { id: 'p-h2d', name: '0.20mm Standard @BBL H2D', source: 'standard' },
  1918. { id: 'p-a1m', name: '0.20mm Standard @BBL A1M', source: 'standard' },
  1919. // No printer tag at all — compatibility is unknown, never hidden.
  1920. { id: 'p-custom', name: 'My own profile', source: 'standard' },
  1921. ],
  1922. filament: [{ id: 'f-x1c', name: 'Bambu PLA Basic @BBL X1C', source: 'standard' }],
  1923. },
  1924. cloud_status: 'ok',
  1925. orca_cloud_status: 'ok',
  1926. } as UnifiedPresetsResponse;
  1927. const processOptionNames = () =>
  1928. Array.from(presetSelects()[1].options).map((o) => o.textContent);
  1929. beforeEach(() => {
  1930. vi.clearAllMocks();
  1931. mockApi.getSlicerPresets.mockResolvedValue(presets);
  1932. mockApi.getSlicerPrinterModels.mockResolvedValue({ 'Bambu Lab X1 Carbon': 'X1C' });
  1933. mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
  1934. mockApi.getSlicerLoadedSpools.mockResolvedValue({ printers: [] });
  1935. mockApi.getLibraryFilePlates.mockResolvedValue({
  1936. file_id: 100, filename: 'Cube.stl', plates: [], is_multi_plate: false,
  1937. });
  1938. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
  1939. file_id: 100, filename: 'Cube.stl', plate_id: 1, filaments: [],
  1940. });
  1941. });
  1942. const open = async () => {
  1943. renderWithTracker({ source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, onClose: vi.fn() });
  1944. await waitFor(() => expect(presetSelects().length).toBeGreaterThan(1));
  1945. };
  1946. it('leaves out presets belonging to another printer', async () => {
  1947. await open();
  1948. await waitFor(() => expect(processOptionNames()).toContain('0.20mm Standard @BBL X1C'));
  1949. expect(processOptionNames()).not.toContain('0.20mm Standard @BBL H2D');
  1950. expect(processOptionNames()).not.toContain('0.20mm Standard @BBL A1M');
  1951. });
  1952. it('keeps a preset whose compatibility cannot be determined', async () => {
  1953. await open();
  1954. // An untagged preset carries no evidence either way; hiding it would make
  1955. // a user's own imported profiles vanish.
  1956. await waitFor(() => expect(processOptionNames()).toContain('My own profile'));
  1957. });
  1958. it('says how many it held back and reveals them on request', async () => {
  1959. const user = userEvent.setup();
  1960. await open();
  1961. const hidden = await screen.findByText('2 hidden');
  1962. expect(hidden).toBeInTheDocument();
  1963. await user.click(within(hidden.parentElement as HTMLElement).getByRole('button', { name: 'Show all' }));
  1964. await waitFor(() => expect(processOptionNames()).toContain('0.20mm Standard @BBL H2D'));
  1965. expect(processOptionNames()).toContain('0.20mm Standard @BBL A1M');
  1966. });
  1967. it('collapses the list again on Show fewer', async () => {
  1968. const user = userEvent.setup();
  1969. await open();
  1970. await user.click((await screen.findAllByRole('button', { name: 'Show all' }))[0]);
  1971. await waitFor(() => expect(processOptionNames()).toContain('0.20mm Standard @BBL H2D'));
  1972. await user.click(screen.getAllByRole('button', { name: 'Show fewer' })[0]);
  1973. await waitFor(() => expect(processOptionNames()).not.toContain('0.20mm Standard @BBL H2D'));
  1974. });
  1975. it('never hides the preset that is currently selected', async () => {
  1976. const user = userEvent.setup();
  1977. await open();
  1978. // Reach a cross-printer preset, pick it, then collapse the list again.
  1979. await user.click((await screen.findAllByRole('button', { name: 'Show all' }))[0]);
  1980. await waitFor(() => expect(processOptionNames()).toContain('0.20mm Standard @BBL H2D'));
  1981. await user.selectOptions(presetSelects()[1], 'standard:p-h2d');
  1982. await user.click(screen.getAllByRole('button', { name: 'Show fewer' })[0]);
  1983. // Dropping it from the options would blank the select and silently discard
  1984. // a deliberate cross-printer choice.
  1985. await waitFor(() => expect(processOptionNames()).toContain('0.20mm Standard @BBL H2D'));
  1986. expect(presetSelects()[1].value).toBe('standard:p-h2d');
  1987. // The one still-hidden preset is counted; the selected one is not.
  1988. expect(screen.getByText('1 hidden')).toBeInTheDocument();
  1989. });
  1990. });
  1991. describe('pickFilamentForSlot — printer-compat contract (#1851)', () => {
  1992. // Index that recognises @BBL H2C / @BBL A1 tokens via the canonical
  1993. // PRINTER_MODEL_MAP. Real production data comes through
  1994. // ``api.getSlicerPrinterModels`` — the H2C / A1 fragments are the ones
  1995. // the production registry ships.
  1996. const index = buildCompatibilityIndex({
  1997. 'Bambu Lab A1': 'A1',
  1998. 'Bambu Lab H2C': 'H2C',
  1999. });
  2000. it('prefers a printer-compatible preset over a printer-mismatched one even with better colour match', () => {
  2001. // The OP scenario for #1851: a Bambu Lab A1 is selected; the unused-slot
  2002. // requirement carries the original H2C plate's PLA colour. With the
  2003. // legacy soft-penalty scoring an H2C-bound preset whose colour matches
  2004. // exactly could still rise above the A1-compatible PLA Basic whose
  2005. // colour doesn't, and then the unused-slot substitution propagated the
  2006. // H2C-bound preset across every unused slot — the CLI rejected with
  2007. // ``filament preset Generic PLA @BBL H2C (slot 1) is not compatible
  2008. // with printer Bambu Lab A1 0.4 nozzle``. The hard-skip contract makes
  2009. // sure a mismatched preset is never chosen while any compatible
  2010. // alternative exists, irrespective of metadata-score arithmetic.
  2011. const presets = makeUnified({
  2012. standard: {
  2013. printer: [],
  2014. process: [],
  2015. filament: [
  2016. {
  2017. id: 'Generic PLA @BBL H2C',
  2018. name: 'Generic PLA @BBL H2C',
  2019. source: 'standard',
  2020. filament_type: 'PLA',
  2021. filament_colour: '#FF0000',
  2022. },
  2023. {
  2024. id: 'Bambu PLA Basic @BBL A1',
  2025. name: 'Bambu PLA Basic @BBL A1',
  2026. source: 'standard',
  2027. filament_type: 'PLA',
  2028. filament_colour: '#FFFFFF',
  2029. },
  2030. ],
  2031. },
  2032. });
  2033. const pick = pickFilamentForSlot(
  2034. presets,
  2035. { type: 'PLA', color: '#FF0000' },
  2036. 'Bambu Lab A1 0.4 nozzle',
  2037. index,
  2038. );
  2039. expect(pick).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL A1' });
  2040. });
  2041. it('falls back to a mismatched preset when no compatible alternative exists', () => {
  2042. // Graceful degrade: when every available preset is printer-mismatched,
  2043. // returning ``null`` would block the slice entirely. The picker keeps
  2044. // its old behaviour of returning the best-scoring mismatch so the user
  2045. // sees a populated dropdown they can correct, not an empty one.
  2046. const presets = makeUnified({
  2047. standard: {
  2048. printer: [],
  2049. process: [],
  2050. filament: [
  2051. {
  2052. id: 'Generic PLA @BBL H2C',
  2053. name: 'Generic PLA @BBL H2C',
  2054. source: 'standard',
  2055. filament_type: 'PLA',
  2056. filament_colour: '#FF0000',
  2057. },
  2058. ],
  2059. },
  2060. });
  2061. const pick = pickFilamentForSlot(
  2062. presets,
  2063. { type: 'PLA', color: '#FF0000' },
  2064. 'Bambu Lab A1 0.4 nozzle',
  2065. index,
  2066. );
  2067. expect(pick).toEqual({ source: 'standard', id: 'Generic PLA @BBL H2C' });
  2068. });
  2069. it('treats a no-printer-context call as no-mismatch (every preset eligible)', () => {
  2070. // ``printerName === null`` happens transiently on first render before the
  2071. // printer pre-pick effect has run. ``presetCompatibility`` returns
  2072. // ``unknown`` for every preset in that case, so the picker should just
  2073. // pick by metadata score with no compatibility filter active.
  2074. const presets = makeUnified({
  2075. standard: {
  2076. printer: [],
  2077. process: [],
  2078. filament: [
  2079. {
  2080. id: 'Generic PLA @BBL H2C',
  2081. name: 'Generic PLA @BBL H2C',
  2082. source: 'standard',
  2083. filament_type: 'PLA',
  2084. filament_colour: '#FF0000',
  2085. },
  2086. ],
  2087. },
  2088. });
  2089. const pick = pickFilamentForSlot(
  2090. presets,
  2091. { type: 'PLA', color: '#FF0000' },
  2092. null,
  2093. index,
  2094. );
  2095. expect(pick).toEqual({ source: 'standard', id: 'Generic PLA @BBL H2C' });
  2096. });
  2097. });
  2098. describe('pickFilamentForSlot — long-form printer tag (#2628)', () => {
  2099. const index = buildCompatibilityIndex({
  2100. 'Bambu Lab A1': 'A1',
  2101. 'Bambu Lab H2D': 'H2D',
  2102. });
  2103. it('never auto-picks a user-saved preset scoped to another printer', () => {
  2104. // michaelklos's registry: a cloud-tier user preset carrying the full
  2105. // "@Bambu Lab H2D 0.4 nozzle" tag outscores the A1 preset on tier bonus
  2106. // alone. Until the matcher learned the long form it classified 'unknown'
  2107. // — indistinguishable from compatible — so it won the slot, landed in a
  2108. // dropdown the modal disables (slot not used by the plate), and the CLI
  2109. // rejected the whole slice.
  2110. const presets = makeUnified({
  2111. cloud: {
  2112. printer: [],
  2113. process: [],
  2114. filament: [
  2115. {
  2116. id: 'sunlu-tpu-h2d',
  2117. name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle',
  2118. source: 'cloud',
  2119. filament_type: 'PLA',
  2120. filament_colour: '#FF0000',
  2121. },
  2122. ],
  2123. },
  2124. standard: {
  2125. printer: [],
  2126. process: [],
  2127. filament: [
  2128. {
  2129. id: 'Bambu PLA Basic @BBL A1',
  2130. name: 'Bambu PLA Basic @BBL A1',
  2131. source: 'standard',
  2132. filament_type: 'PLA',
  2133. filament_colour: '#FFFFFF',
  2134. },
  2135. ],
  2136. },
  2137. });
  2138. const pick = pickFilamentForSlot(
  2139. presets,
  2140. { type: 'PLA', color: '#FF0000' },
  2141. 'Bambu Lab A1 0.4 nozzle',
  2142. index,
  2143. );
  2144. expect(pick).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL A1' });
  2145. });
  2146. it('still picks a long-form preset for its own printer', () => {
  2147. const presets = makeUnified({
  2148. cloud: {
  2149. printer: [],
  2150. process: [],
  2151. filament: [
  2152. {
  2153. id: 'sunlu-tpu-h2d',
  2154. name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle',
  2155. source: 'cloud',
  2156. filament_type: 'TPU',
  2157. filament_colour: '#FF0000',
  2158. },
  2159. ],
  2160. },
  2161. });
  2162. const pick = pickFilamentForSlot(
  2163. presets,
  2164. { type: 'TPU', color: '#FF0000' },
  2165. 'Bambu Lab H2D 0.4 nozzle',
  2166. index,
  2167. );
  2168. expect(pick).toEqual({ source: 'cloud', id: 'sunlu-tpu-h2d' });
  2169. });
  2170. });
  2171. describe('SliceModal — material and printer filtering (#2982)', () => {
  2172. const A1 = 'Bambu Lab A1 0.4 nozzle';
  2173. const P1S = 'Bambu Lab P1S 0.4 nozzle';
  2174. beforeEach(() => {
  2175. vi.clearAllMocks();
  2176. mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {}, reason: 'ok' });
  2177. mockApi.getSlicerPrinterModels.mockResolvedValue({
  2178. 'Bambu Lab A1': 'A1',
  2179. 'Bambu Lab P1S': 'P1S',
  2180. 'Bambu Lab X1 Carbon': 'X1C',
  2181. });
  2182. mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
  2183. mockApi.getSlicerLoadedSpools.mockResolvedValue({ printers: [] });
  2184. mockApi.getLibraryFilePlates.mockResolvedValue({ file_id: 100, filename: 'Plate.3mf', plates: [] });
  2185. mockApi.sliceLibraryFile.mockResolvedValue({
  2186. job_id: 42,
  2187. status: 'pending',
  2188. status_url: '/api/v1/slice-jobs/42',
  2189. });
  2190. mockApi.getSliceJob.mockResolvedValue({
  2191. job_id: 42,
  2192. status: 'running',
  2193. kind: 'library_file',
  2194. source_id: 100,
  2195. source_name: 'Plate.3mf',
  2196. created_at: new Date().toISOString(),
  2197. started_at: null,
  2198. completed_at: null,
  2199. });
  2200. });
  2201. // One PLA plate slot — the case the report is about.
  2202. function plaPlate() {
  2203. return {
  2204. file_id: 100,
  2205. filename: 'Plate.3mf',
  2206. plate_id: 1,
  2207. filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, used_meters: 3 }],
  2208. };
  2209. }
  2210. function presetsFor(printer: string, filaments: UnifiedPresetsResponse['standard']['filament']) {
  2211. return makeUnified({
  2212. standard: {
  2213. printer: [{ id: printer, name: printer, source: 'standard' }],
  2214. process: [
  2215. {
  2216. id: '0.20mm Standard @BBL X1C',
  2217. name: '0.20mm Standard @BBL X1C',
  2218. source: 'standard',
  2219. compatible_printers: [P1S, 'Bambu Lab X1 Carbon 0.4 nozzle'],
  2220. },
  2221. ],
  2222. filament: filaments,
  2223. },
  2224. });
  2225. }
  2226. it('does not auto-pick a stated PETG for a PLA plate', async () => {
  2227. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(plaPlate());
  2228. mockApi.getSlicerPresets.mockResolvedValue(
  2229. presetsFor(A1, [
  2230. { id: 'petg', name: 'eSUN PETG Basic @BBL A1', source: 'standard', filament_type: 'PETG', filament_colour: '#FF0000' },
  2231. { id: 'pla', name: 'Bambu PLA Basic @BBL A1', source: 'standard', filament_type: 'PLA', filament_colour: '#FFFFFF' },
  2232. ]),
  2233. );
  2234. renderWithTracker({
  2235. source: { kind: 'libraryFile', id: 100, filename: 'Plate.3mf' },
  2236. onClose: vi.fn(),
  2237. });
  2238. await waitFor(() => expect(screen.getByText(A1)).toBeDefined());
  2239. const user = userEvent.setup();
  2240. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  2241. await waitFor(() => {
  2242. const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
  2243. expect(body.filament_presets).toEqual([{ source: 'standard', id: 'pla' }]);
  2244. });
  2245. });
  2246. it('drops a wrong-material pick once the listing learns the material', async () => {
  2247. // The sequence a user upgrading their sidecar actually goes through. The
  2248. // first listing reports no material for anything, so the pre-pick has
  2249. // only colour to go on and lands on the PETG. The second reports the
  2250. // materials — and the wrong pick has to go, which it did not, because the
  2251. // slot was held on printer-compatibility alone.
  2252. const colourless = presetsFor(A1, [
  2253. { id: 'petg', name: 'eSUN PETG Basic @BBL A1', source: 'standard', filament_type: null, filament_colour: '#FF0000' },
  2254. { id: 'pla', name: 'Bambu PLA Basic @BBL A1', source: 'standard', filament_type: null, filament_colour: '#FFFFFF' },
  2255. ]);
  2256. const typed = presetsFor(A1, [
  2257. { id: 'petg', name: 'eSUN PETG Basic @BBL A1', source: 'standard', filament_type: 'PETG', filament_colour: '#FF0000' },
  2258. { id: 'pla', name: 'Bambu PLA Basic @BBL A1', source: 'standard', filament_type: 'PLA', filament_colour: '#FFFFFF' },
  2259. ]);
  2260. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(plaPlate());
  2261. mockApi.getSlicerPresets.mockResolvedValueOnce(colourless).mockResolvedValue(typed);
  2262. renderWithTracker({
  2263. source: { kind: 'libraryFile', id: 100, filename: 'Plate.3mf' },
  2264. onClose: vi.fn(),
  2265. });
  2266. await waitFor(() => expect(screen.getByText(A1)).toBeDefined());
  2267. const user = userEvent.setup();
  2268. // The colour-matched PETG is what the first listing produces.
  2269. await waitFor(() => {
  2270. const select = presetSelects().find((el) => el.value.startsWith('standard:p'));
  2271. expect(select?.value).toBe('standard:petg');
  2272. });
  2273. await user.click(screen.getByRole('button', { name: 'Refresh' }));
  2274. await waitFor(() => {
  2275. const select = presetSelects().find((el) => el.value.startsWith('standard:p'));
  2276. expect(select?.value).toBe('standard:pla');
  2277. });
  2278. });
  2279. it('keeps a wrong-material preset the user picked on purpose', async () => {
  2280. // Printing PETG on a plate a designer labelled PLA is a legitimate thing
  2281. // to do. The material rule corrects the auto-pick; it must not overrule
  2282. // a choice made in the dropdown.
  2283. const pla = { id: 'pla', name: 'Bambu PLA Basic @BBL A1', source: 'standard' as const, filament_type: 'PLA', filament_colour: '#FF0000' };
  2284. const petg = { id: 'petg', name: 'eSUN PETG Basic @BBL A1', source: 'standard' as const, filament_type: 'PETG', filament_colour: '#FFFFFF' };
  2285. const matte = { id: 'matte', name: 'Bambu PLA Matte @BBL A1', source: 'standard' as const, filament_type: 'PLA', filament_colour: '#00FF00' };
  2286. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(plaPlate());
  2287. // The refresh has to return a *different* listing, or React Query's
  2288. // structural sharing hands back the same object and the pre-pick never
  2289. // re-runs — which would make this test pass without proving anything.
  2290. mockApi.getSlicerPresets
  2291. .mockResolvedValueOnce(presetsFor(A1, [pla, petg]))
  2292. .mockResolvedValue(presetsFor(A1, [pla, petg, matte]));
  2293. renderWithTracker({
  2294. source: { kind: 'libraryFile', id: 100, filename: 'Plate.3mf' },
  2295. onClose: vi.fn(),
  2296. });
  2297. await waitFor(() => expect(screen.getByText(A1)).toBeDefined());
  2298. const user = userEvent.setup();
  2299. const filamentSelect = presetSelects().find((el) =>
  2300. Array.from(el.options).some((o) => o.textContent?.includes('eSUN PETG Basic')),
  2301. );
  2302. expect(filamentSelect).toBeDefined();
  2303. await user.selectOptions(filamentSelect!, 'standard:petg');
  2304. // Refreshing re-runs the pre-pick over the same slots. That is exactly
  2305. // where an un-exempted material rule would quietly undo the choice.
  2306. await user.click(screen.getByRole('button', { name: 'Refresh' }));
  2307. await waitFor(() => {
  2308. const select = presetSelects().find((el) => el.value.startsWith('standard:p'));
  2309. expect(select?.value).toBe('standard:petg');
  2310. });
  2311. await user.click(screen.getByRole('button', { name: /^Slice$/ }));
  2312. await waitFor(() => {
  2313. const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
  2314. expect(body.filament_presets).toEqual([{ source: 'standard', id: 'petg' }]);
  2315. });
  2316. });
  2317. it('shows every process when the printer filter would leave the list empty', async () => {
  2318. // A P1S against a sidecar too old to report compatible_printers: all 198
  2319. // processes read as another printer's, so the dropdown held one
  2320. // auto-picked entry and a "Show all" link, with nothing saying the list
  2321. // itself was the problem.
  2322. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(plaPlate());
  2323. mockApi.getSlicerPresets.mockResolvedValue(
  2324. makeUnified({
  2325. standard: {
  2326. printer: [{ id: P1S, name: P1S, source: 'standard' }],
  2327. process: [
  2328. { id: 'x1c', name: '0.20mm Standard @BBL X1C', source: 'standard' },
  2329. { id: 'a1', name: '0.06mm Fine @BBL A1 0.2 nozzle', source: 'standard' },
  2330. ],
  2331. filament: [
  2332. { id: 'pla', name: 'Bambu PLA Basic @BBL A1', source: 'standard', filament_type: 'PLA' },
  2333. ],
  2334. },
  2335. }),
  2336. );
  2337. renderWithTracker({
  2338. source: { kind: 'libraryFile', id: 100, filename: 'Plate.3mf' },
  2339. onClose: vi.fn(),
  2340. });
  2341. await waitFor(() => expect(screen.getByText(P1S)).toBeDefined());
  2342. const processSelect = presetSelects().find((el) =>
  2343. Array.from(el.options).some((o) => o.textContent?.includes('0.20mm Standard @BBL X1C')),
  2344. );
  2345. expect(processSelect).toBeDefined();
  2346. // Both are visible rather than one hidden behind "Show all".
  2347. const labels = Array.from(processSelect!.options).map((o) => o.textContent);
  2348. expect(labels.some((l) => l?.includes('0.20mm Standard @BBL X1C'))).toBe(true);
  2349. expect(labels.some((l) => l?.includes('0.06mm Fine @BBL A1 0.2 nozzle'))).toBe(true);
  2350. });
  2351. it('still hides other-printer presets when some do match', async () => {
  2352. // The guard is for an empty list only — the normal filter must survive.
  2353. mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(plaPlate());
  2354. mockApi.getSlicerPresets.mockResolvedValue(
  2355. makeUnified({
  2356. standard: {
  2357. printer: [{ id: P1S, name: P1S, source: 'standard' }],
  2358. process: [
  2359. {
  2360. id: 'x1c',
  2361. name: '0.20mm Standard @BBL X1C',
  2362. source: 'standard',
  2363. compatible_printers: [P1S],
  2364. },
  2365. { id: 'a1', name: '0.06mm Fine @BBL A1 0.2 nozzle', source: 'standard' },
  2366. ],
  2367. filament: [
  2368. { id: 'pla', name: 'Bambu PLA Basic @BBL A1', source: 'standard', filament_type: 'PLA' },
  2369. ],
  2370. },
  2371. }),
  2372. );
  2373. renderWithTracker({
  2374. source: { kind: 'libraryFile', id: 100, filename: 'Plate.3mf' },
  2375. onClose: vi.fn(),
  2376. });
  2377. await waitFor(() => expect(screen.getByText(P1S)).toBeDefined());
  2378. const processSelect = presetSelects().find((el) =>
  2379. Array.from(el.options).some((o) => o.textContent?.includes('0.20mm Standard @BBL X1C')),
  2380. );
  2381. const labels = Array.from(processSelect!.options).map((o) => o.textContent);
  2382. expect(labels.some((l) => l?.includes('0.06mm Fine @BBL A1 0.2 nozzle'))).toBe(false);
  2383. });
  2384. });