SliceModal.test.tsx 82 KB

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