useFilamentMapping.test.ts 50 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399
  1. /**
  2. * Tests for the useFilamentMapping hook and helper functions.
  3. *
  4. * Tests the tray_info_idx matching logic that ensures the exact spool
  5. * selected during slicing is used when multiple trays have identical filament.
  6. */
  7. import { describe, it, expect } from 'vitest';
  8. import { renderHook } from '@testing-library/react';
  9. import {
  10. buildAmsMapping,
  11. buildFilamentComparison,
  12. buildLoadedFilaments,
  13. computeAmsMapping,
  14. useFilamentMapping,
  15. } from '../../hooks/useFilamentMapping';
  16. import { effectivePreferLowest } from '../../utils/amsHelpers';
  17. import type { PrinterStatus } from '../../api/client';
  18. // Helper to create a minimal printer status with AMS data
  19. function createPrinterStatus(ams: PrinterStatus['ams'], vt_tray: PrinterStatus['vt_tray'] = []): PrinterStatus {
  20. return {
  21. ams,
  22. vt_tray,
  23. } as PrinterStatus;
  24. }
  25. describe('buildLoadedFilaments', () => {
  26. it('returns empty array for undefined status', () => {
  27. const result = buildLoadedFilaments(undefined);
  28. expect(result).toEqual([]);
  29. });
  30. it('extracts filaments from AMS units', () => {
  31. const status = createPrinterStatus([
  32. {
  33. id: 0,
  34. tray: [
  35. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
  36. { id: 1, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFA01' },
  37. ],
  38. },
  39. ]);
  40. const result = buildLoadedFilaments(status);
  41. expect(result).toHaveLength(2);
  42. expect(result[0]).toMatchObject({
  43. type: 'PLA',
  44. color: '#FF0000',
  45. amsId: 0,
  46. trayId: 0,
  47. globalTrayId: 0,
  48. trayInfoIdx: 'GFA00',
  49. });
  50. expect(result[1]).toMatchObject({
  51. type: 'PETG',
  52. color: '#00FF00',
  53. globalTrayId: 1,
  54. trayInfoIdx: 'GFA01',
  55. });
  56. });
  57. it('includes tray_info_idx from AMS trays', () => {
  58. const status = createPrinterStatus([
  59. {
  60. id: 0,
  61. tray: [
  62. { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'P4d64437' },
  63. ],
  64. },
  65. ]);
  66. const result = buildLoadedFilaments(status);
  67. expect(result[0].trayInfoIdx).toBe('P4d64437');
  68. });
  69. it('handles missing tray_info_idx', () => {
  70. const status = createPrinterStatus([
  71. {
  72. id: 0,
  73. tray: [
  74. { id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // No tray_info_idx
  75. ],
  76. },
  77. ]);
  78. const result = buildLoadedFilaments(status);
  79. expect(result[0].trayInfoIdx).toBe('');
  80. });
  81. it('includes tray_sub_brands from AMS trays', () => {
  82. const status = createPrinterStatus([
  83. {
  84. id: 0,
  85. tray: [
  86. { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFL99', tray_sub_brands: 'PLA Basic' },
  87. { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFL05', tray_sub_brands: 'PLA Matte' },
  88. ],
  89. },
  90. ]);
  91. const result = buildLoadedFilaments(status);
  92. expect(result[0].traySubBrands).toBe('PLA Basic');
  93. expect(result[1].traySubBrands).toBe('PLA Matte');
  94. });
  95. it('handles missing tray_sub_brands', () => {
  96. const status = createPrinterStatus([
  97. {
  98. id: 0,
  99. tray: [
  100. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
  101. ],
  102. },
  103. ]);
  104. const result = buildLoadedFilaments(status);
  105. expect(result[0].traySubBrands).toBe('');
  106. });
  107. it('includes tray_sub_brands from external spool', () => {
  108. const status = createPrinterStatus(
  109. [],
  110. [{ tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFG00', tray_sub_brands: 'PETG HF' }]
  111. );
  112. const result = buildLoadedFilaments(status);
  113. expect(result[0].traySubBrands).toBe('PETG HF');
  114. });
  115. it('extracts external spool with tray_info_idx', () => {
  116. const status = createPrinterStatus(
  117. [],
  118. [{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }]
  119. );
  120. const result = buildLoadedFilaments(status);
  121. expect(result).toHaveLength(1);
  122. expect(result[0]).toMatchObject({
  123. type: 'TPU',
  124. isExternal: true,
  125. globalTrayId: 254,
  126. trayInfoIdx: 'EXT001',
  127. });
  128. });
  129. it('skips empty trays', () => {
  130. const status = createPrinterStatus([
  131. {
  132. id: 0,
  133. tray: [
  134. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
  135. { id: 1, tray_type: '', tray_color: '' }, // Empty tray
  136. { id: 2 }, // No tray_type
  137. ],
  138. },
  139. ]);
  140. const result = buildLoadedFilaments(status);
  141. expect(result).toHaveLength(1);
  142. expect(result[0].type).toBe('PLA');
  143. });
  144. it('marks AMS-HT units correctly', () => {
  145. const status = createPrinterStatus([
  146. {
  147. id: 128, // AMS-HT typically has high ID
  148. tray: [
  149. { id: 0, tray_type: 'PLA-CF', tray_color: '000000', tray_info_idx: 'HT001' },
  150. ], // Single tray = AMS-HT
  151. },
  152. ]);
  153. const result = buildLoadedFilaments(status);
  154. expect(result[0].isHt).toBe(true);
  155. expect(result[0].globalTrayId).toBe(128); // AMS-HT uses ams_id directly
  156. });
  157. });
  158. describe('computeAmsMapping', () => {
  159. it('returns undefined for empty filament requirements', () => {
  160. const status = createPrinterStatus([
  161. {
  162. id: 0,
  163. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  164. },
  165. ]);
  166. expect(computeAmsMapping(undefined, status)).toBeUndefined();
  167. expect(computeAmsMapping({ filaments: [] }, status)).toBeUndefined();
  168. });
  169. it('returns undefined when no filaments loaded', () => {
  170. const reqs = {
  171. filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
  172. };
  173. expect(computeAmsMapping(reqs, undefined)).toBeUndefined();
  174. expect(computeAmsMapping(reqs, createPrinterStatus([]))).toBeUndefined();
  175. });
  176. it('matches by tray_info_idx with highest priority', () => {
  177. const reqs = {
  178. filaments: [
  179. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA01' },
  180. ],
  181. };
  182. const status = createPrinterStatus([
  183. {
  184. id: 0,
  185. tray: [
  186. { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' }, // Same color, wrong idx
  187. { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' }, // Exact idx match
  188. { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' }, // Same color, wrong idx
  189. ],
  190. },
  191. ]);
  192. const result = computeAmsMapping(reqs, status);
  193. expect(result).toEqual([1]); // Should pick tray 1, not tray 0
  194. });
  195. it('matches multiple identical filaments by tray_info_idx (H2D Pro scenario)', () => {
  196. // This is the exact scenario from issue #245 - multiple black PLA spools
  197. const reqs = {
  198. filaments: [
  199. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 50, tray_info_idx: 'GFA03' },
  200. ],
  201. };
  202. const status = createPrinterStatus([
  203. {
  204. id: 0,
  205. tray: [
  206. { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
  207. { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
  208. { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
  209. { id: 3, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA03' }, // This one
  210. ],
  211. },
  212. ]);
  213. const result = computeAmsMapping(reqs, status);
  214. expect(result).toEqual([3]); // Should pick tray 3, not tray 0
  215. });
  216. it('falls back to color match when tray_info_idx is empty', () => {
  217. const reqs = {
  218. filaments: [
  219. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: '' },
  220. ],
  221. };
  222. const status = createPrinterStatus([
  223. {
  224. id: 0,
  225. tray: [
  226. { id: 0, tray_type: 'PLA', tray_color: '00FF00', tray_info_idx: 'GFA00' }, // Wrong color
  227. { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA01' }, // Color match
  228. ],
  229. },
  230. ]);
  231. const result = computeAmsMapping(reqs, status);
  232. expect(result).toEqual([1]);
  233. });
  234. it('falls back to color match when tray_info_idx does not match', () => {
  235. const reqs = {
  236. filaments: [
  237. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'OLD_SPOOL' },
  238. ],
  239. };
  240. const status = createPrinterStatus([
  241. {
  242. id: 0,
  243. tray: [
  244. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'NEW_SPOOL' }, // Different idx, same color
  245. ],
  246. },
  247. ]);
  248. const result = computeAmsMapping(reqs, status);
  249. expect(result).toEqual([0]); // Falls back to color match
  250. });
  251. it('matches by type only when color differs', () => {
  252. const reqs = {
  253. filaments: [
  254. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
  255. ],
  256. };
  257. const status = createPrinterStatus([
  258. {
  259. id: 0,
  260. tray: [
  261. { id: 0, tray_type: 'PLA', tray_color: '0000FF' }, // Same type, different color
  262. ],
  263. },
  264. ]);
  265. const result = computeAmsMapping(reqs, status);
  266. expect(result).toEqual([0]); // Type-only match
  267. });
  268. it('returns -1 for unmatched slots', () => {
  269. const reqs = {
  270. filaments: [
  271. { slot_id: 1, type: 'TPU', color: '#FF0000', used_grams: 10 }, // No TPU loaded
  272. ],
  273. };
  274. const status = createPrinterStatus([
  275. {
  276. id: 0,
  277. tray: [
  278. { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
  279. ],
  280. },
  281. ]);
  282. const result = computeAmsMapping(reqs, status);
  283. expect(result).toEqual([-1]);
  284. });
  285. it('avoids duplicate tray assignment', () => {
  286. const reqs = {
  287. filaments: [
  288. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
  289. { slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 }, // Same requirements
  290. ],
  291. };
  292. const status = createPrinterStatus([
  293. {
  294. id: 0,
  295. tray: [
  296. { id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // Only one PLA
  297. ],
  298. },
  299. ]);
  300. const result = computeAmsMapping(reqs, status);
  301. expect(result).toEqual([0, -1]); // First slot gets the match, second is unmatched
  302. });
  303. it('handles multi-slot mapping with tray_info_idx', () => {
  304. const reqs = {
  305. filaments: [
  306. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA00' },
  307. { slot_id: 2, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA02' },
  308. ],
  309. };
  310. const status = createPrinterStatus([
  311. {
  312. id: 0,
  313. tray: [
  314. { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
  315. { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
  316. { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
  317. ],
  318. },
  319. ]);
  320. const result = computeAmsMapping(reqs, status);
  321. expect(result).toEqual([0, 2]); // Each slot gets its specific tray
  322. });
  323. it('handles external spool matching', () => {
  324. const reqs = {
  325. filaments: [
  326. { slot_id: 1, type: 'TPU', color: '#0000FF', used_grams: 10, tray_info_idx: 'EXT001' },
  327. ],
  328. };
  329. const status = createPrinterStatus(
  330. [],
  331. [{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }]
  332. );
  333. const result = computeAmsMapping(reqs, status);
  334. expect(result).toEqual([254]); // External spool global ID
  335. });
  336. });
  337. describe('buildLoadedFilaments - nozzle awareness', () => {
  338. it('sets extruderId from ams_extruder_map', () => {
  339. const status = createPrinterStatus([
  340. {
  341. id: 0,
  342. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  343. },
  344. {
  345. id: 1,
  346. tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
  347. },
  348. ]);
  349. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  350. const result = buildLoadedFilaments(status);
  351. expect(result[0].extruderId).toBe(1); // AMS 0 → left nozzle
  352. expect(result[1].extruderId).toBe(0); // AMS 1 → right nozzle
  353. });
  354. it('leaves extruderId undefined when no ams_extruder_map', () => {
  355. const status = createPrinterStatus([
  356. {
  357. id: 0,
  358. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  359. },
  360. ]);
  361. const result = buildLoadedFilaments(status);
  362. expect(result[0].extruderId).toBeUndefined();
  363. });
  364. });
  365. describe('computeAmsMapping - nozzle filtering', () => {
  366. it('filters candidates by nozzle_id when set', () => {
  367. // Filament requires left nozzle (extruder 1), only AMS 0 is on left
  368. const reqs = {
  369. filaments: [
  370. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
  371. ],
  372. };
  373. const status = createPrinterStatus([
  374. {
  375. id: 0, // Left nozzle
  376. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  377. },
  378. {
  379. id: 1, // Right nozzle
  380. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  381. },
  382. ]);
  383. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  384. const result = computeAmsMapping(reqs, status);
  385. expect(result).toEqual([0]); // AMS 0, tray 0 (on left nozzle)
  386. });
  387. it('filters to right nozzle when nozzle_id=0', () => {
  388. const reqs = {
  389. filaments: [
  390. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 0 },
  391. ],
  392. };
  393. const status = createPrinterStatus([
  394. {
  395. id: 0, // Left nozzle
  396. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  397. },
  398. {
  399. id: 1, // Right nozzle
  400. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  401. },
  402. ]);
  403. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  404. const result = computeAmsMapping(reqs, status);
  405. expect(result).toEqual([4]); // AMS 1, tray 0 (global ID = 1*4+0 = 4, on right nozzle)
  406. });
  407. it('returns -1 when target nozzle has no trays (hard filter)', () => {
  408. // Requires nozzle_id=1 (left), but no AMS units are on left nozzle
  409. // Hard filter: cross-nozzle assignment causes "position of left hotend is abnormal"
  410. const reqs = {
  411. filaments: [
  412. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
  413. ],
  414. };
  415. const status = createPrinterStatus([
  416. {
  417. id: 0, // Right nozzle only
  418. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  419. },
  420. ]);
  421. (status as any).ams_extruder_map = { '0': 0 }; // AMS 0 → right nozzle, none on left
  422. const result = computeAmsMapping(reqs, status);
  423. expect(result).toEqual([-1]); // Hard filter: no fallback to wrong nozzle
  424. });
  425. it('stays restricted when target nozzle has trays but wrong type', () => {
  426. // Left nozzle has PETG, right has PLA — but requires PLA on left
  427. const reqs = {
  428. filaments: [
  429. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
  430. ],
  431. };
  432. const status = createPrinterStatus([
  433. {
  434. id: 0, // Left nozzle - only PETG
  435. tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
  436. },
  437. {
  438. id: 1, // Right nozzle - has PLA
  439. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  440. },
  441. ]);
  442. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  443. const result = computeAmsMapping(reqs, status);
  444. expect(result).toEqual([-1]); // No PLA on left nozzle, stays restricted
  445. });
  446. it('skips nozzle filtering when nozzle_id is undefined', () => {
  447. const reqs = {
  448. filaments: [
  449. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }, // No nozzle_id
  450. ],
  451. };
  452. const status = createPrinterStatus([
  453. {
  454. id: 0,
  455. tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
  456. },
  457. {
  458. id: 1,
  459. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  460. },
  461. ]);
  462. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  463. const result = computeAmsMapping(reqs, status);
  464. expect(result).toEqual([4]); // Picks best match regardless of nozzle
  465. });
  466. it('handles dual-nozzle multi-slot mapping', () => {
  467. // Two filaments: one for left, one for right
  468. const reqs = {
  469. filaments: [
  470. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 }, // Left
  471. { slot_id: 2, type: 'PETG', color: '#00FF00', used_grams: 10, nozzle_id: 0 }, // Right
  472. ],
  473. };
  474. const status = createPrinterStatus([
  475. {
  476. id: 0, // Left nozzle
  477. tray: [
  478. { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
  479. ],
  480. },
  481. {
  482. id: 1, // Right nozzle
  483. tray: [
  484. { id: 0, tray_type: 'PETG', tray_color: '00FF00' },
  485. ],
  486. },
  487. ]);
  488. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  489. const result = computeAmsMapping(reqs, status);
  490. expect(result).toEqual([0, 4]); // Left gets AMS0-T0, Right gets AMS1-T0
  491. });
  492. // FTS (Filament Track Switch) — when present, AMS slots aren't tied to a
  493. // specific extruder. The track switch routes any slot to either extruder, so
  494. // the per-nozzle hard filter must NOT apply. See #1162.
  495. it('ignores nozzle_id when FTS is installed', () => {
  496. // Required filament asks for nozzle 1 (left). Without FTS this would force
  497. // AMS 0 (which is on the left nozzle). With FTS we accept any AMS slot
  498. // matching by type/color since the FTS routes it to whichever extruder.
  499. const reqs = {
  500. filaments: [
  501. { slot_id: 1, type: 'PETG', color: '#00FF00', used_grams: 10, nozzle_id: 1 },
  502. ],
  503. };
  504. const status = createPrinterStatus([
  505. {
  506. id: 0, // Without FTS, this AMS would be left/extruder 1; ams_extruder_map
  507. // is empty because the printer reports info bits 8-11 = 0xE.
  508. tray: [
  509. { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
  510. { id: 1, tray_type: 'PETG', tray_color: '00FF00' },
  511. ],
  512. },
  513. ]);
  514. (status as any).ams_extruder_map = {};
  515. (status as any).fila_switch = {
  516. installed: true,
  517. in_slots: [-1, 1],
  518. out_extruders: [0, 1],
  519. stat: 0,
  520. info: 2,
  521. };
  522. const result = computeAmsMapping(reqs, status);
  523. expect(result).toEqual([1]); // Picks AMS 0 tray 1 (PETG green) regardless of nozzle
  524. });
  525. // X2D / H2D / X2 Pro with no AMS but two external spools (one feeding each
  526. // extruder). Pre-fix, dual-nozzle was inferred from `ams_extruder_map` being
  527. // non-empty, which fails when there are no AMS units — both vt_tray entries
  528. // got `extruderId=undefined`, the per-nozzle filter rejected everything, and
  529. // the UI surfaced "Required filament type not found in printer" even though
  530. // the matching filament was sitting in the external spool. (#1257)
  531. it('matches external spools per-extruder on dual-nozzle without AMS', () => {
  532. const reqs = {
  533. filaments: [
  534. { slot_id: 1, type: 'PETG', color: '#FFFFFF', used_grams: 15, nozzle_id: 1 }, // Left
  535. ],
  536. };
  537. const status = createPrinterStatus([], [
  538. // Two external spools, both PETG. Ext-L (id=254) feeds left extruder (1),
  539. // Ext-R (id=255) feeds right (0). 255 - id formula in buildLoadedFilaments
  540. // routes them when hasDualNozzle is true.
  541. { id: 254, tray_type: 'PETG', tray_color: 'FFFFFF' } as PrinterStatus['vt_tray'][number],
  542. { id: 255, tray_type: 'PETG', tray_color: '000000' } as PrinterStatus['vt_tray'][number],
  543. ]);
  544. // Real X2D hardware: both nozzles report a populated diameter via the
  545. // MQTT right_nozzle_diameter / left_nozzle_diameter fields. ams_extruder_map
  546. // is empty because there are zero AMS units.
  547. (status as any).nozzles = [
  548. { nozzle_type: 'stainless_steel', nozzle_diameter: '0.4' },
  549. { nozzle_type: 'stainless_steel', nozzle_diameter: '0.4' },
  550. ];
  551. (status as any).ams_extruder_map = {};
  552. // Loaded filaments must surface extruderId on each external entry,
  553. // otherwise computeAmsMapping's per-nozzle filter strips them out.
  554. const loaded = buildLoadedFilaments(status);
  555. expect(loaded).toHaveLength(2);
  556. expect(loaded.find((f) => f.globalTrayId === 254)?.extruderId).toBe(1); // Ext-L → left
  557. expect(loaded.find((f) => f.globalTrayId === 255)?.extruderId).toBe(0); // Ext-R → right
  558. // Mapping must succeed and pick Ext-L (left extruder, white PETG).
  559. const result = computeAmsMapping(reqs, status);
  560. expect(result).toEqual([254]);
  561. });
  562. // Sibling regression: the bambu_mqtt state defaults `nozzles` to a 2-entry
  563. // list with empty NozzleInfo() stubs even on single-nozzle printers, and the
  564. // route emits both entries on the wire. The dual-nozzle inference must NOT
  565. // be tripped by a stub second entry — only by populated hardware info,
  566. // populated ams_extruder_map, or >1 external trays. Pin: single-nozzle
  567. // printer (P1S/A1/X1C) with one external spool gets extruderId=undefined,
  568. // matching pre-fix behaviour. (#1257)
  569. it('does not fabricate extruderId for single-nozzle with stub nozzles[1]', () => {
  570. const status = createPrinterStatus([], [
  571. { id: 254, tray_type: 'PLA', tray_color: 'FF0000' } as PrinterStatus['vt_tray'][number],
  572. ]);
  573. // Single-nozzle: nozzles[1] is the default stub (empty fields).
  574. (status as any).nozzles = [
  575. { nozzle_type: 'stainless_steel', nozzle_diameter: '0.4' },
  576. { nozzle_type: '', nozzle_diameter: '' },
  577. ];
  578. (status as any).ams_extruder_map = {};
  579. const loaded = buildLoadedFilaments(status);
  580. expect(loaded).toHaveLength(1);
  581. expect(loaded[0].extruderId).toBeUndefined();
  582. });
  583. it('still applies nozzle filter when FTS object is null', () => {
  584. // Sanity check: explicit null fila_switch behaves like no FTS — nozzle
  585. // filter still applies on real dual-nozzle printers.
  586. const reqs = {
  587. filaments: [
  588. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
  589. ],
  590. };
  591. const status = createPrinterStatus([
  592. { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }] },
  593. { id: 1, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }] },
  594. ]);
  595. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  596. (status as any).fila_switch = null;
  597. const result = computeAmsMapping(reqs, status);
  598. expect(result).toEqual([0]); // AMS 0 (left/extruder 1)
  599. });
  600. });
  601. // ============================================================================
  602. // MODEL-SPECIFIC TESTS: Real data from actual printers
  603. // ============================================================================
  604. /**
  605. * H2D real data fixture (from live API response 2026-02-18).
  606. *
  607. * Configuration:
  608. * LEFT nozzle (extruder 1): AMS 0 (4-slot), AMS 2 (4-slot)
  609. * RIGHT nozzle (extruder 0): AMS 1 (4-slot), AMS-HT 128 (1-slot, empty)
  610. * External: 254 (Ext-L, LEFT nozzle), 255 (Ext-R, RIGHT nozzle)
  611. *
  612. * ams_extruder_map: {"0": 1, "1": 0, "2": 1, "128": 0}
  613. */
  614. function createH2DStatus(): PrinterStatus {
  615. const status = createPrinterStatus(
  616. [
  617. {
  618. id: 0, // LEFT nozzle (extruder 1)
  619. humidity: 24,
  620. temp: 21.4,
  621. tray: [
  622. { id: 0, tray_type: 'PETG', tray_color: 'FFFFFFFF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
  623. { id: 1, tray_type: 'PLA', tray_color: 'C8C8C8FF', tray_info_idx: 'GFA06', tray_sub_brands: 'PLA Silk+' },
  624. { id: 2, tray_type: 'PETG', tray_color: '875718FF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
  625. { id: 3, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' },
  626. ],
  627. },
  628. {
  629. id: 1, // RIGHT nozzle (extruder 0)
  630. humidity: 25,
  631. temp: 21.7,
  632. tray: [
  633. { id: 0, tray_type: 'PLA', tray_color: 'FFFFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' },
  634. { id: 1, tray_type: 'PETG', tray_color: '000000FF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
  635. { id: 2, tray_type: 'PLA', tray_color: '5F6367FF', tray_info_idx: 'GFA06', tray_sub_brands: 'PLA Silk+' },
  636. { id: 3, tray_type: 'PLA', tray_color: 'B39B84FF', tray_info_idx: 'GFA02', tray_sub_brands: 'PLA Metal' },
  637. ],
  638. },
  639. {
  640. id: 128, // AMS-HT, RIGHT nozzle (extruder 0) — empty
  641. humidity: 48,
  642. temp: 21.4,
  643. tray: [
  644. { id: 0 }, // empty tray
  645. ],
  646. },
  647. {
  648. id: 2, // LEFT nozzle (extruder 1)
  649. humidity: 18,
  650. temp: 24.0,
  651. tray: [
  652. { id: 0, tray_type: 'PLA-S', tray_color: 'FFFFFFFF', tray_info_idx: 'P8aa1726' },
  653. { id: 1, tray_type: 'PLA', tray_color: '56B7E6FF', tray_info_idx: 'PFUS9924' },
  654. { id: 2, tray_type: 'PETG', tray_color: '6EE53CFF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
  655. { id: 3, tray_type: 'PLA', tray_color: 'FF0000FF', tray_info_idx: 'PFUS9ac9' },
  656. ],
  657. },
  658. ],
  659. [
  660. { id: 254, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'P4d64437' }, // Ext-L (loaded)
  661. { id: 255, tray_type: '', tray_color: '00000000' }, // Ext-R (empty)
  662. ]
  663. );
  664. (status as any).ams_extruder_map = { '0': 1, '1': 0, '2': 1, '128': 0 };
  665. return status;
  666. }
  667. /**
  668. * X1C real data fixture (from live API response 2026-02-18).
  669. *
  670. * Configuration:
  671. * Single nozzle (extruder 0): AMS 0 (4-slot), AMS 1 (4-slot)
  672. * External: 254 (single)
  673. *
  674. * ams_extruder_map: {"0": 0, "1": 0} ← NOT empty, all on extruder 0
  675. */
  676. function createX1CStatus(): PrinterStatus {
  677. const status = createPrinterStatus(
  678. [
  679. {
  680. id: 0,
  681. humidity: 23,
  682. temp: 26.1,
  683. tray: [
  684. { id: 0 }, // empty (has tray_color but no tray_type)
  685. { id: 1 }, // empty
  686. { id: 2 }, // empty (has tray_color FFFFFFFF but no tray_type)
  687. { id: 3 }, // empty
  688. ],
  689. },
  690. {
  691. id: 1,
  692. humidity: 20,
  693. temp: 25.9,
  694. tray: [
  695. { id: 0 }, // empty
  696. { id: 1, tray_type: 'PLA', tray_color: 'EBCFA6FF', tray_info_idx: 'PFUS22b2' },
  697. { id: 2, tray_type: 'PLA', tray_color: 'FCECD6FF', tray_info_idx: 'P4d64437' },
  698. { id: 3, tray_type: 'PLA', tray_color: '0066FFFF', tray_info_idx: 'P4d64437' },
  699. ],
  700. },
  701. ],
  702. [
  703. { id: 254, tray_type: '', tray_color: '00000000' }, // empty
  704. ]
  705. );
  706. (status as any).ams_extruder_map = { '0': 0, '1': 0 };
  707. return status;
  708. }
  709. describe('H2D model tests (dual nozzle, real data)', () => {
  710. describe('buildLoadedFilaments', () => {
  711. it('assigns correct extruderId to all AMS units', () => {
  712. const result = buildLoadedFilaments(createH2DStatus());
  713. // AMS 0 trays → extruder 1 (LEFT)
  714. const ams0 = result.filter((f) => f.amsId === 0);
  715. expect(ams0).toHaveLength(4);
  716. ams0.forEach((f) => expect(f.extruderId).toBe(1));
  717. // AMS 1 trays → extruder 0 (RIGHT)
  718. const ams1 = result.filter((f) => f.amsId === 1);
  719. expect(ams1).toHaveLength(4);
  720. ams1.forEach((f) => expect(f.extruderId).toBe(0));
  721. // AMS 2 trays → extruder 1 (LEFT)
  722. const ams2 = result.filter((f) => f.amsId === 2);
  723. expect(ams2).toHaveLength(4);
  724. ams2.forEach((f) => expect(f.extruderId).toBe(1));
  725. });
  726. it('computes correct globalTrayId for all AMS types', () => {
  727. const result = buildLoadedFilaments(createH2DStatus());
  728. // Regular AMS: amsId * 4 + trayId
  729. expect(result.find((f) => f.amsId === 0 && f.trayId === 0)?.globalTrayId).toBe(0);
  730. expect(result.find((f) => f.amsId === 0 && f.trayId === 3)?.globalTrayId).toBe(3);
  731. expect(result.find((f) => f.amsId === 1 && f.trayId === 0)?.globalTrayId).toBe(4);
  732. expect(result.find((f) => f.amsId === 1 && f.trayId === 3)?.globalTrayId).toBe(7);
  733. expect(result.find((f) => f.amsId === 2 && f.trayId === 0)?.globalTrayId).toBe(8);
  734. expect(result.find((f) => f.amsId === 2 && f.trayId === 3)?.globalTrayId).toBe(11);
  735. });
  736. it('skips empty AMS-HT tray (no tray_type)', () => {
  737. const result = buildLoadedFilaments(createH2DStatus());
  738. // AMS-HT 128 is empty in real data — should be skipped
  739. const ht = result.filter((f) => f.amsId === 128);
  740. expect(ht).toHaveLength(0);
  741. });
  742. it('includes loaded external spool with correct extruder', () => {
  743. const result = buildLoadedFilaments(createH2DStatus());
  744. const ext = result.filter((f) => f.isExternal);
  745. // Only Ext-L (254) has filament, Ext-R (255) is empty
  746. expect(ext).toHaveLength(1);
  747. expect(ext[0].globalTrayId).toBe(254);
  748. expect(ext[0].type).toBe('PLA');
  749. // Ext-L (254) should be LEFT nozzle (extruder 1)
  750. expect(ext[0].extruderId).toBe(1);
  751. });
  752. it('returns 13 loaded filaments total (12 AMS + 1 external)', () => {
  753. const result = buildLoadedFilaments(createH2DStatus());
  754. // AMS 0: 4, AMS 1: 4, AMS-HT 128: 0 (empty), AMS 2: 4, External: 1
  755. expect(result).toHaveLength(13);
  756. });
  757. });
  758. describe('computeAmsMapping', () => {
  759. it('matches left-nozzle filament to left-nozzle AMS only', () => {
  760. const reqs = {
  761. filaments: [
  762. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, nozzle_id: 1 },
  763. ],
  764. };
  765. const result = computeAmsMapping(reqs, createH2DStatus());
  766. // Black PLA on LEFT: AMS 0 T4 (globalTrayId 3) is PLA Basic black on left
  767. expect(result).toEqual([3]);
  768. });
  769. it('matches right-nozzle filament to right-nozzle AMS only', () => {
  770. const reqs = {
  771. filaments: [
  772. { slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 10, nozzle_id: 0 },
  773. ],
  774. };
  775. const result = computeAmsMapping(reqs, createH2DStatus());
  776. // White PLA on RIGHT: AMS 1 T1 (globalTrayId 4) is PLA Basic white on right
  777. expect(result).toEqual([4]);
  778. });
  779. it('rejects cross-nozzle assignment (right requires type only on left)', () => {
  780. const reqs = {
  781. filaments: [
  782. // PLA-S only exists on AMS 2 T1 (left nozzle), but requires right nozzle
  783. { slot_id: 1, type: 'PLA-S', color: '#FFFFFF', used_grams: 10, nozzle_id: 0, tray_info_idx: 'P8aa1726' },
  784. ],
  785. };
  786. const result = computeAmsMapping(reqs, createH2DStatus());
  787. expect(result).toEqual([-1]); // No fallback to wrong nozzle
  788. });
  789. it('maps dual-nozzle multi-filament print correctly', () => {
  790. const reqs = {
  791. filaments: [
  792. // Slot 1: PETG white on LEFT → AMS 0 T1 (globalTrayId 0)
  793. { slot_id: 1, type: 'PETG', color: '#FFFFFF', used_grams: 30, nozzle_id: 1, tray_info_idx: 'GFG02' },
  794. // Slot 2: PLA white on RIGHT → AMS 1 T1 (globalTrayId 4)
  795. { slot_id: 2, type: 'PLA', color: '#FFFFFF', used_grams: 20, nozzle_id: 0, tray_info_idx: 'GFA00' },
  796. ],
  797. };
  798. const result = computeAmsMapping(reqs, createH2DStatus());
  799. expect(result).toEqual([0, 4]);
  800. });
  801. it('matches external spool on correct nozzle', () => {
  802. const reqs = {
  803. filaments: [
  804. // Ext-L has black PLA loaded, on LEFT nozzle (extruder 1)
  805. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 5, nozzle_id: 1, tray_info_idx: 'P4d64437' },
  806. ],
  807. };
  808. const result = computeAmsMapping(reqs, createH2DStatus());
  809. expect(result).toEqual([254]); // External spool on left nozzle
  810. });
  811. });
  812. });
  813. describe('X1C model tests (single nozzle, real data)', () => {
  814. describe('buildLoadedFilaments', () => {
  815. it('assigns all filaments to extruder 0', () => {
  816. const result = buildLoadedFilaments(createX1CStatus());
  817. result.forEach((f) => expect(f.extruderId).toBe(0));
  818. });
  819. it('computes correct globalTrayId for regular AMS', () => {
  820. const result = buildLoadedFilaments(createX1CStatus());
  821. // AMS 1 T2 (tray id 1) → globalTrayId 5
  822. expect(result.find((f) => f.amsId === 1 && f.trayId === 1)?.globalTrayId).toBe(5);
  823. // AMS 1 T3 (tray id 2) → globalTrayId 6
  824. expect(result.find((f) => f.amsId === 1 && f.trayId === 2)?.globalTrayId).toBe(6);
  825. // AMS 1 T4 (tray id 3) → globalTrayId 7
  826. expect(result.find((f) => f.amsId === 1 && f.trayId === 3)?.globalTrayId).toBe(7);
  827. });
  828. it('returns only loaded trays (3 from AMS 1)', () => {
  829. const result = buildLoadedFilaments(createX1CStatus());
  830. // AMS 0: all 4 slots empty, AMS 1: slots 1-3 loaded, External: empty
  831. expect(result).toHaveLength(3);
  832. });
  833. });
  834. describe('computeAmsMapping', () => {
  835. it('matches single-nozzle file without nozzle filtering', () => {
  836. const reqs = {
  837. filaments: [
  838. { slot_id: 1, type: 'PLA', color: '#0066FF', used_grams: 15 },
  839. ],
  840. };
  841. const result = computeAmsMapping(reqs, createX1CStatus());
  842. // Blue PLA → AMS 1 T4 (globalTrayId 7, color 0066FF)
  843. expect(result).toEqual([7]);
  844. });
  845. it('matches by tray_info_idx across AMS units', () => {
  846. const reqs = {
  847. filaments: [
  848. { slot_id: 1, type: 'PLA', color: '#EBCFA6', used_grams: 10, tray_info_idx: 'PFUS22b2' },
  849. ],
  850. };
  851. const result = computeAmsMapping(reqs, createX1CStatus());
  852. // PFUS22b2 uniquely in AMS 1 T2 (globalTrayId 5)
  853. expect(result).toEqual([5]);
  854. });
  855. it('handles non-unique tray_info_idx with color matching', () => {
  856. // P4d64437 appears in both AMS 1 T3 and T4
  857. const reqs = {
  858. filaments: [
  859. { slot_id: 1, type: 'PLA', color: '#FCECD6', used_grams: 10, tray_info_idx: 'P4d64437' },
  860. ],
  861. };
  862. const result = computeAmsMapping(reqs, createX1CStatus());
  863. // Should pick AMS 1 T3 (globalTrayId 6, color FCECD6) over T4 (0066FF)
  864. expect(result).toEqual([6]);
  865. });
  866. it('does not cross-nozzle filter for single-nozzle printer', () => {
  867. // Even if ams_extruder_map exists, single-nozzle 3MF has no nozzle_id
  868. const reqs = {
  869. filaments: [
  870. { slot_id: 1, type: 'PLA', color: '#EBCFA6', used_grams: 10 },
  871. { slot_id: 2, type: 'PLA', color: '#0066FF', used_grams: 10 },
  872. ],
  873. };
  874. const result = computeAmsMapping(reqs, createX1CStatus());
  875. // Both should match freely across all AMS units
  876. expect(result).toEqual([5, 7]);
  877. });
  878. });
  879. });
  880. describe('computeAmsMapping preferLowest', () => {
  881. it('picks spool with lowest remain when enabled', () => {
  882. const reqs = {
  883. filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
  884. };
  885. const status = createPrinterStatus([
  886. {
  887. id: 0,
  888. tray: [
  889. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },
  890. { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 },
  891. ],
  892. },
  893. ]);
  894. const result = computeAmsMapping(reqs, status, true);
  895. expect(result).toEqual([1]); // Tray 1 has 25% remain
  896. });
  897. it('picks first match when disabled', () => {
  898. const reqs = {
  899. filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
  900. };
  901. const status = createPrinterStatus([
  902. {
  903. id: 0,
  904. tray: [
  905. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },
  906. { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 },
  907. ],
  908. },
  909. ]);
  910. const result = computeAmsMapping(reqs, status, false);
  911. expect(result).toEqual([0]); // First match (default)
  912. });
  913. it('sorts unknown remain to end', () => {
  914. const reqs = {
  915. filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
  916. };
  917. const status = createPrinterStatus([
  918. {
  919. id: 0,
  920. tray: [
  921. { id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // No remain (defaults to -1)
  922. { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 60 },
  923. ],
  924. },
  925. ]);
  926. const result = computeAmsMapping(reqs, status, true);
  927. expect(result).toEqual([1]); // Known 60% over unknown
  928. });
  929. });
  930. // #1766: the user reported that "Prefer lowest remaining filament" picked the
  931. // wrong spool when two identical-material/color spools differed only in the
  932. // inventory-tracked grams (not the printer's `remain%`). The pre-fix sort
  933. // looked at `remain%` only and ignored Bambuddy's bound inventory entirely;
  934. // now we pass a globalTrayId -> grams map and the sort lifts inventory-bound
  935. // spools to tier 0 (matching backend _prefer_lowest_sort_key).
  936. describe('computeAmsMapping preferLowest with inventory map (#1766)', () => {
  937. it('picks spool with lower inventory grams when both spools report same remain%', () => {
  938. // Reporter's scenario: two identical Bambu-branded spools, both report
  939. // `remain=100` because they were freshly inserted, but inventory has them
  940. // at 950 g vs 50 g remaining. Pre-fix sort ties and picks the first.
  941. const reqs = {
  942. filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'GFA00' }],
  943. };
  944. const status = createPrinterStatus([
  945. {
  946. id: 0,
  947. tray: [
  948. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 100 },
  949. { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 100 },
  950. ],
  951. },
  952. ]);
  953. const inventory = new Map<number, number>([[0, 950], [1, 50]]);
  954. const result = computeAmsMapping(reqs, status, true, inventory);
  955. expect(result).toEqual([1]); // Inventory says tray 1 is nearly empty — use it first.
  956. });
  957. it('prefers inventory-tracked spool over non-tracked one even when remain% would order them differently', () => {
  958. // Two spools both match by type+color, both have the same tray_info_idx
  959. // (identical SKU). Tray 0 has no inventory binding but reports remain=20.
  960. // Tray 1 has an inventory binding with 100 g remaining. Tier 0 (bound)
  961. // always beats tier 1 (MQTT-only) regardless of value — matches backend.
  962. const reqs = {
  963. filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'GFA00' }],
  964. };
  965. const status = createPrinterStatus([
  966. {
  967. id: 0,
  968. tray: [
  969. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 20 },
  970. { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 80 },
  971. ],
  972. },
  973. ]);
  974. const inventory = new Map<number, number>([[1, 100]]); // Only tray 1 bound
  975. const result = computeAmsMapping(reqs, status, true, inventory);
  976. expect(result).toEqual([1]);
  977. });
  978. it('falls back to remain% sort when no inventory map provided (pre-#1766 behaviour)', () => {
  979. // Regression guard: callers that haven't yet wired the map must get the
  980. // same sort they always got. None of the existing tests in this file pass
  981. // a map; this asserts the default path is unchanged.
  982. const reqs = {
  983. filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
  984. };
  985. const status = createPrinterStatus([
  986. {
  987. id: 0,
  988. tray: [
  989. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },
  990. { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 },
  991. ],
  992. },
  993. ]);
  994. const result = computeAmsMapping(reqs, status, true, undefined);
  995. expect(result).toEqual([1]); // Same as the existing no-inventory case.
  996. });
  997. });
  998. // #1766 safety gate: when the printer has AMS Filament Backup OFF, the sort
  999. // MUST NOT run, even with the user setting on. Otherwise the dispatch picks a
  1000. // near-empty spool the printer can't switch off of when it runs out mid-print.
  1001. // Mirrors backend `_compute_ams_mapping_for_printer` gate.
  1002. describe('effectivePreferLowest gate (#1766)', () => {
  1003. it('coerces to false when backup is OFF', () => {
  1004. expect(effectivePreferLowest(true, false)).toBe(false);
  1005. });
  1006. it('passes through when backup is ON', () => {
  1007. expect(effectivePreferLowest(true, true)).toBe(true);
  1008. });
  1009. it('passes through when backup is unknown (null/undefined — A1 family)', () => {
  1010. expect(effectivePreferLowest(true, null)).toBe(true);
  1011. expect(effectivePreferLowest(true, undefined)).toBe(true);
  1012. });
  1013. it('stays false when the user setting is off, regardless of backup state', () => {
  1014. expect(effectivePreferLowest(false, true)).toBe(false);
  1015. expect(effectivePreferLowest(false, false)).toBe(false);
  1016. expect(effectivePreferLowest(undefined, true)).toBe(false);
  1017. });
  1018. it('slot-priority tie-break: external/VT spools sort AFTER regular AMS', () => {
  1019. // Mirrors backend `_slot_priority` banding. When tier and value tie, slot
  1020. // position decides — external (ams_id = -1) must clamp to 10_000 so it
  1021. // can't beat AMS slot 0 (priority 0).
  1022. const reqs = {
  1023. filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
  1024. };
  1025. const status = createPrinterStatus(
  1026. [
  1027. {
  1028. id: 0,
  1029. tray: [
  1030. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: -1 }, // priority 0
  1031. ],
  1032. },
  1033. ],
  1034. [{ id: 254, tray_type: 'PLA', tray_color: 'FF0000', remain: -1 }], // priority 10_000
  1035. );
  1036. const result = computeAmsMapping(reqs, status, true);
  1037. expect(result).toEqual([0]); // AMS slot wins the tie; VT does not.
  1038. });
  1039. it('end-to-end: backup OFF prevents lowest-pick at dispatch (caller-coerced)', () => {
  1040. // PrintModal computes the effective flag and passes it to computeAmsMapping.
  1041. // This pins that flow: with backup=false the flag becomes false, the sort
  1042. // doesn't run, and the first matching tray wins (today's behaviour).
  1043. const reqs = {
  1044. filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
  1045. };
  1046. const status = createPrinterStatus([
  1047. {
  1048. id: 0,
  1049. tray: [
  1050. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },
  1051. { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 5 }, // near-empty
  1052. ],
  1053. },
  1054. ]);
  1055. const gated = effectivePreferLowest(true, false);
  1056. const result = computeAmsMapping(reqs, status, gated);
  1057. expect(result).toEqual([0]); // First match wins; the 5%-remain spool is NOT selected.
  1058. });
  1059. });
  1060. describe('per-plate mapping vs. the whole-file union (#2551 follow-up)', () => {
  1061. // The multi-plate case that made this necessary: two plates, each printing one
  1062. // red object, but on different slots of the same file. The printer has exactly
  1063. // one red spool.
  1064. const PLATE_1 = { filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }] };
  1065. const PLATE_2 = { filaments: [{ slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 }] };
  1066. const WHOLE_FILE_UNION = {
  1067. filaments: [
  1068. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
  1069. { slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 },
  1070. ],
  1071. };
  1072. const printer = createPrinterStatus([
  1073. {
  1074. id: 0,
  1075. tray: [
  1076. { id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // the only red — global tray 0
  1077. { id: 1, tray_type: 'PLA', tray_color: '000000' }, // black — global tray 1
  1078. ],
  1079. },
  1080. ]);
  1081. const mapPlate = (reqs: { filaments: { slot_id: number; type: string; color: string; used_grams: number }[] }) =>
  1082. buildAmsMapping(buildFilamentComparison(reqs, buildLoadedFilaments(printer), {}));
  1083. it('maps each plate to the red tray, because each plate is its own print', () => {
  1084. expect(mapPlate(PLATE_1)).toEqual([0]);
  1085. // Slot 1 is unused by this plate, so it stays -1; slot 2 takes the red tray.
  1086. expect(mapPlate(PLATE_2)).toEqual([-1, 0]);
  1087. });
  1088. it('the union starves the second slot — which is what a shared mapping sent', () => {
  1089. // Tray assignment is stateful: slot 1 claims the red tray, so slot 2 falls
  1090. // through to a type-only match on the BLACK tray. Sending this to plate 2
  1091. // prints it in black. This is the mapping the modal used to post for every
  1092. // plate of a multi-plate submission.
  1093. expect(mapPlate(WHOLE_FILE_UNION)).toEqual([0, 1]);
  1094. });
  1095. it('a manual override on one plate does not leak into another', () => {
  1096. const loaded = buildLoadedFilaments(printer);
  1097. // The user pins plate 2's slot 2 to the black tray by hand.
  1098. const plate2 = buildAmsMapping(buildFilamentComparison(PLATE_2, loaded, { 2: 1 }));
  1099. expect(plate2).toEqual([-1, 1]);
  1100. // Plate 1 is mapped from its own (empty) override set and still gets red.
  1101. expect(buildAmsMapping(buildFilamentComparison(PLATE_1, loaded, {}))).toEqual([0]);
  1102. });
  1103. });
  1104. describe('useFilamentMapping — no [-1] mapping during a status-load race (#2589)', () => {
  1105. // The file requires one PETG slot; two compatible PETG spools are loaded once
  1106. // the printer status arrives.
  1107. const filamentReqs = {
  1108. filaments: [{ slot_id: 1, type: 'PETG', color: '#161616', used_grams: 141.86 }],
  1109. };
  1110. const statusWithPetg = createPrinterStatus([
  1111. {
  1112. id: 0,
  1113. tray: [
  1114. { id: 0, tray_type: 'PETG', tray_color: 'BCBCBC' },
  1115. { id: 1, tray_type: 'PETG', tray_color: 'FFFFFF' },
  1116. ],
  1117. },
  1118. ]);
  1119. it('returns undefined (not [-1]) while printerStatus is still loading', () => {
  1120. // printerStatus undefined = query not resolved yet. Serializing [-1] here is
  1121. // exactly what dispatched the P1S print to the empty external feed.
  1122. const { result } = renderHook(() => useFilamentMapping(filamentReqs, undefined, {}));
  1123. expect(result.current.amsMapping).toBeUndefined();
  1124. });
  1125. it('resolves to an AMS tray once status has loaded (type-only match, strict color off)', () => {
  1126. // Black requested, only gray/white PETG loaded: a type-only match is valid.
  1127. const { result } = renderHook(() => useFilamentMapping(filamentReqs, statusWithPetg, {}));
  1128. expect(result.current.amsMapping).toEqual([0]);
  1129. expect(result.current.hasTypeMismatch).toBe(false);
  1130. });
  1131. it('still emits [-1] for a genuine mismatch when trays are present', () => {
  1132. // Status loaded but nothing compatible (only PLA) -> the mapping legitimately
  1133. // carries -1 and the mismatch is surfaced; this must NOT be suppressed.
  1134. const statusWithPla = createPrinterStatus([
  1135. { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }] },
  1136. ]);
  1137. const { result } = renderHook(() => useFilamentMapping(filamentReqs, statusWithPla, {}));
  1138. expect(result.current.amsMapping).toEqual([-1]);
  1139. expect(result.current.hasTypeMismatch).toBe(true);
  1140. });
  1141. });
  1142. describe('colour verdict is independent of how the tray was found (#2687)', () => {
  1143. // tray_info_idx names the filament *variant*, not an individual spool:
  1144. // GFA00 = PLA Basic, GFA01 = PLA Matte, GFA17 = PLA Translucent. A user with
  1145. // exactly one Matte spool loaded therefore idx-matches every Matte
  1146. // requirement no matter what colour it is.
  1147. const MATTE_DARK_GREEN = createPrinterStatus([
  1148. { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: '004225', tray_info_idx: 'GFA01' }] },
  1149. ]);
  1150. const wantRedMatte = {
  1151. filaments: [
  1152. { slot_id: 1, type: 'PLA', color: '#9D432C', used_grams: 31, tray_info_idx: 'GFA01' },
  1153. ],
  1154. };
  1155. it('reports a unique-idx tray of the wrong colour as type_only, not match', () => {
  1156. const [item] = buildFilamentComparison(
  1157. wantRedMatte,
  1158. buildLoadedFilaments(MATTE_DARK_GREEN),
  1159. {},
  1160. );
  1161. // The tray is still selected — it is the right variant (#2650) ...
  1162. expect(item.loaded?.globalTrayId).toBe(0);
  1163. // ... but red-on-dark-green is not a colour match.
  1164. expect(item.colorMatch).toBe(false);
  1165. expect(item.status).toBe('type_only');
  1166. });
  1167. it('auto and manual agree on the same tray', () => {
  1168. const loaded = buildLoadedFilaments(MATTE_DARK_GREEN);
  1169. const auto = buildFilamentComparison(wantRedMatte, loaded, {})[0];
  1170. const manual = buildFilamentComparison(wantRedMatte, loaded, { 1: 0 })[0];
  1171. // The original report: auto said "match", manually picking that very tray
  1172. // said "mismatch". Both paths must now reach the same verdict.
  1173. expect(manual.isManual).toBe(true);
  1174. expect(auto.status).toBe(manual.status);
  1175. expect(auto.colorMatch).toBe(manual.colorMatch);
  1176. });
  1177. it('surfaces the mismatch through the hook so the panel stops saying Ready', () => {
  1178. const { result } = renderHook(() => useFilamentMapping(wantRedMatte, MATTE_DARK_GREEN, {}));
  1179. // hasColorMismatch drives the yellow "(Color mismatch)" header; the tray is
  1180. // still mapped, so this is not a type mismatch.
  1181. expect(result.current.hasColorMismatch).toBe(true);
  1182. expect(result.current.hasTypeMismatch).toBe(false);
  1183. expect(result.current.amsMapping).toEqual([0]);
  1184. });
  1185. it('still reports a match when the unique-idx tray does carry the right colour', () => {
  1186. const [item] = buildFilamentComparison(
  1187. { filaments: [{ slot_id: 1, type: 'PLA', color: '#004225', used_grams: 31, tray_info_idx: 'GFA01' }] },
  1188. buildLoadedFilaments(MATTE_DARK_GREEN),
  1189. {},
  1190. );
  1191. expect(item.status).toBe('match');
  1192. expect(item.colorMatch).toBe(true);
  1193. });
  1194. it('accepts a near-enough shade on the idx path', () => {
  1195. // Within colorsAreSimilar's per-channel tolerance — the printer reporting a
  1196. // spool a shade off must not become a mismatch.
  1197. const [item] = buildFilamentComparison(
  1198. { filaments: [{ slot_id: 1, type: 'PLA', color: '#0A4A2A', used_grams: 31, tray_info_idx: 'GFA01' }] },
  1199. buildLoadedFilaments(MATTE_DARK_GREEN),
  1200. {},
  1201. );
  1202. expect(item.status).toBe('match');
  1203. });
  1204. it('treats a colourless requirement as satisfied by any colour', () => {
  1205. // 3MFs that omit the colour parse to "" (filament_requirements.py); there is
  1206. // nothing to disagree with, so this must not read as a colour mismatch.
  1207. const [item] = buildFilamentComparison(
  1208. { filaments: [{ slot_id: 1, type: 'PLA', color: '', used_grams: 31, tray_info_idx: 'GFA01' }] },
  1209. buildLoadedFilaments(MATTE_DARK_GREEN),
  1210. {},
  1211. );
  1212. expect(item.status).toBe('match');
  1213. expect(item.colorMatch).toBe(true);
  1214. });
  1215. it('keeps the multi-idx path intact — same idx, several colours picks the right one', () => {
  1216. // Two Matte spools: the branch that already compared colours must be
  1217. // unaffected, and the exact-colour tray still wins.
  1218. const twoMatte = createPrinterStatus([
  1219. {
  1220. id: 0,
  1221. tray: [
  1222. { id: 0, tray_type: 'PLA', tray_color: '004225', tray_info_idx: 'GFA01' },
  1223. { id: 1, tray_type: 'PLA', tray_color: '9D432C', tray_info_idx: 'GFA01' },
  1224. ],
  1225. },
  1226. ]);
  1227. const [item] = buildFilamentComparison(wantRedMatte, buildLoadedFilaments(twoMatte), {});
  1228. expect(item.loaded?.globalTrayId).toBe(1);
  1229. expect(item.status).toBe('match');
  1230. });
  1231. it('a type-only fallback with no idx candidate is still type_only', () => {
  1232. // Regression guard: the pre-existing "type matches, colour does not" path.
  1233. const basicOnly = createPrinterStatus([
  1234. { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: '004225', tray_info_idx: 'GFA00' }] },
  1235. ]);
  1236. const [item] = buildFilamentComparison(wantRedMatte, buildLoadedFilaments(basicOnly), {});
  1237. expect(item.status).toBe('type_only');
  1238. expect(item.colorMatch).toBe(false);
  1239. });
  1240. });