InventoryPageGrouping.test.ts 10 KB

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