useFilamentMapping.test.ts 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050
  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 {
  9. buildLoadedFilaments,
  10. computeAmsMapping,
  11. } from '../../hooks/useFilamentMapping';
  12. import type { PrinterStatus } from '../../api/client';
  13. // Helper to create a minimal printer status with AMS data
  14. function createPrinterStatus(ams: PrinterStatus['ams'], vt_tray: PrinterStatus['vt_tray'] = []): PrinterStatus {
  15. return {
  16. ams,
  17. vt_tray,
  18. } as PrinterStatus;
  19. }
  20. describe('buildLoadedFilaments', () => {
  21. it('returns empty array for undefined status', () => {
  22. const result = buildLoadedFilaments(undefined);
  23. expect(result).toEqual([]);
  24. });
  25. it('extracts filaments from AMS units', () => {
  26. const status = createPrinterStatus([
  27. {
  28. id: 0,
  29. tray: [
  30. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
  31. { id: 1, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFA01' },
  32. ],
  33. },
  34. ]);
  35. const result = buildLoadedFilaments(status);
  36. expect(result).toHaveLength(2);
  37. expect(result[0]).toMatchObject({
  38. type: 'PLA',
  39. color: '#FF0000',
  40. amsId: 0,
  41. trayId: 0,
  42. globalTrayId: 0,
  43. trayInfoIdx: 'GFA00',
  44. });
  45. expect(result[1]).toMatchObject({
  46. type: 'PETG',
  47. color: '#00FF00',
  48. globalTrayId: 1,
  49. trayInfoIdx: 'GFA01',
  50. });
  51. });
  52. it('includes tray_info_idx from AMS trays', () => {
  53. const status = createPrinterStatus([
  54. {
  55. id: 0,
  56. tray: [
  57. { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'P4d64437' },
  58. ],
  59. },
  60. ]);
  61. const result = buildLoadedFilaments(status);
  62. expect(result[0].trayInfoIdx).toBe('P4d64437');
  63. });
  64. it('handles missing tray_info_idx', () => {
  65. const status = createPrinterStatus([
  66. {
  67. id: 0,
  68. tray: [
  69. { id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // No tray_info_idx
  70. ],
  71. },
  72. ]);
  73. const result = buildLoadedFilaments(status);
  74. expect(result[0].trayInfoIdx).toBe('');
  75. });
  76. it('includes tray_sub_brands from AMS trays', () => {
  77. const status = createPrinterStatus([
  78. {
  79. id: 0,
  80. tray: [
  81. { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFL99', tray_sub_brands: 'PLA Basic' },
  82. { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFL05', tray_sub_brands: 'PLA Matte' },
  83. ],
  84. },
  85. ]);
  86. const result = buildLoadedFilaments(status);
  87. expect(result[0].traySubBrands).toBe('PLA Basic');
  88. expect(result[1].traySubBrands).toBe('PLA Matte');
  89. });
  90. it('handles missing tray_sub_brands', () => {
  91. const status = createPrinterStatus([
  92. {
  93. id: 0,
  94. tray: [
  95. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
  96. ],
  97. },
  98. ]);
  99. const result = buildLoadedFilaments(status);
  100. expect(result[0].traySubBrands).toBe('');
  101. });
  102. it('includes tray_sub_brands from external spool', () => {
  103. const status = createPrinterStatus(
  104. [],
  105. [{ tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFG00', tray_sub_brands: 'PETG HF' }]
  106. );
  107. const result = buildLoadedFilaments(status);
  108. expect(result[0].traySubBrands).toBe('PETG HF');
  109. });
  110. it('extracts external spool with tray_info_idx', () => {
  111. const status = createPrinterStatus(
  112. [],
  113. [{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }]
  114. );
  115. const result = buildLoadedFilaments(status);
  116. expect(result).toHaveLength(1);
  117. expect(result[0]).toMatchObject({
  118. type: 'TPU',
  119. isExternal: true,
  120. globalTrayId: 254,
  121. trayInfoIdx: 'EXT001',
  122. });
  123. });
  124. it('skips empty trays', () => {
  125. const status = createPrinterStatus([
  126. {
  127. id: 0,
  128. tray: [
  129. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
  130. { id: 1, tray_type: '', tray_color: '' }, // Empty tray
  131. { id: 2 }, // No tray_type
  132. ],
  133. },
  134. ]);
  135. const result = buildLoadedFilaments(status);
  136. expect(result).toHaveLength(1);
  137. expect(result[0].type).toBe('PLA');
  138. });
  139. it('marks AMS-HT units correctly', () => {
  140. const status = createPrinterStatus([
  141. {
  142. id: 128, // AMS-HT typically has high ID
  143. tray: [
  144. { id: 0, tray_type: 'PLA-CF', tray_color: '000000', tray_info_idx: 'HT001' },
  145. ], // Single tray = AMS-HT
  146. },
  147. ]);
  148. const result = buildLoadedFilaments(status);
  149. expect(result[0].isHt).toBe(true);
  150. expect(result[0].globalTrayId).toBe(128); // AMS-HT uses ams_id directly
  151. });
  152. });
  153. describe('computeAmsMapping', () => {
  154. it('returns undefined for empty filament requirements', () => {
  155. const status = createPrinterStatus([
  156. {
  157. id: 0,
  158. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  159. },
  160. ]);
  161. expect(computeAmsMapping(undefined, status)).toBeUndefined();
  162. expect(computeAmsMapping({ filaments: [] }, status)).toBeUndefined();
  163. });
  164. it('returns undefined when no filaments loaded', () => {
  165. const reqs = {
  166. filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
  167. };
  168. expect(computeAmsMapping(reqs, undefined)).toBeUndefined();
  169. expect(computeAmsMapping(reqs, createPrinterStatus([]))).toBeUndefined();
  170. });
  171. it('matches by tray_info_idx with highest priority', () => {
  172. const reqs = {
  173. filaments: [
  174. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA01' },
  175. ],
  176. };
  177. const status = createPrinterStatus([
  178. {
  179. id: 0,
  180. tray: [
  181. { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' }, // Same color, wrong idx
  182. { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' }, // Exact idx match
  183. { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' }, // Same color, wrong idx
  184. ],
  185. },
  186. ]);
  187. const result = computeAmsMapping(reqs, status);
  188. expect(result).toEqual([1]); // Should pick tray 1, not tray 0
  189. });
  190. it('matches multiple identical filaments by tray_info_idx (H2D Pro scenario)', () => {
  191. // This is the exact scenario from issue #245 - multiple black PLA spools
  192. const reqs = {
  193. filaments: [
  194. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 50, tray_info_idx: 'GFA03' },
  195. ],
  196. };
  197. const status = createPrinterStatus([
  198. {
  199. id: 0,
  200. tray: [
  201. { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
  202. { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
  203. { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
  204. { id: 3, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA03' }, // This one
  205. ],
  206. },
  207. ]);
  208. const result = computeAmsMapping(reqs, status);
  209. expect(result).toEqual([3]); // Should pick tray 3, not tray 0
  210. });
  211. it('falls back to color match when tray_info_idx is empty', () => {
  212. const reqs = {
  213. filaments: [
  214. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: '' },
  215. ],
  216. };
  217. const status = createPrinterStatus([
  218. {
  219. id: 0,
  220. tray: [
  221. { id: 0, tray_type: 'PLA', tray_color: '00FF00', tray_info_idx: 'GFA00' }, // Wrong color
  222. { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA01' }, // Color match
  223. ],
  224. },
  225. ]);
  226. const result = computeAmsMapping(reqs, status);
  227. expect(result).toEqual([1]);
  228. });
  229. it('falls back to color match when tray_info_idx does not match', () => {
  230. const reqs = {
  231. filaments: [
  232. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'OLD_SPOOL' },
  233. ],
  234. };
  235. const status = createPrinterStatus([
  236. {
  237. id: 0,
  238. tray: [
  239. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'NEW_SPOOL' }, // Different idx, same color
  240. ],
  241. },
  242. ]);
  243. const result = computeAmsMapping(reqs, status);
  244. expect(result).toEqual([0]); // Falls back to color match
  245. });
  246. it('matches by type only when color differs', () => {
  247. const reqs = {
  248. filaments: [
  249. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
  250. ],
  251. };
  252. const status = createPrinterStatus([
  253. {
  254. id: 0,
  255. tray: [
  256. { id: 0, tray_type: 'PLA', tray_color: '0000FF' }, // Same type, different color
  257. ],
  258. },
  259. ]);
  260. const result = computeAmsMapping(reqs, status);
  261. expect(result).toEqual([0]); // Type-only match
  262. });
  263. it('returns -1 for unmatched slots', () => {
  264. const reqs = {
  265. filaments: [
  266. { slot_id: 1, type: 'TPU', color: '#FF0000', used_grams: 10 }, // No TPU loaded
  267. ],
  268. };
  269. const status = createPrinterStatus([
  270. {
  271. id: 0,
  272. tray: [
  273. { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
  274. ],
  275. },
  276. ]);
  277. const result = computeAmsMapping(reqs, status);
  278. expect(result).toEqual([-1]);
  279. });
  280. it('avoids duplicate tray assignment', () => {
  281. const reqs = {
  282. filaments: [
  283. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
  284. { slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 }, // Same requirements
  285. ],
  286. };
  287. const status = createPrinterStatus([
  288. {
  289. id: 0,
  290. tray: [
  291. { id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // Only one PLA
  292. ],
  293. },
  294. ]);
  295. const result = computeAmsMapping(reqs, status);
  296. expect(result).toEqual([0, -1]); // First slot gets the match, second is unmatched
  297. });
  298. it('handles multi-slot mapping with tray_info_idx', () => {
  299. const reqs = {
  300. filaments: [
  301. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA00' },
  302. { slot_id: 2, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA02' },
  303. ],
  304. };
  305. const status = createPrinterStatus([
  306. {
  307. id: 0,
  308. tray: [
  309. { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
  310. { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
  311. { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
  312. ],
  313. },
  314. ]);
  315. const result = computeAmsMapping(reqs, status);
  316. expect(result).toEqual([0, 2]); // Each slot gets its specific tray
  317. });
  318. it('handles external spool matching', () => {
  319. const reqs = {
  320. filaments: [
  321. { slot_id: 1, type: 'TPU', color: '#0000FF', used_grams: 10, tray_info_idx: 'EXT001' },
  322. ],
  323. };
  324. const status = createPrinterStatus(
  325. [],
  326. [{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }]
  327. );
  328. const result = computeAmsMapping(reqs, status);
  329. expect(result).toEqual([254]); // External spool global ID
  330. });
  331. });
  332. describe('buildLoadedFilaments - nozzle awareness', () => {
  333. it('sets extruderId from ams_extruder_map', () => {
  334. const status = createPrinterStatus([
  335. {
  336. id: 0,
  337. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  338. },
  339. {
  340. id: 1,
  341. tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
  342. },
  343. ]);
  344. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  345. const result = buildLoadedFilaments(status);
  346. expect(result[0].extruderId).toBe(1); // AMS 0 → left nozzle
  347. expect(result[1].extruderId).toBe(0); // AMS 1 → right nozzle
  348. });
  349. it('leaves extruderId undefined when no ams_extruder_map', () => {
  350. const status = createPrinterStatus([
  351. {
  352. id: 0,
  353. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  354. },
  355. ]);
  356. const result = buildLoadedFilaments(status);
  357. expect(result[0].extruderId).toBeUndefined();
  358. });
  359. });
  360. describe('computeAmsMapping - nozzle filtering', () => {
  361. it('filters candidates by nozzle_id when set', () => {
  362. // Filament requires left nozzle (extruder 1), only AMS 0 is on left
  363. const reqs = {
  364. filaments: [
  365. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
  366. ],
  367. };
  368. const status = createPrinterStatus([
  369. {
  370. id: 0, // Left nozzle
  371. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  372. },
  373. {
  374. id: 1, // Right nozzle
  375. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  376. },
  377. ]);
  378. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  379. const result = computeAmsMapping(reqs, status);
  380. expect(result).toEqual([0]); // AMS 0, tray 0 (on left nozzle)
  381. });
  382. it('filters to right nozzle when nozzle_id=0', () => {
  383. const reqs = {
  384. filaments: [
  385. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 0 },
  386. ],
  387. };
  388. const status = createPrinterStatus([
  389. {
  390. id: 0, // Left nozzle
  391. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  392. },
  393. {
  394. id: 1, // Right nozzle
  395. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  396. },
  397. ]);
  398. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  399. const result = computeAmsMapping(reqs, status);
  400. expect(result).toEqual([4]); // AMS 1, tray 0 (global ID = 1*4+0 = 4, on right nozzle)
  401. });
  402. it('returns -1 when target nozzle has no trays (hard filter)', () => {
  403. // Requires nozzle_id=1 (left), but no AMS units are on left nozzle
  404. // Hard filter: cross-nozzle assignment causes "position of left hotend is abnormal"
  405. const reqs = {
  406. filaments: [
  407. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
  408. ],
  409. };
  410. const status = createPrinterStatus([
  411. {
  412. id: 0, // Right nozzle only
  413. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  414. },
  415. ]);
  416. (status as any).ams_extruder_map = { '0': 0 }; // AMS 0 → right nozzle, none on left
  417. const result = computeAmsMapping(reqs, status);
  418. expect(result).toEqual([-1]); // Hard filter: no fallback to wrong nozzle
  419. });
  420. it('stays restricted when target nozzle has trays but wrong type', () => {
  421. // Left nozzle has PETG, right has PLA — but requires PLA on left
  422. const reqs = {
  423. filaments: [
  424. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
  425. ],
  426. };
  427. const status = createPrinterStatus([
  428. {
  429. id: 0, // Left nozzle - only PETG
  430. tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
  431. },
  432. {
  433. id: 1, // Right nozzle - has PLA
  434. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  435. },
  436. ]);
  437. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  438. const result = computeAmsMapping(reqs, status);
  439. expect(result).toEqual([-1]); // No PLA on left nozzle, stays restricted
  440. });
  441. it('skips nozzle filtering when nozzle_id is undefined', () => {
  442. const reqs = {
  443. filaments: [
  444. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }, // No nozzle_id
  445. ],
  446. };
  447. const status = createPrinterStatus([
  448. {
  449. id: 0,
  450. tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
  451. },
  452. {
  453. id: 1,
  454. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  455. },
  456. ]);
  457. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  458. const result = computeAmsMapping(reqs, status);
  459. expect(result).toEqual([4]); // Picks best match regardless of nozzle
  460. });
  461. it('handles dual-nozzle multi-slot mapping', () => {
  462. // Two filaments: one for left, one for right
  463. const reqs = {
  464. filaments: [
  465. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 }, // Left
  466. { slot_id: 2, type: 'PETG', color: '#00FF00', used_grams: 10, nozzle_id: 0 }, // Right
  467. ],
  468. };
  469. const status = createPrinterStatus([
  470. {
  471. id: 0, // Left nozzle
  472. tray: [
  473. { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
  474. ],
  475. },
  476. {
  477. id: 1, // Right nozzle
  478. tray: [
  479. { id: 0, tray_type: 'PETG', tray_color: '00FF00' },
  480. ],
  481. },
  482. ]);
  483. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  484. const result = computeAmsMapping(reqs, status);
  485. expect(result).toEqual([0, 4]); // Left gets AMS0-T0, Right gets AMS1-T0
  486. });
  487. // FTS (Filament Track Switch) — when present, AMS slots aren't tied to a
  488. // specific extruder. The track switch routes any slot to either extruder, so
  489. // the per-nozzle hard filter must NOT apply. See #1162.
  490. it('ignores nozzle_id when FTS is installed', () => {
  491. // Required filament asks for nozzle 1 (left). Without FTS this would force
  492. // AMS 0 (which is on the left nozzle). With FTS we accept any AMS slot
  493. // matching by type/color since the FTS routes it to whichever extruder.
  494. const reqs = {
  495. filaments: [
  496. { slot_id: 1, type: 'PETG', color: '#00FF00', used_grams: 10, nozzle_id: 1 },
  497. ],
  498. };
  499. const status = createPrinterStatus([
  500. {
  501. id: 0, // Without FTS, this AMS would be left/extruder 1; ams_extruder_map
  502. // is empty because the printer reports info bits 8-11 = 0xE.
  503. tray: [
  504. { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
  505. { id: 1, tray_type: 'PETG', tray_color: '00FF00' },
  506. ],
  507. },
  508. ]);
  509. (status as any).ams_extruder_map = {};
  510. (status as any).fila_switch = {
  511. installed: true,
  512. in_slots: [-1, 1],
  513. out_extruders: [0, 1],
  514. stat: 0,
  515. info: 2,
  516. };
  517. const result = computeAmsMapping(reqs, status);
  518. expect(result).toEqual([1]); // Picks AMS 0 tray 1 (PETG green) regardless of nozzle
  519. });
  520. // X2D / H2D / X2 Pro with no AMS but two external spools (one feeding each
  521. // extruder). Pre-fix, dual-nozzle was inferred from `ams_extruder_map` being
  522. // non-empty, which fails when there are no AMS units — both vt_tray entries
  523. // got `extruderId=undefined`, the per-nozzle filter rejected everything, and
  524. // the UI surfaced "Required filament type not found in printer" even though
  525. // the matching filament was sitting in the external spool. (#1257)
  526. it('matches external spools per-extruder on dual-nozzle without AMS', () => {
  527. const reqs = {
  528. filaments: [
  529. { slot_id: 1, type: 'PETG', color: '#FFFFFF', used_grams: 15, nozzle_id: 1 }, // Left
  530. ],
  531. };
  532. const status = createPrinterStatus([], [
  533. // Two external spools, both PETG. Ext-L (id=254) feeds left extruder (1),
  534. // Ext-R (id=255) feeds right (0). 255 - id formula in buildLoadedFilaments
  535. // routes them when hasDualNozzle is true.
  536. { id: 254, tray_type: 'PETG', tray_color: 'FFFFFF' } as PrinterStatus['vt_tray'][number],
  537. { id: 255, tray_type: 'PETG', tray_color: '000000' } as PrinterStatus['vt_tray'][number],
  538. ]);
  539. // Real X2D hardware: both nozzles report a populated diameter via the
  540. // MQTT right_nozzle_diameter / left_nozzle_diameter fields. ams_extruder_map
  541. // is empty because there are zero AMS units.
  542. (status as any).nozzles = [
  543. { nozzle_type: 'stainless_steel', nozzle_diameter: '0.4' },
  544. { nozzle_type: 'stainless_steel', nozzle_diameter: '0.4' },
  545. ];
  546. (status as any).ams_extruder_map = {};
  547. // Loaded filaments must surface extruderId on each external entry,
  548. // otherwise computeAmsMapping's per-nozzle filter strips them out.
  549. const loaded = buildLoadedFilaments(status);
  550. expect(loaded).toHaveLength(2);
  551. expect(loaded.find((f) => f.globalTrayId === 254)?.extruderId).toBe(1); // Ext-L → left
  552. expect(loaded.find((f) => f.globalTrayId === 255)?.extruderId).toBe(0); // Ext-R → right
  553. // Mapping must succeed and pick Ext-L (left extruder, white PETG).
  554. const result = computeAmsMapping(reqs, status);
  555. expect(result).toEqual([254]);
  556. });
  557. // Sibling regression: the bambu_mqtt state defaults `nozzles` to a 2-entry
  558. // list with empty NozzleInfo() stubs even on single-nozzle printers, and the
  559. // route emits both entries on the wire. The dual-nozzle inference must NOT
  560. // be tripped by a stub second entry — only by populated hardware info,
  561. // populated ams_extruder_map, or >1 external trays. Pin: single-nozzle
  562. // printer (P1S/A1/X1C) with one external spool gets extruderId=undefined,
  563. // matching pre-fix behaviour. (#1257)
  564. it('does not fabricate extruderId for single-nozzle with stub nozzles[1]', () => {
  565. const status = createPrinterStatus([], [
  566. { id: 254, tray_type: 'PLA', tray_color: 'FF0000' } as PrinterStatus['vt_tray'][number],
  567. ]);
  568. // Single-nozzle: nozzles[1] is the default stub (empty fields).
  569. (status as any).nozzles = [
  570. { nozzle_type: 'stainless_steel', nozzle_diameter: '0.4' },
  571. { nozzle_type: '', nozzle_diameter: '' },
  572. ];
  573. (status as any).ams_extruder_map = {};
  574. const loaded = buildLoadedFilaments(status);
  575. expect(loaded).toHaveLength(1);
  576. expect(loaded[0].extruderId).toBeUndefined();
  577. });
  578. it('still applies nozzle filter when FTS object is null', () => {
  579. // Sanity check: explicit null fila_switch behaves like no FTS — nozzle
  580. // filter still applies on real dual-nozzle printers.
  581. const reqs = {
  582. filaments: [
  583. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
  584. ],
  585. };
  586. const status = createPrinterStatus([
  587. { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }] },
  588. { id: 1, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }] },
  589. ]);
  590. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  591. (status as any).fila_switch = null;
  592. const result = computeAmsMapping(reqs, status);
  593. expect(result).toEqual([0]); // AMS 0 (left/extruder 1)
  594. });
  595. });
  596. // ============================================================================
  597. // MODEL-SPECIFIC TESTS: Real data from actual printers
  598. // ============================================================================
  599. /**
  600. * H2D real data fixture (from live API response 2026-02-18).
  601. *
  602. * Configuration:
  603. * LEFT nozzle (extruder 1): AMS 0 (4-slot), AMS 2 (4-slot)
  604. * RIGHT nozzle (extruder 0): AMS 1 (4-slot), AMS-HT 128 (1-slot, empty)
  605. * External: 254 (Ext-L, LEFT nozzle), 255 (Ext-R, RIGHT nozzle)
  606. *
  607. * ams_extruder_map: {"0": 1, "1": 0, "2": 1, "128": 0}
  608. */
  609. function createH2DStatus(): PrinterStatus {
  610. const status = createPrinterStatus(
  611. [
  612. {
  613. id: 0, // LEFT nozzle (extruder 1)
  614. humidity: 24,
  615. temp: 21.4,
  616. tray: [
  617. { id: 0, tray_type: 'PETG', tray_color: 'FFFFFFFF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
  618. { id: 1, tray_type: 'PLA', tray_color: 'C8C8C8FF', tray_info_idx: 'GFA06', tray_sub_brands: 'PLA Silk+' },
  619. { id: 2, tray_type: 'PETG', tray_color: '875718FF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
  620. { id: 3, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' },
  621. ],
  622. },
  623. {
  624. id: 1, // RIGHT nozzle (extruder 0)
  625. humidity: 25,
  626. temp: 21.7,
  627. tray: [
  628. { id: 0, tray_type: 'PLA', tray_color: 'FFFFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' },
  629. { id: 1, tray_type: 'PETG', tray_color: '000000FF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
  630. { id: 2, tray_type: 'PLA', tray_color: '5F6367FF', tray_info_idx: 'GFA06', tray_sub_brands: 'PLA Silk+' },
  631. { id: 3, tray_type: 'PLA', tray_color: 'B39B84FF', tray_info_idx: 'GFA02', tray_sub_brands: 'PLA Metal' },
  632. ],
  633. },
  634. {
  635. id: 128, // AMS-HT, RIGHT nozzle (extruder 0) — empty
  636. humidity: 48,
  637. temp: 21.4,
  638. tray: [
  639. { id: 0 }, // empty tray
  640. ],
  641. },
  642. {
  643. id: 2, // LEFT nozzle (extruder 1)
  644. humidity: 18,
  645. temp: 24.0,
  646. tray: [
  647. { id: 0, tray_type: 'PLA-S', tray_color: 'FFFFFFFF', tray_info_idx: 'P8aa1726' },
  648. { id: 1, tray_type: 'PLA', tray_color: '56B7E6FF', tray_info_idx: 'PFUS9924' },
  649. { id: 2, tray_type: 'PETG', tray_color: '6EE53CFF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
  650. { id: 3, tray_type: 'PLA', tray_color: 'FF0000FF', tray_info_idx: 'PFUS9ac9' },
  651. ],
  652. },
  653. ],
  654. [
  655. { id: 254, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'P4d64437' }, // Ext-L (loaded)
  656. { id: 255, tray_type: '', tray_color: '00000000' }, // Ext-R (empty)
  657. ]
  658. );
  659. (status as any).ams_extruder_map = { '0': 1, '1': 0, '2': 1, '128': 0 };
  660. return status;
  661. }
  662. /**
  663. * X1C real data fixture (from live API response 2026-02-18).
  664. *
  665. * Configuration:
  666. * Single nozzle (extruder 0): AMS 0 (4-slot), AMS 1 (4-slot)
  667. * External: 254 (single)
  668. *
  669. * ams_extruder_map: {"0": 0, "1": 0} ← NOT empty, all on extruder 0
  670. */
  671. function createX1CStatus(): PrinterStatus {
  672. const status = createPrinterStatus(
  673. [
  674. {
  675. id: 0,
  676. humidity: 23,
  677. temp: 26.1,
  678. tray: [
  679. { id: 0 }, // empty (has tray_color but no tray_type)
  680. { id: 1 }, // empty
  681. { id: 2 }, // empty (has tray_color FFFFFFFF but no tray_type)
  682. { id: 3 }, // empty
  683. ],
  684. },
  685. {
  686. id: 1,
  687. humidity: 20,
  688. temp: 25.9,
  689. tray: [
  690. { id: 0 }, // empty
  691. { id: 1, tray_type: 'PLA', tray_color: 'EBCFA6FF', tray_info_idx: 'PFUS22b2' },
  692. { id: 2, tray_type: 'PLA', tray_color: 'FCECD6FF', tray_info_idx: 'P4d64437' },
  693. { id: 3, tray_type: 'PLA', tray_color: '0066FFFF', tray_info_idx: 'P4d64437' },
  694. ],
  695. },
  696. ],
  697. [
  698. { id: 254, tray_type: '', tray_color: '00000000' }, // empty
  699. ]
  700. );
  701. (status as any).ams_extruder_map = { '0': 0, '1': 0 };
  702. return status;
  703. }
  704. describe('H2D model tests (dual nozzle, real data)', () => {
  705. describe('buildLoadedFilaments', () => {
  706. it('assigns correct extruderId to all AMS units', () => {
  707. const result = buildLoadedFilaments(createH2DStatus());
  708. // AMS 0 trays → extruder 1 (LEFT)
  709. const ams0 = result.filter((f) => f.amsId === 0);
  710. expect(ams0).toHaveLength(4);
  711. ams0.forEach((f) => expect(f.extruderId).toBe(1));
  712. // AMS 1 trays → extruder 0 (RIGHT)
  713. const ams1 = result.filter((f) => f.amsId === 1);
  714. expect(ams1).toHaveLength(4);
  715. ams1.forEach((f) => expect(f.extruderId).toBe(0));
  716. // AMS 2 trays → extruder 1 (LEFT)
  717. const ams2 = result.filter((f) => f.amsId === 2);
  718. expect(ams2).toHaveLength(4);
  719. ams2.forEach((f) => expect(f.extruderId).toBe(1));
  720. });
  721. it('computes correct globalTrayId for all AMS types', () => {
  722. const result = buildLoadedFilaments(createH2DStatus());
  723. // Regular AMS: amsId * 4 + trayId
  724. expect(result.find((f) => f.amsId === 0 && f.trayId === 0)?.globalTrayId).toBe(0);
  725. expect(result.find((f) => f.amsId === 0 && f.trayId === 3)?.globalTrayId).toBe(3);
  726. expect(result.find((f) => f.amsId === 1 && f.trayId === 0)?.globalTrayId).toBe(4);
  727. expect(result.find((f) => f.amsId === 1 && f.trayId === 3)?.globalTrayId).toBe(7);
  728. expect(result.find((f) => f.amsId === 2 && f.trayId === 0)?.globalTrayId).toBe(8);
  729. expect(result.find((f) => f.amsId === 2 && f.trayId === 3)?.globalTrayId).toBe(11);
  730. });
  731. it('skips empty AMS-HT tray (no tray_type)', () => {
  732. const result = buildLoadedFilaments(createH2DStatus());
  733. // AMS-HT 128 is empty in real data — should be skipped
  734. const ht = result.filter((f) => f.amsId === 128);
  735. expect(ht).toHaveLength(0);
  736. });
  737. it('includes loaded external spool with correct extruder', () => {
  738. const result = buildLoadedFilaments(createH2DStatus());
  739. const ext = result.filter((f) => f.isExternal);
  740. // Only Ext-L (254) has filament, Ext-R (255) is empty
  741. expect(ext).toHaveLength(1);
  742. expect(ext[0].globalTrayId).toBe(254);
  743. expect(ext[0].type).toBe('PLA');
  744. // Ext-L (254) should be LEFT nozzle (extruder 1)
  745. expect(ext[0].extruderId).toBe(1);
  746. });
  747. it('returns 13 loaded filaments total (12 AMS + 1 external)', () => {
  748. const result = buildLoadedFilaments(createH2DStatus());
  749. // AMS 0: 4, AMS 1: 4, AMS-HT 128: 0 (empty), AMS 2: 4, External: 1
  750. expect(result).toHaveLength(13);
  751. });
  752. });
  753. describe('computeAmsMapping', () => {
  754. it('matches left-nozzle filament to left-nozzle AMS only', () => {
  755. const reqs = {
  756. filaments: [
  757. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, nozzle_id: 1 },
  758. ],
  759. };
  760. const result = computeAmsMapping(reqs, createH2DStatus());
  761. // Black PLA on LEFT: AMS 0 T4 (globalTrayId 3) is PLA Basic black on left
  762. expect(result).toEqual([3]);
  763. });
  764. it('matches right-nozzle filament to right-nozzle AMS only', () => {
  765. const reqs = {
  766. filaments: [
  767. { slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 10, nozzle_id: 0 },
  768. ],
  769. };
  770. const result = computeAmsMapping(reqs, createH2DStatus());
  771. // White PLA on RIGHT: AMS 1 T1 (globalTrayId 4) is PLA Basic white on right
  772. expect(result).toEqual([4]);
  773. });
  774. it('rejects cross-nozzle assignment (right requires type only on left)', () => {
  775. const reqs = {
  776. filaments: [
  777. // PLA-S only exists on AMS 2 T1 (left nozzle), but requires right nozzle
  778. { slot_id: 1, type: 'PLA-S', color: '#FFFFFF', used_grams: 10, nozzle_id: 0, tray_info_idx: 'P8aa1726' },
  779. ],
  780. };
  781. const result = computeAmsMapping(reqs, createH2DStatus());
  782. expect(result).toEqual([-1]); // No fallback to wrong nozzle
  783. });
  784. it('maps dual-nozzle multi-filament print correctly', () => {
  785. const reqs = {
  786. filaments: [
  787. // Slot 1: PETG white on LEFT → AMS 0 T1 (globalTrayId 0)
  788. { slot_id: 1, type: 'PETG', color: '#FFFFFF', used_grams: 30, nozzle_id: 1, tray_info_idx: 'GFG02' },
  789. // Slot 2: PLA white on RIGHT → AMS 1 T1 (globalTrayId 4)
  790. { slot_id: 2, type: 'PLA', color: '#FFFFFF', used_grams: 20, nozzle_id: 0, tray_info_idx: 'GFA00' },
  791. ],
  792. };
  793. const result = computeAmsMapping(reqs, createH2DStatus());
  794. expect(result).toEqual([0, 4]);
  795. });
  796. it('matches external spool on correct nozzle', () => {
  797. const reqs = {
  798. filaments: [
  799. // Ext-L has black PLA loaded, on LEFT nozzle (extruder 1)
  800. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 5, nozzle_id: 1, tray_info_idx: 'P4d64437' },
  801. ],
  802. };
  803. const result = computeAmsMapping(reqs, createH2DStatus());
  804. expect(result).toEqual([254]); // External spool on left nozzle
  805. });
  806. });
  807. });
  808. describe('X1C model tests (single nozzle, real data)', () => {
  809. describe('buildLoadedFilaments', () => {
  810. it('assigns all filaments to extruder 0', () => {
  811. const result = buildLoadedFilaments(createX1CStatus());
  812. result.forEach((f) => expect(f.extruderId).toBe(0));
  813. });
  814. it('computes correct globalTrayId for regular AMS', () => {
  815. const result = buildLoadedFilaments(createX1CStatus());
  816. // AMS 1 T2 (tray id 1) → globalTrayId 5
  817. expect(result.find((f) => f.amsId === 1 && f.trayId === 1)?.globalTrayId).toBe(5);
  818. // AMS 1 T3 (tray id 2) → globalTrayId 6
  819. expect(result.find((f) => f.amsId === 1 && f.trayId === 2)?.globalTrayId).toBe(6);
  820. // AMS 1 T4 (tray id 3) → globalTrayId 7
  821. expect(result.find((f) => f.amsId === 1 && f.trayId === 3)?.globalTrayId).toBe(7);
  822. });
  823. it('returns only loaded trays (3 from AMS 1)', () => {
  824. const result = buildLoadedFilaments(createX1CStatus());
  825. // AMS 0: all 4 slots empty, AMS 1: slots 1-3 loaded, External: empty
  826. expect(result).toHaveLength(3);
  827. });
  828. });
  829. describe('computeAmsMapping', () => {
  830. it('matches single-nozzle file without nozzle filtering', () => {
  831. const reqs = {
  832. filaments: [
  833. { slot_id: 1, type: 'PLA', color: '#0066FF', used_grams: 15 },
  834. ],
  835. };
  836. const result = computeAmsMapping(reqs, createX1CStatus());
  837. // Blue PLA → AMS 1 T4 (globalTrayId 7, color 0066FF)
  838. expect(result).toEqual([7]);
  839. });
  840. it('matches by tray_info_idx across AMS units', () => {
  841. const reqs = {
  842. filaments: [
  843. { slot_id: 1, type: 'PLA', color: '#EBCFA6', used_grams: 10, tray_info_idx: 'PFUS22b2' },
  844. ],
  845. };
  846. const result = computeAmsMapping(reqs, createX1CStatus());
  847. // PFUS22b2 uniquely in AMS 1 T2 (globalTrayId 5)
  848. expect(result).toEqual([5]);
  849. });
  850. it('handles non-unique tray_info_idx with color matching', () => {
  851. // P4d64437 appears in both AMS 1 T3 and T4
  852. const reqs = {
  853. filaments: [
  854. { slot_id: 1, type: 'PLA', color: '#FCECD6', used_grams: 10, tray_info_idx: 'P4d64437' },
  855. ],
  856. };
  857. const result = computeAmsMapping(reqs, createX1CStatus());
  858. // Should pick AMS 1 T3 (globalTrayId 6, color FCECD6) over T4 (0066FF)
  859. expect(result).toEqual([6]);
  860. });
  861. it('does not cross-nozzle filter for single-nozzle printer', () => {
  862. // Even if ams_extruder_map exists, single-nozzle 3MF has no nozzle_id
  863. const reqs = {
  864. filaments: [
  865. { slot_id: 1, type: 'PLA', color: '#EBCFA6', used_grams: 10 },
  866. { slot_id: 2, type: 'PLA', color: '#0066FF', used_grams: 10 },
  867. ],
  868. };
  869. const result = computeAmsMapping(reqs, createX1CStatus());
  870. // Both should match freely across all AMS units
  871. expect(result).toEqual([5, 7]);
  872. });
  873. });
  874. });
  875. describe('computeAmsMapping preferLowest', () => {
  876. it('picks spool with lowest remain when enabled', () => {
  877. const reqs = {
  878. filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
  879. };
  880. const status = createPrinterStatus([
  881. {
  882. id: 0,
  883. tray: [
  884. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },
  885. { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 },
  886. ],
  887. },
  888. ]);
  889. const result = computeAmsMapping(reqs, status, true);
  890. expect(result).toEqual([1]); // Tray 1 has 25% remain
  891. });
  892. it('picks first match when disabled', () => {
  893. const reqs = {
  894. filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
  895. };
  896. const status = createPrinterStatus([
  897. {
  898. id: 0,
  899. tray: [
  900. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },
  901. { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 },
  902. ],
  903. },
  904. ]);
  905. const result = computeAmsMapping(reqs, status, false);
  906. expect(result).toEqual([0]); // First match (default)
  907. });
  908. it('sorts unknown remain to end', () => {
  909. const reqs = {
  910. filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
  911. };
  912. const status = createPrinterStatus([
  913. {
  914. id: 0,
  915. tray: [
  916. { id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // No remain (defaults to -1)
  917. { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 60 },
  918. ],
  919. },
  920. ]);
  921. const result = computeAmsMapping(reqs, status, true);
  922. expect(result).toEqual([1]); // Known 60% over unknown
  923. });
  924. });