InventoryPageGrouping.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. /**
  2. * Tests for the spool grouping logic used in InventoryPage.
  3. *
  4. * The grouping is a pure client-side computation:
  5. * - Spools with identical material+subtype+brand+color_name+rgba+label_weight are grouped
  6. * - Only unused (weight_used === 0) and unassigned spools are eligible for grouping
  7. * - Used or assigned spools always appear individually
  8. * - Groups with only 1 member remain as singles
  9. */
  10. import { describe, it, expect } from 'vitest';
  11. import type { InventorySpool, SpoolAssignment } from '../../api/client';
  12. import { aggregateGroupSpool } from '../../utils/inventoryGrouping';
  13. // Replicate the grouping key function from InventoryPage (not exported).
  14. // Must stay in lockstep with InventoryPage.tsx::spoolGroupKey — extra_colors
  15. // and effect_type are part of the key (#1154) so multi-colour / effect
  16. // variants don't get collapsed under "Group similar".
  17. function spoolGroupKey(s: InventorySpool): string {
  18. return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.extra_colors || ''}|${s.effect_type || ''}|${s.label_weight}|${s.last_dried_at || ''}`;
  19. }
  20. type DisplayItem =
  21. | { type: 'single'; spool: InventorySpool }
  22. | { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool };
  23. // Replicate the grouping logic from InventoryPage
  24. function computeDisplayItems(
  25. sortedSpools: InventorySpool[],
  26. assignmentMap: Record<number, SpoolAssignment>,
  27. ): DisplayItem[] {
  28. const groups = new Map<string, InventorySpool[]>();
  29. for (const spool of sortedSpools) {
  30. if (spool.weight_used > 0 || assignmentMap[spool.id]) {
  31. // Will be added as singles in the walk below
  32. } else {
  33. const key = spoolGroupKey(spool);
  34. const arr = groups.get(key);
  35. if (arr) arr.push(spool);
  36. else groups.set(key, [spool]);
  37. }
  38. }
  39. const items: DisplayItem[] = [];
  40. const processedKeys = new Set<string>();
  41. for (const spool of sortedSpools) {
  42. if (spool.weight_used > 0 || assignmentMap[spool.id]) {
  43. items.push({ type: 'single', spool });
  44. continue;
  45. }
  46. const key = spoolGroupKey(spool);
  47. if (processedKeys.has(key)) continue;
  48. processedKeys.add(key);
  49. const members = groups.get(key)!;
  50. if (members.length === 1) {
  51. items.push({ type: 'single', spool: members[0] });
  52. } else {
  53. items.push({ type: 'group', key, spools: members, representative: members[0] });
  54. }
  55. }
  56. return items;
  57. }
  58. function makeSpool(overrides: Partial<InventorySpool> & { id: number }): InventorySpool {
  59. return {
  60. material: 'PLA',
  61. subtype: 'Basic',
  62. brand: 'Polymaker',
  63. color_name: 'Red',
  64. rgba: 'FF0000FF',
  65. extra_colors: null,
  66. effect_type: null,
  67. label_weight: 1000,
  68. core_weight: 250,
  69. core_weight_catalog_id: null,
  70. weight_used: 0,
  71. slicer_filament: null,
  72. slicer_filament_name: null,
  73. nozzle_temp_min: null,
  74. nozzle_temp_max: null,
  75. note: null,
  76. added_full: null,
  77. last_used: null,
  78. encode_time: null,
  79. tag_uid: null,
  80. tray_uuid: null,
  81. data_origin: null,
  82. tag_type: null,
  83. archived_at: null,
  84. created_at: '2025-01-01T00:00:00Z',
  85. updated_at: '2025-01-01T00:00:00Z',
  86. k_profiles: [],
  87. cost_per_kg: null,
  88. ...overrides,
  89. };
  90. }
  91. describe('spoolGroupKey', () => {
  92. it('generates same key for identical spools', () => {
  93. const a = makeSpool({ id: 1 });
  94. const b = makeSpool({ id: 2 });
  95. expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));
  96. });
  97. it('keeps a dried spool out of a group with an undried one (#2863)', () => {
  98. const a = makeSpool({ id: 1, last_dried_at: '2026-10-06T12:30:00' });
  99. const b = makeSpool({ id: 2 });
  100. expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
  101. });
  102. it('generates different key when material differs', () => {
  103. const a = makeSpool({ id: 1, material: 'PLA' });
  104. const b = makeSpool({ id: 2, material: 'PETG' });
  105. expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
  106. });
  107. it('generates different key when subtype differs', () => {
  108. const a = makeSpool({ id: 1, subtype: 'Basic' });
  109. const b = makeSpool({ id: 2, subtype: 'Matte' });
  110. expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
  111. });
  112. it('generates different key when brand differs', () => {
  113. const a = makeSpool({ id: 1, brand: 'Polymaker' });
  114. const b = makeSpool({ id: 2, brand: 'Bambu Lab' });
  115. expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
  116. });
  117. it('generates different key when color_name differs', () => {
  118. const a = makeSpool({ id: 1, color_name: 'Red' });
  119. const b = makeSpool({ id: 2, color_name: 'Blue' });
  120. expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
  121. });
  122. it('generates different key when label_weight differs', () => {
  123. const a = makeSpool({ id: 1, label_weight: 1000 });
  124. const b = makeSpool({ id: 2, label_weight: 500 });
  125. expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
  126. });
  127. it('generates different key when extra_colors differs (#1154)', () => {
  128. // Two spools that share the base hex but have different gradient stops
  129. // are visually different — they must not collapse under "Group similar".
  130. const a = makeSpool({ id: 1, extra_colors: 'ff0000,00ff00' });
  131. const b = makeSpool({ id: 2, extra_colors: 'ff0000,0000ff' });
  132. expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
  133. });
  134. it('generates different key when effect_type differs (#1154)', () => {
  135. const a = makeSpool({ id: 1, effect_type: 'sparkle' });
  136. const b = makeSpool({ id: 2, effect_type: 'matte' });
  137. expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
  138. });
  139. it('still groups identical multi-colour spools (#1154)', () => {
  140. // Same base + same stops + same effect → same group; the new fields
  141. // join the key but don't break the existing identical-grouping case.
  142. const a = makeSpool({ id: 1, extra_colors: 'ff0000,00ff00', effect_type: 'multicolor' });
  143. const b = makeSpool({ id: 2, extra_colors: 'ff0000,00ff00', effect_type: 'multicolor' });
  144. expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));
  145. });
  146. it('treats null and empty string subtype the same', () => {
  147. const a = makeSpool({ id: 1, subtype: null as unknown as string });
  148. const b = makeSpool({ id: 2, subtype: '' });
  149. expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));
  150. });
  151. });
  152. describe('computeDisplayItems', () => {
  153. it('groups identical unused unassigned spools', () => {
  154. const spools = [
  155. makeSpool({ id: 1 }),
  156. makeSpool({ id: 2 }),
  157. makeSpool({ id: 3 }),
  158. ];
  159. const items = computeDisplayItems(spools, {});
  160. expect(items).toHaveLength(1);
  161. expect(items[0].type).toBe('group');
  162. if (items[0].type === 'group') {
  163. expect(items[0].spools).toHaveLength(3);
  164. expect(items[0].representative.id).toBe(1);
  165. }
  166. });
  167. it('does not group spools with different properties', () => {
  168. const spools = [
  169. makeSpool({ id: 1, material: 'PLA' }),
  170. makeSpool({ id: 2, material: 'PETG' }),
  171. makeSpool({ id: 3, material: 'ABS' }),
  172. ];
  173. const items = computeDisplayItems(spools, {});
  174. expect(items).toHaveLength(3);
  175. expect(items.every((i) => i.type === 'single')).toBe(true);
  176. });
  177. it('excludes used spools from groups', () => {
  178. const spools = [
  179. makeSpool({ id: 1, weight_used: 0 }),
  180. makeSpool({ id: 2, weight_used: 100 }), // used
  181. makeSpool({ id: 3, weight_used: 0 }),
  182. ];
  183. const items = computeDisplayItems(spools, {});
  184. // 1 group (id:1, id:3) + 1 single (id:2)
  185. expect(items).toHaveLength(2);
  186. const group = items.find((i) => i.type === 'group');
  187. const single = items.find((i) => i.type === 'single');
  188. expect(group).toBeDefined();
  189. expect(single).toBeDefined();
  190. if (group?.type === 'group') {
  191. expect(group.spools).toHaveLength(2);
  192. expect(group.spools.map((s) => s.id).sort()).toEqual([1, 3]);
  193. }
  194. if (single?.type === 'single') {
  195. expect(single.spool.id).toBe(2);
  196. }
  197. });
  198. it('excludes assigned spools from groups', () => {
  199. const spools = [
  200. makeSpool({ id: 1 }),
  201. makeSpool({ id: 2 }), // assigned
  202. makeSpool({ id: 3 }),
  203. ];
  204. const assignmentMap: Record<number, SpoolAssignment> = {
  205. 2: {
  206. spool_id: 2,
  207. printer_id: 1,
  208. printer_name: 'P1S',
  209. ams_id: 0,
  210. tray_id: 0,
  211. configured: true,
  212. fingerprint_color: null,
  213. fingerprint_type: null,
  214. },
  215. };
  216. const items = computeDisplayItems(spools, assignmentMap);
  217. // 1 group (id:1, id:3) + 1 single (id:2)
  218. expect(items).toHaveLength(2);
  219. const group = items.find((i) => i.type === 'group');
  220. expect(group?.type).toBe('group');
  221. if (group?.type === 'group') {
  222. expect(group.spools.map((s) => s.id).sort()).toEqual([1, 3]);
  223. }
  224. });
  225. it('does not group a single spool', () => {
  226. const spools = [makeSpool({ id: 1 })];
  227. const items = computeDisplayItems(spools, {});
  228. expect(items).toHaveLength(1);
  229. expect(items[0].type).toBe('single');
  230. });
  231. it('preserves order — group appears at first member position', () => {
  232. const spools = [
  233. makeSpool({ id: 1, material: 'PETG' }), // unique
  234. makeSpool({ id: 2, material: 'PLA' }), // group member
  235. makeSpool({ id: 3, material: 'PLA' }), // group member
  236. makeSpool({ id: 4, material: 'ABS' }), // unique
  237. ];
  238. const items = computeDisplayItems(spools, {});
  239. expect(items).toHaveLength(3);
  240. expect(items[0].type).toBe('single'); // PETG
  241. expect(items[1].type).toBe('group'); // PLA group at position of id:2
  242. expect(items[2].type).toBe('single'); // ABS
  243. if (items[1].type === 'group') {
  244. expect(items[1].spools.map((s) => s.id)).toEqual([2, 3]);
  245. }
  246. });
  247. it('handles mix of groupable and non-groupable spools', () => {
  248. const spools = [
  249. makeSpool({ id: 1, material: 'PLA' }), // groupable
  250. makeSpool({ id: 2, material: 'PLA', weight_used: 50 }), // used → single
  251. makeSpool({ id: 3, material: 'PLA' }), // groupable
  252. makeSpool({ id: 4, material: 'PETG' }), // different → single
  253. ];
  254. const items = computeDisplayItems(spools, {});
  255. // PLA group (id:1,3) + PLA used single (id:2) + PETG single (id:4)
  256. expect(items).toHaveLength(3);
  257. });
  258. it('returns all singles when no spools can be grouped', () => {
  259. const spools = [
  260. makeSpool({ id: 1, material: 'PLA', weight_used: 100 }),
  261. makeSpool({ id: 2, material: 'PETG', weight_used: 200 }),
  262. ];
  263. const items = computeDisplayItems(spools, {});
  264. expect(items).toHaveLength(2);
  265. expect(items.every((i) => i.type === 'single')).toBe(true);
  266. });
  267. it('returns empty array for empty input', () => {
  268. const items = computeDisplayItems([], {});
  269. expect(items).toHaveLength(0);
  270. });
  271. });
  272. describe('aggregateGroupSpool (#1368)', () => {
  273. it('sums label_weight, weight_used and core_weight across members', () => {
  274. const agg = aggregateGroupSpool([
  275. makeSpool({ id: 1, label_weight: 1000, weight_used: 200, core_weight: 250 }),
  276. makeSpool({ id: 2, label_weight: 1000, weight_used: 50, core_weight: 250 }),
  277. makeSpool({ id: 3, label_weight: 1000, weight_used: 0, core_weight: 250 }),
  278. ]);
  279. expect(agg.label_weight).toBe(3000);
  280. expect(agg.weight_used).toBe(250);
  281. expect(agg.core_weight).toBe(750);
  282. });
  283. it('aggregate remaining equals the sum of per-member remaining', () => {
  284. const members = [
  285. makeSpool({ id: 1, label_weight: 1000, weight_used: 200 }), // 800
  286. makeSpool({ id: 2, label_weight: 1000, weight_used: 600 }), // 400
  287. ];
  288. const agg = aggregateGroupSpool(members);
  289. const perMember = members.reduce(
  290. (sum, s) => sum + Math.max(0, s.label_weight - s.weight_used),
  291. 0,
  292. );
  293. expect(agg.label_weight - agg.weight_used).toBe(perMember);
  294. expect(agg.label_weight - agg.weight_used).toBe(1200);
  295. });
  296. it('carries identity fields from the first member', () => {
  297. const agg = aggregateGroupSpool([
  298. makeSpool({ id: 7, material: 'PETG', brand: 'Bambu Lab', color_name: 'Blue', rgba: '0000FFFF' }),
  299. makeSpool({ id: 8, material: 'PETG', brand: 'Bambu Lab', color_name: 'Blue', rgba: '0000FFFF' }),
  300. ]);
  301. expect(agg.id).toBe(7);
  302. expect(agg.material).toBe('PETG');
  303. expect(agg.brand).toBe('Bambu Lab');
  304. expect(agg.color_name).toBe('Blue');
  305. });
  306. it('returns a member equivalent for a single-element group', () => {
  307. const agg = aggregateGroupSpool([
  308. makeSpool({ id: 1, label_weight: 750, weight_used: 100, core_weight: 200 }),
  309. ]);
  310. expect(agg.label_weight).toBe(750);
  311. expect(agg.weight_used).toBe(100);
  312. expect(agg.core_weight).toBe(200);
  313. });
  314. });