InventoryPageGrouping.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  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}`;
  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('generates different key when material differs', () => {
  98. const a = makeSpool({ id: 1, material: 'PLA' });
  99. const b = makeSpool({ id: 2, material: 'PETG' });
  100. expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
  101. });
  102. it('generates different key when subtype differs', () => {
  103. const a = makeSpool({ id: 1, subtype: 'Basic' });
  104. const b = makeSpool({ id: 2, subtype: 'Matte' });
  105. expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
  106. });
  107. it('generates different key when brand differs', () => {
  108. const a = makeSpool({ id: 1, brand: 'Polymaker' });
  109. const b = makeSpool({ id: 2, brand: 'Bambu Lab' });
  110. expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
  111. });
  112. it('generates different key when color_name differs', () => {
  113. const a = makeSpool({ id: 1, color_name: 'Red' });
  114. const b = makeSpool({ id: 2, color_name: 'Blue' });
  115. expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
  116. });
  117. it('generates different key when label_weight differs', () => {
  118. const a = makeSpool({ id: 1, label_weight: 1000 });
  119. const b = makeSpool({ id: 2, label_weight: 500 });
  120. expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
  121. });
  122. it('generates different key when extra_colors differs (#1154)', () => {
  123. // Two spools that share the base hex but have different gradient stops
  124. // are visually different — they must not collapse under "Group similar".
  125. const a = makeSpool({ id: 1, extra_colors: 'ff0000,00ff00' });
  126. const b = makeSpool({ id: 2, extra_colors: 'ff0000,0000ff' });
  127. expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
  128. });
  129. it('generates different key when effect_type differs (#1154)', () => {
  130. const a = makeSpool({ id: 1, effect_type: 'sparkle' });
  131. const b = makeSpool({ id: 2, effect_type: 'matte' });
  132. expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
  133. });
  134. it('still groups identical multi-colour spools (#1154)', () => {
  135. // Same base + same stops + same effect → same group; the new fields
  136. // join the key but don't break the existing identical-grouping case.
  137. const a = makeSpool({ id: 1, extra_colors: 'ff0000,00ff00', effect_type: 'multicolor' });
  138. const b = makeSpool({ id: 2, extra_colors: 'ff0000,00ff00', effect_type: 'multicolor' });
  139. expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));
  140. });
  141. it('treats null and empty string subtype the same', () => {
  142. const a = makeSpool({ id: 1, subtype: null as unknown as string });
  143. const b = makeSpool({ id: 2, subtype: '' });
  144. expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));
  145. });
  146. });
  147. describe('computeDisplayItems', () => {
  148. it('groups identical unused unassigned spools', () => {
  149. const spools = [
  150. makeSpool({ id: 1 }),
  151. makeSpool({ id: 2 }),
  152. makeSpool({ id: 3 }),
  153. ];
  154. const items = computeDisplayItems(spools, {});
  155. expect(items).toHaveLength(1);
  156. expect(items[0].type).toBe('group');
  157. if (items[0].type === 'group') {
  158. expect(items[0].spools).toHaveLength(3);
  159. expect(items[0].representative.id).toBe(1);
  160. }
  161. });
  162. it('does not group spools with different properties', () => {
  163. const spools = [
  164. makeSpool({ id: 1, material: 'PLA' }),
  165. makeSpool({ id: 2, material: 'PETG' }),
  166. makeSpool({ id: 3, material: 'ABS' }),
  167. ];
  168. const items = computeDisplayItems(spools, {});
  169. expect(items).toHaveLength(3);
  170. expect(items.every((i) => i.type === 'single')).toBe(true);
  171. });
  172. it('excludes used spools from groups', () => {
  173. const spools = [
  174. makeSpool({ id: 1, weight_used: 0 }),
  175. makeSpool({ id: 2, weight_used: 100 }), // used
  176. makeSpool({ id: 3, weight_used: 0 }),
  177. ];
  178. const items = computeDisplayItems(spools, {});
  179. // 1 group (id:1, id:3) + 1 single (id:2)
  180. expect(items).toHaveLength(2);
  181. const group = items.find((i) => i.type === 'group');
  182. const single = items.find((i) => i.type === 'single');
  183. expect(group).toBeDefined();
  184. expect(single).toBeDefined();
  185. if (group?.type === 'group') {
  186. expect(group.spools).toHaveLength(2);
  187. expect(group.spools.map((s) => s.id).sort()).toEqual([1, 3]);
  188. }
  189. if (single?.type === 'single') {
  190. expect(single.spool.id).toBe(2);
  191. }
  192. });
  193. it('excludes assigned spools from groups', () => {
  194. const spools = [
  195. makeSpool({ id: 1 }),
  196. makeSpool({ id: 2 }), // assigned
  197. makeSpool({ id: 3 }),
  198. ];
  199. const assignmentMap: Record<number, SpoolAssignment> = {
  200. 2: {
  201. spool_id: 2,
  202. printer_id: 1,
  203. printer_name: 'P1S',
  204. ams_id: 0,
  205. tray_id: 0,
  206. configured: true,
  207. fingerprint_color: null,
  208. fingerprint_type: null,
  209. },
  210. };
  211. const items = computeDisplayItems(spools, assignmentMap);
  212. // 1 group (id:1, id:3) + 1 single (id:2)
  213. expect(items).toHaveLength(2);
  214. const group = items.find((i) => i.type === 'group');
  215. expect(group?.type).toBe('group');
  216. if (group?.type === 'group') {
  217. expect(group.spools.map((s) => s.id).sort()).toEqual([1, 3]);
  218. }
  219. });
  220. it('does not group a single spool', () => {
  221. const spools = [makeSpool({ id: 1 })];
  222. const items = computeDisplayItems(spools, {});
  223. expect(items).toHaveLength(1);
  224. expect(items[0].type).toBe('single');
  225. });
  226. it('preserves order — group appears at first member position', () => {
  227. const spools = [
  228. makeSpool({ id: 1, material: 'PETG' }), // unique
  229. makeSpool({ id: 2, material: 'PLA' }), // group member
  230. makeSpool({ id: 3, material: 'PLA' }), // group member
  231. makeSpool({ id: 4, material: 'ABS' }), // unique
  232. ];
  233. const items = computeDisplayItems(spools, {});
  234. expect(items).toHaveLength(3);
  235. expect(items[0].type).toBe('single'); // PETG
  236. expect(items[1].type).toBe('group'); // PLA group at position of id:2
  237. expect(items[2].type).toBe('single'); // ABS
  238. if (items[1].type === 'group') {
  239. expect(items[1].spools.map((s) => s.id)).toEqual([2, 3]);
  240. }
  241. });
  242. it('handles mix of groupable and non-groupable spools', () => {
  243. const spools = [
  244. makeSpool({ id: 1, material: 'PLA' }), // groupable
  245. makeSpool({ id: 2, material: 'PLA', weight_used: 50 }), // used → single
  246. makeSpool({ id: 3, material: 'PLA' }), // groupable
  247. makeSpool({ id: 4, material: 'PETG' }), // different → single
  248. ];
  249. const items = computeDisplayItems(spools, {});
  250. // PLA group (id:1,3) + PLA used single (id:2) + PETG single (id:4)
  251. expect(items).toHaveLength(3);
  252. });
  253. it('returns all singles when no spools can be grouped', () => {
  254. const spools = [
  255. makeSpool({ id: 1, material: 'PLA', weight_used: 100 }),
  256. makeSpool({ id: 2, material: 'PETG', weight_used: 200 }),
  257. ];
  258. const items = computeDisplayItems(spools, {});
  259. expect(items).toHaveLength(2);
  260. expect(items.every((i) => i.type === 'single')).toBe(true);
  261. });
  262. it('returns empty array for empty input', () => {
  263. const items = computeDisplayItems([], {});
  264. expect(items).toHaveLength(0);
  265. });
  266. });
  267. describe('aggregateGroupSpool (#1368)', () => {
  268. it('sums label_weight, weight_used and core_weight across members', () => {
  269. const agg = aggregateGroupSpool([
  270. makeSpool({ id: 1, label_weight: 1000, weight_used: 200, core_weight: 250 }),
  271. makeSpool({ id: 2, label_weight: 1000, weight_used: 50, core_weight: 250 }),
  272. makeSpool({ id: 3, label_weight: 1000, weight_used: 0, core_weight: 250 }),
  273. ]);
  274. expect(agg.label_weight).toBe(3000);
  275. expect(agg.weight_used).toBe(250);
  276. expect(agg.core_weight).toBe(750);
  277. });
  278. it('aggregate remaining equals the sum of per-member remaining', () => {
  279. const members = [
  280. makeSpool({ id: 1, label_weight: 1000, weight_used: 200 }), // 800
  281. makeSpool({ id: 2, label_weight: 1000, weight_used: 600 }), // 400
  282. ];
  283. const agg = aggregateGroupSpool(members);
  284. const perMember = members.reduce(
  285. (sum, s) => sum + Math.max(0, s.label_weight - s.weight_used),
  286. 0,
  287. );
  288. expect(agg.label_weight - agg.weight_used).toBe(perMember);
  289. expect(agg.label_weight - agg.weight_used).toBe(1200);
  290. });
  291. it('carries identity fields from the first member', () => {
  292. const agg = aggregateGroupSpool([
  293. makeSpool({ id: 7, material: 'PETG', brand: 'Bambu Lab', color_name: 'Blue', rgba: '0000FFFF' }),
  294. makeSpool({ id: 8, material: 'PETG', brand: 'Bambu Lab', color_name: 'Blue', rgba: '0000FFFF' }),
  295. ]);
  296. expect(agg.id).toBe(7);
  297. expect(agg.material).toBe('PETG');
  298. expect(agg.brand).toBe('Bambu Lab');
  299. expect(agg.color_name).toBe('Blue');
  300. });
  301. it('returns a member equivalent for a single-element group', () => {
  302. const agg = aggregateGroupSpool([
  303. makeSpool({ id: 1, label_weight: 750, weight_used: 100, core_weight: 200 }),
  304. ]);
  305. expect(agg.label_weight).toBe(750);
  306. expect(agg.weight_used).toBe(100);
  307. expect(agg.core_weight).toBe(200);
  308. });
  309. });