InventoryPageGrouping.test.ts 8.8 KB

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