useFilamentMapping.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  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('extracts external spool with tray_info_idx', () => {
  77. const status = createPrinterStatus(
  78. [],
  79. [{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }]
  80. );
  81. const result = buildLoadedFilaments(status);
  82. expect(result).toHaveLength(1);
  83. expect(result[0]).toMatchObject({
  84. type: 'TPU',
  85. isExternal: true,
  86. globalTrayId: 254,
  87. trayInfoIdx: 'EXT001',
  88. });
  89. });
  90. it('skips empty trays', () => {
  91. const status = createPrinterStatus([
  92. {
  93. id: 0,
  94. tray: [
  95. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
  96. { id: 1, tray_type: '', tray_color: '' }, // Empty tray
  97. { id: 2 }, // No tray_type
  98. ],
  99. },
  100. ]);
  101. const result = buildLoadedFilaments(status);
  102. expect(result).toHaveLength(1);
  103. expect(result[0].type).toBe('PLA');
  104. });
  105. it('marks AMS-HT units correctly', () => {
  106. const status = createPrinterStatus([
  107. {
  108. id: 128, // AMS-HT typically has high ID
  109. tray: [
  110. { id: 0, tray_type: 'PLA-CF', tray_color: '000000', tray_info_idx: 'HT001' },
  111. ], // Single tray = AMS-HT
  112. },
  113. ]);
  114. const result = buildLoadedFilaments(status);
  115. expect(result[0].isHt).toBe(true);
  116. expect(result[0].globalTrayId).toBe(128); // AMS-HT uses ams_id directly
  117. });
  118. });
  119. describe('computeAmsMapping', () => {
  120. it('returns undefined for empty filament requirements', () => {
  121. const status = createPrinterStatus([
  122. {
  123. id: 0,
  124. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  125. },
  126. ]);
  127. expect(computeAmsMapping(undefined, status)).toBeUndefined();
  128. expect(computeAmsMapping({ filaments: [] }, status)).toBeUndefined();
  129. });
  130. it('returns undefined when no filaments loaded', () => {
  131. const reqs = {
  132. filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
  133. };
  134. expect(computeAmsMapping(reqs, undefined)).toBeUndefined();
  135. expect(computeAmsMapping(reqs, createPrinterStatus([]))).toBeUndefined();
  136. });
  137. it('matches by tray_info_idx with highest priority', () => {
  138. const reqs = {
  139. filaments: [
  140. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA01' },
  141. ],
  142. };
  143. const status = createPrinterStatus([
  144. {
  145. id: 0,
  146. tray: [
  147. { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' }, // Same color, wrong idx
  148. { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' }, // Exact idx match
  149. { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' }, // Same color, wrong idx
  150. ],
  151. },
  152. ]);
  153. const result = computeAmsMapping(reqs, status);
  154. expect(result).toEqual([1]); // Should pick tray 1, not tray 0
  155. });
  156. it('matches multiple identical filaments by tray_info_idx (H2D Pro scenario)', () => {
  157. // This is the exact scenario from issue #245 - multiple black PLA spools
  158. const reqs = {
  159. filaments: [
  160. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 50, tray_info_idx: 'GFA03' },
  161. ],
  162. };
  163. const status = createPrinterStatus([
  164. {
  165. id: 0,
  166. tray: [
  167. { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
  168. { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
  169. { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
  170. { id: 3, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA03' }, // This one
  171. ],
  172. },
  173. ]);
  174. const result = computeAmsMapping(reqs, status);
  175. expect(result).toEqual([3]); // Should pick tray 3, not tray 0
  176. });
  177. it('falls back to color match when tray_info_idx is empty', () => {
  178. const reqs = {
  179. filaments: [
  180. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: '' },
  181. ],
  182. };
  183. const status = createPrinterStatus([
  184. {
  185. id: 0,
  186. tray: [
  187. { id: 0, tray_type: 'PLA', tray_color: '00FF00', tray_info_idx: 'GFA00' }, // Wrong color
  188. { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA01' }, // Color match
  189. ],
  190. },
  191. ]);
  192. const result = computeAmsMapping(reqs, status);
  193. expect(result).toEqual([1]);
  194. });
  195. it('falls back to color match when tray_info_idx does not match', () => {
  196. const reqs = {
  197. filaments: [
  198. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'OLD_SPOOL' },
  199. ],
  200. };
  201. const status = createPrinterStatus([
  202. {
  203. id: 0,
  204. tray: [
  205. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'NEW_SPOOL' }, // Different idx, same color
  206. ],
  207. },
  208. ]);
  209. const result = computeAmsMapping(reqs, status);
  210. expect(result).toEqual([0]); // Falls back to color match
  211. });
  212. it('matches by type only when color differs', () => {
  213. const reqs = {
  214. filaments: [
  215. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
  216. ],
  217. };
  218. const status = createPrinterStatus([
  219. {
  220. id: 0,
  221. tray: [
  222. { id: 0, tray_type: 'PLA', tray_color: '0000FF' }, // Same type, different color
  223. ],
  224. },
  225. ]);
  226. const result = computeAmsMapping(reqs, status);
  227. expect(result).toEqual([0]); // Type-only match
  228. });
  229. it('returns -1 for unmatched slots', () => {
  230. const reqs = {
  231. filaments: [
  232. { slot_id: 1, type: 'TPU', color: '#FF0000', used_grams: 10 }, // No TPU loaded
  233. ],
  234. };
  235. const status = createPrinterStatus([
  236. {
  237. id: 0,
  238. tray: [
  239. { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
  240. ],
  241. },
  242. ]);
  243. const result = computeAmsMapping(reqs, status);
  244. expect(result).toEqual([-1]);
  245. });
  246. it('avoids duplicate tray assignment', () => {
  247. const reqs = {
  248. filaments: [
  249. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
  250. { slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 }, // Same requirements
  251. ],
  252. };
  253. const status = createPrinterStatus([
  254. {
  255. id: 0,
  256. tray: [
  257. { id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // Only one PLA
  258. ],
  259. },
  260. ]);
  261. const result = computeAmsMapping(reqs, status);
  262. expect(result).toEqual([0, -1]); // First slot gets the match, second is unmatched
  263. });
  264. it('handles multi-slot mapping with tray_info_idx', () => {
  265. const reqs = {
  266. filaments: [
  267. { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA00' },
  268. { slot_id: 2, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA02' },
  269. ],
  270. };
  271. const status = createPrinterStatus([
  272. {
  273. id: 0,
  274. tray: [
  275. { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
  276. { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
  277. { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
  278. ],
  279. },
  280. ]);
  281. const result = computeAmsMapping(reqs, status);
  282. expect(result).toEqual([0, 2]); // Each slot gets its specific tray
  283. });
  284. it('handles external spool matching', () => {
  285. const reqs = {
  286. filaments: [
  287. { slot_id: 1, type: 'TPU', color: '#0000FF', used_grams: 10, tray_info_idx: 'EXT001' },
  288. ],
  289. };
  290. const status = createPrinterStatus(
  291. [],
  292. [{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }]
  293. );
  294. const result = computeAmsMapping(reqs, status);
  295. expect(result).toEqual([254]); // External spool global ID
  296. });
  297. });
  298. describe('buildLoadedFilaments - nozzle awareness', () => {
  299. it('sets extruderId from ams_extruder_map', () => {
  300. const status = createPrinterStatus([
  301. {
  302. id: 0,
  303. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  304. },
  305. {
  306. id: 1,
  307. tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
  308. },
  309. ]);
  310. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  311. const result = buildLoadedFilaments(status);
  312. expect(result[0].extruderId).toBe(1); // AMS 0 → left nozzle
  313. expect(result[1].extruderId).toBe(0); // AMS 1 → right nozzle
  314. });
  315. it('leaves extruderId undefined when no ams_extruder_map', () => {
  316. const status = createPrinterStatus([
  317. {
  318. id: 0,
  319. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  320. },
  321. ]);
  322. const result = buildLoadedFilaments(status);
  323. expect(result[0].extruderId).toBeUndefined();
  324. });
  325. });
  326. describe('computeAmsMapping - nozzle filtering', () => {
  327. it('filters candidates by nozzle_id when set', () => {
  328. // Filament requires left nozzle (extruder 1), only AMS 0 is on left
  329. const reqs = {
  330. filaments: [
  331. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
  332. ],
  333. };
  334. const status = createPrinterStatus([
  335. {
  336. id: 0, // Left nozzle
  337. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  338. },
  339. {
  340. id: 1, // Right nozzle
  341. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  342. },
  343. ]);
  344. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  345. const result = computeAmsMapping(reqs, status);
  346. expect(result).toEqual([0]); // AMS 0, tray 0 (on left nozzle)
  347. });
  348. it('filters to right nozzle when nozzle_id=0', () => {
  349. const reqs = {
  350. filaments: [
  351. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 0 },
  352. ],
  353. };
  354. const status = createPrinterStatus([
  355. {
  356. id: 0, // Left nozzle
  357. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  358. },
  359. {
  360. id: 1, // Right nozzle
  361. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  362. },
  363. ]);
  364. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  365. const result = computeAmsMapping(reqs, status);
  366. expect(result).toEqual([4]); // AMS 1, tray 0 (global ID = 1*4+0 = 4, on right nozzle)
  367. });
  368. it('falls back to all trays when target nozzle has no trays at all', () => {
  369. // Requires nozzle_id=1 (left), but no AMS units are on left nozzle
  370. const reqs = {
  371. filaments: [
  372. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
  373. ],
  374. };
  375. const status = createPrinterStatus([
  376. {
  377. id: 0, // Right nozzle only
  378. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  379. },
  380. ]);
  381. (status as any).ams_extruder_map = { '0': 0 }; // AMS 0 → right nozzle, none on left
  382. const result = computeAmsMapping(reqs, status);
  383. expect(result).toEqual([0]); // Falls back to unfiltered (right nozzle PLA)
  384. });
  385. it('stays restricted when target nozzle has trays but wrong type', () => {
  386. // Left nozzle has PETG, right has PLA — but requires PLA on left
  387. const reqs = {
  388. filaments: [
  389. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
  390. ],
  391. };
  392. const status = createPrinterStatus([
  393. {
  394. id: 0, // Left nozzle - only PETG
  395. tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
  396. },
  397. {
  398. id: 1, // Right nozzle - has PLA
  399. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  400. },
  401. ]);
  402. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  403. const result = computeAmsMapping(reqs, status);
  404. expect(result).toEqual([-1]); // No PLA on left nozzle, stays restricted
  405. });
  406. it('skips nozzle filtering when nozzle_id is undefined', () => {
  407. const reqs = {
  408. filaments: [
  409. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }, // No nozzle_id
  410. ],
  411. };
  412. const status = createPrinterStatus([
  413. {
  414. id: 0,
  415. tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
  416. },
  417. {
  418. id: 1,
  419. tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
  420. },
  421. ]);
  422. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  423. const result = computeAmsMapping(reqs, status);
  424. expect(result).toEqual([4]); // Picks best match regardless of nozzle
  425. });
  426. it('handles dual-nozzle multi-slot mapping', () => {
  427. // Two filaments: one for left, one for right
  428. const reqs = {
  429. filaments: [
  430. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 }, // Left
  431. { slot_id: 2, type: 'PETG', color: '#00FF00', used_grams: 10, nozzle_id: 0 }, // Right
  432. ],
  433. };
  434. const status = createPrinterStatus([
  435. {
  436. id: 0, // Left nozzle
  437. tray: [
  438. { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
  439. ],
  440. },
  441. {
  442. id: 1, // Right nozzle
  443. tray: [
  444. { id: 0, tray_type: 'PETG', tray_color: '00FF00' },
  445. ],
  446. },
  447. ]);
  448. (status as any).ams_extruder_map = { '0': 1, '1': 0 };
  449. const result = computeAmsMapping(reqs, status);
  450. expect(result).toEqual([0, 4]); // Left gets AMS0-T0, Right gets AMS1-T0
  451. });
  452. });