slicePresetPicker2982.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import { describe, expect, it } from 'vitest';
  2. import type { UnifiedPreset, UnifiedPresetsBySlot, UnifiedPresetsResponse } from '../../api/client';
  3. import {
  4. pickFilamentForSlot,
  5. pickProcessDefault,
  6. statesDifferentMaterial,
  7. } from '../../utils/slicePresetPicker';
  8. import { buildCompatibilityIndex } from '../../utils/slicerPrinterMatch';
  9. // The production registry, trimmed to the models these cases name.
  10. const index = buildCompatibilityIndex({
  11. 'Bambu Lab X1 Carbon': 'X1C',
  12. 'Bambu Lab P1S': 'P1S',
  13. 'Bambu Lab A1': 'A1',
  14. 'Bambu Lab A1 mini': 'A1 Mini',
  15. });
  16. const P1S = 'Bambu Lab P1S 0.4 nozzle';
  17. const X1C = 'Bambu Lab X1 Carbon 0.4 nozzle';
  18. const A1 = 'Bambu Lab A1 0.4 nozzle';
  19. function empty(): UnifiedPresetsBySlot {
  20. return { printer: [], process: [], filament: [] };
  21. }
  22. function unified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
  23. return {
  24. orca_cloud: empty(),
  25. cloud: empty(),
  26. local: empty(),
  27. standard: empty(),
  28. cloud_status: 'ok',
  29. orca_cloud_status: 'ok',
  30. ...overrides,
  31. };
  32. }
  33. function standard(slot: 'process' | 'filament', entries: Partial<UnifiedPreset>[]): UnifiedPresetsResponse {
  34. const list = entries.map((e) => ({
  35. id: e.name as string,
  36. source: 'standard' as const,
  37. ...e,
  38. })) as UnifiedPreset[];
  39. return unified({ standard: { ...empty(), [slot]: list } });
  40. }
  41. describe('statesDifferentMaterial', () => {
  42. it('is true only when both sides state a material and they differ', () => {
  43. expect(statesDifferentMaterial({ filament_type: 'PETG' }, 'PLA')).toBe(true);
  44. });
  45. it('is false for the same material, whatever the casing or padding', () => {
  46. expect(statesDifferentMaterial({ filament_type: ' pla ' }, 'PLA')).toBe(false);
  47. });
  48. it('is false when the preset states no material', () => {
  49. // 32 of the shipped BBL filament profiles inherit from a parent the bundle
  50. // does not contain, so their material genuinely cannot be resolved.
  51. // "Unknown" has to stay eligible or those slots get nothing.
  52. expect(statesDifferentMaterial({ filament_type: null }, 'PLA')).toBe(false);
  53. expect(statesDifferentMaterial({ filament_type: '' }, 'PLA')).toBe(false);
  54. });
  55. it('is false when the plate asks for no particular material', () => {
  56. expect(statesDifferentMaterial({ filament_type: 'PETG' }, '')).toBe(false);
  57. });
  58. });
  59. describe('pickFilamentForSlot — material is a hard partition (#2982)', () => {
  60. it('never picks a stated PETG for a PLA plate, however well the colour fits', () => {
  61. // The reported symptom, in the state the fixed sidecar produces: with a
  62. // material on both presets the +10 type bonus decides it. What made the
  63. // PETG win was the standard tier reporting a null material for all 1156
  64. // presets it listed, which left colour and tier the only signals in play.
  65. const presets = standard('filament', [
  66. { name: 'eSUN PETG Basic @BBL A1', filament_type: 'PETG', filament_colour: '#FF0000' },
  67. { name: 'Bambu PLA Basic @BBL A1', filament_type: 'PLA', filament_colour: '#FFFFFF' },
  68. ]);
  69. const pick = pickFilamentForSlot(presets, { type: 'PLA', color: '#FF0000' }, A1, index);
  70. expect(pick).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL A1' });
  71. });
  72. it('prefers the right material over a higher tier offering the wrong one', () => {
  73. const presets = unified({
  74. local: {
  75. ...empty(),
  76. filament: [
  77. { id: 'my-petg', name: 'My PETG @BBL A1', source: 'local', filament_type: 'PETG', filament_colour: '#FF0000' },
  78. ],
  79. },
  80. standard: {
  81. ...empty(),
  82. filament: [
  83. { id: 'Bambu PLA Basic @BBL A1', name: 'Bambu PLA Basic @BBL A1', source: 'standard', filament_type: 'PLA', filament_colour: '#FF0000' },
  84. ],
  85. },
  86. });
  87. const pick = pickFilamentForSlot(presets, { type: 'PLA', color: '#FF0000' }, A1, index);
  88. expect(pick).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL A1' });
  89. });
  90. it('still picks a preset that states no material at all', () => {
  91. // Ordering that only the hard partition produces: on score alone the PETG
  92. // wins here, because an unknown material earns no type bonus either.
  93. const presets = standard('filament', [
  94. { name: 'Mystery @BBL A1', filament_type: null },
  95. { name: 'eSUN PETG Basic @BBL A1', filament_type: 'PETG', filament_colour: '#FFFFFF' },
  96. ]);
  97. const pick = pickFilamentForSlot(presets, { type: 'PLA', color: '#FFFFFF' }, A1, index);
  98. expect(pick).toEqual({ source: 'standard', id: 'Mystery @BBL A1' });
  99. });
  100. it('prefers the right material on the right printer over the right material on the wrong one', () => {
  101. const presets = standard('filament', [
  102. { name: 'Bambu PLA Basic @BBL X1C', filament_type: 'PLA', filament_colour: '#FF0000' },
  103. { name: 'Bambu PLA Basic @BBL A1', filament_type: 'PLA', filament_colour: '#FFFFFF' },
  104. ]);
  105. const pick = pickFilamentForSlot(presets, { type: 'PLA', color: '#FF0000' }, A1, index);
  106. expect(pick).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL A1' });
  107. });
  108. it('falls back to a wrong-material preset rather than leaving the slot empty', () => {
  109. // A registry with nothing of the asked-for material still has to fill the
  110. // slot: a visible wrong preset can be changed in the dropdown, a null
  111. // renders as an empty slot with nothing to act on.
  112. const presets = standard('filament', [
  113. { name: 'eSUN PETG Basic @BBL A1', filament_type: 'PETG', filament_colour: '#FFFFFF' },
  114. ]);
  115. const pick = pickFilamentForSlot(presets, { type: 'PLA', color: '#FFFFFF' }, A1, index);
  116. expect(pick).toEqual({ source: 'standard', id: 'eSUN PETG Basic @BBL A1' });
  117. });
  118. it('prefers a right-material preset for the wrong printer over a wrong-material one for the right printer', () => {
  119. const presets = standard('filament', [
  120. { name: 'Bambu PLA Basic @BBL X1C', filament_type: 'PLA', filament_colour: '#FFFFFF' },
  121. { name: 'eSUN PETG Basic @BBL A1', filament_type: 'PETG', filament_colour: '#FFFFFF' },
  122. ]);
  123. const pick = pickFilamentForSlot(presets, { type: 'PLA', color: '#FFFFFF' }, A1, index);
  124. expect(pick).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL X1C' });
  125. });
  126. it('keeps colour as the tie-breaker within one material', () => {
  127. const presets = standard('filament', [
  128. { name: 'Bambu PLA Matte @BBL A1', filament_type: 'PLA', filament_colour: '#FFFFFF' },
  129. { name: 'Bambu PLA Basic @BBL A1', filament_type: 'PLA', filament_colour: '#FF0000' },
  130. ]);
  131. const pick = pickFilamentForSlot(presets, { type: 'PLA', color: '#FF0000' }, A1, index);
  132. expect(pick).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL A1' });
  133. });
  134. });
  135. describe('pickProcessDefault — compatible_printers reaches the standard tier (#2982)', () => {
  136. // Verbatim from the shipped bundle: every P1S process is named for an X1C
  137. // and names the P1S only here.
  138. const x1cStandard = {
  139. name: '0.20mm Standard @BBL X1C',
  140. compatible_printers: [X1C, 'Bambu Lab X1 0.4 nozzle', P1S],
  141. };
  142. it('picks an X1C-named process for a P1S when it declares the P1S', () => {
  143. const presets = standard('process', [
  144. { name: '0.06mm Fine @BBL A1 0.2 nozzle', compatible_printers: ['Bambu Lab A1 0.2 nozzle'] },
  145. x1cStandard,
  146. ]);
  147. const pick = pickProcessDefault(presets, P1S, index, null);
  148. expect(pick).toEqual({ source: 'standard', id: '0.20mm Standard @BBL X1C' });
  149. });
  150. it('does not offer an A1 process to a P1S', () => {
  151. // What shipped: with no declared list the name matcher read all 198 as
  152. // mismatches, and the fall-through returned the alphabetically first.
  153. const presets = standard('process', [
  154. { name: '0.06mm Fine @BBL A1 0.2 nozzle', compatible_printers: ['Bambu Lab A1 0.2 nozzle'] },
  155. x1cStandard,
  156. ]);
  157. const pick = pickProcessDefault(presets, P1S, index, null);
  158. expect(pick?.id).not.toBe('0.06mm Fine @BBL A1 0.2 nozzle');
  159. });
  160. it('lets a declared list overrule the printer the name implies', () => {
  161. const presets = standard('process', [x1cStandard]);
  162. expect(pickProcessDefault(presets, P1S, index, null)).toEqual({
  163. source: 'standard',
  164. id: '0.20mm Standard @BBL X1C',
  165. });
  166. expect(pickProcessDefault(presets, A1, index, null)).toEqual({
  167. source: 'standard',
  168. id: '0.20mm Standard @BBL X1C',
  169. });
  170. });
  171. });
  172. describe('pickProcessDefault — layer height within equally-valid candidates (#2982)', () => {
  173. const heights = (names: string[]) =>
  174. standard('process', names.map((name) => ({ name, compatible_printers: [X1C] })));
  175. it('prefers 0.20mm Standard over the alphabetically first 0.08mm', () => {
  176. // Tier order says which source to prefer; within a tier the list is
  177. // alphabetical, and Bambu's naming puts the finest — slowest — height
  178. // first. Every X1C slice that did not name its own process silently got
  179. // 0.08mm Extra Fine.
  180. const presets = heights([
  181. '0.08mm Extra Fine @BBL X1C',
  182. '0.12mm Fine @BBL X1C',
  183. '0.20mm Standard @BBL X1C',
  184. '0.28mm Extra Draft @BBL X1C',
  185. ]);
  186. expect(pickProcessDefault(presets, X1C, index, null)?.id).toBe('0.20mm Standard @BBL X1C');
  187. });
  188. it('takes the nearest height when nothing sits exactly at 0.2mm', () => {
  189. const presets = heights(['0.08mm Extra Fine @BBL X1C', '0.16mm Optimal @BBL X1C']);
  190. expect(pickProcessDefault(presets, X1C, index, null)?.id).toBe('0.16mm Optimal @BBL X1C');
  191. });
  192. it('breaks an equal distance toward the coarser height', () => {
  193. // 0.16 and 0.24 are both 0.04 away. The coarser one prints faster, which
  194. // is the friendlier default to be wrong in.
  195. const presets = heights(['0.16mm Optimal @BBL X1C', '0.24mm Draft @BBL X1C']);
  196. expect(pickProcessDefault(presets, X1C, index, null)?.id).toBe('0.24mm Draft @BBL X1C');
  197. });
  198. it('still picks a preset whose name carries no height', () => {
  199. const presets = heights(['My Favourite Process']);
  200. expect(pickProcessDefault(presets, X1C, index, null)?.id).toBe('My Favourite Process');
  201. });
  202. it('prefers a readable height over a name with none', () => {
  203. const presets = heights(['A Nameless Process', '0.20mm Standard @BBL X1C']);
  204. expect(pickProcessDefault(presets, X1C, index, null)?.id).toBe('0.20mm Standard @BBL X1C');
  205. });
  206. it('does not let layer height override tier order', () => {
  207. const presets = unified({
  208. local: {
  209. ...empty(),
  210. process: [
  211. { id: 'mine', name: '0.08mm Mine @BBL X1C', source: 'local', compatible_printers: [X1C] },
  212. ],
  213. },
  214. standard: {
  215. ...empty(),
  216. process: [
  217. { id: 'std', name: '0.20mm Standard @BBL X1C', source: 'standard', compatible_printers: [X1C] },
  218. ],
  219. },
  220. });
  221. expect(pickProcessDefault(presets, X1C, index, null)).toEqual({ source: 'local', id: 'mine' });
  222. });
  223. it('does not override a process the 3MF named', () => {
  224. const presets = heights(['0.08mm Extra Fine @BBL X1C', '0.20mm Standard @BBL X1C']);
  225. const pick = pickProcessDefault(presets, X1C, index, '0.08mm Extra Fine @BBL X1C');
  226. expect(pick?.id).toBe('0.08mm Extra Fine @BBL X1C');
  227. });
  228. it('does not let a coarse mismatch beat a compatible fine one', () => {
  229. // Compatibility is decided before height is ever consulted.
  230. const presets = standard('process', [
  231. { name: '0.08mm Extra Fine @BBL X1C', compatible_printers: [X1C] },
  232. { name: '0.28mm Extra Draft @BBL A1', compatible_printers: [A1] },
  233. ]);
  234. expect(pickProcessDefault(presets, X1C, index, null)?.id).toBe('0.08mm Extra Fine @BBL X1C');
  235. });
  236. });