PrintersPageBackupGroups.test.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. /**
  2. * Tests for #1762 — `computeBackupGroups` strict identity rule.
  3. *
  4. * Slots pair ONLY when they share the same Bambu preset ID
  5. * (`tray_info_idx`). User-tagged spools without a preset never pair.
  6. * Empty slots are skipped; non-empty slots without a peer come back as
  7. * 1-member entries so the modal can list them as "Slots without a peer".
  8. */
  9. import { describe, it, expect } from 'vitest';
  10. import { computeBackupGroups } from '../../utils/amsHelpers';
  11. function ams(id: number, tray: Array<{
  12. tray_type?: string | null;
  13. tray_sub_brands?: string | null;
  14. tray_color?: string | null;
  15. tray_info_idx?: string | null;
  16. }>) {
  17. return {
  18. id,
  19. tray: tray.map((t, i) => ({
  20. id: i,
  21. tray_type: t.tray_type ?? null,
  22. tray_sub_brands: t.tray_sub_brands ?? null,
  23. tray_color: t.tray_color ?? null,
  24. tray_info_idx: t.tray_info_idx ?? null,
  25. })),
  26. };
  27. }
  28. describe('computeBackupGroups', () => {
  29. it('returns empty list for missing/empty AMS input', () => {
  30. expect(computeBackupGroups(undefined, {}, false)).toEqual([]);
  31. expect(computeBackupGroups([], {}, false)).toEqual([]);
  32. });
  33. it('skips empty slots entirely', () => {
  34. const groups = computeBackupGroups(
  35. [ams(0, [
  36. { tray_type: null, tray_color: null, tray_info_idx: null },
  37. { tray_type: null, tray_color: null, tray_info_idx: null },
  38. ])],
  39. {},
  40. false,
  41. );
  42. expect(groups).toEqual([]);
  43. });
  44. it('groups two slots in different AMS units holding the same preset', () => {
  45. const groups = computeBackupGroups(
  46. [
  47. ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
  48. ams(1, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
  49. ],
  50. {},
  51. false,
  52. );
  53. expect(groups).toHaveLength(1);
  54. expect(groups[0].presetId).toBe('GFA00');
  55. expect(groups[0].members.map((m) => m.globalTrayId)).toEqual([0, 4]);
  56. });
  57. it('STRICT rule: two slots without a preset never pair, even with matching material+colour', () => {
  58. const groups = computeBackupGroups(
  59. [
  60. ams(0, [{ tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#FF0000' }]),
  61. ams(1, [{ tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#FF0000' }]),
  62. ],
  63. {},
  64. false,
  65. );
  66. // Two lone slots — no pair.
  67. expect(groups).toHaveLength(2);
  68. expect(groups.every((g) => g.members.length === 1)).toBe(true);
  69. expect(groups.every((g) => g.presetId === null)).toBe(true);
  70. });
  71. it('does NOT group slots with different presets even if same material', () => {
  72. const groups = computeBackupGroups(
  73. [ams(0, [
  74. { tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' },
  75. { tray_type: 'PLA', tray_color: '#FFFFFF', tray_info_idx: 'GFA01' },
  76. ])],
  77. {},
  78. false,
  79. );
  80. expect(groups).toHaveLength(2);
  81. expect(groups.every((g) => g.members.length === 1)).toBe(true);
  82. });
  83. it('returns lone slots alongside pairs in the same list', () => {
  84. const groups = computeBackupGroups(
  85. [
  86. ams(0, [
  87. { tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' },
  88. { tray_type: 'PETG', tray_color: '#0000FF', tray_info_idx: 'GFG99' },
  89. ]),
  90. ams(1, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
  91. ],
  92. {},
  93. false,
  94. );
  95. // 1 pair + 1 lone, pair first by sort order.
  96. expect(groups).toHaveLength(2);
  97. expect(groups[0].members).toHaveLength(2);
  98. expect(groups[1].members).toHaveLength(1);
  99. expect(groups[1].displayName).toContain('PETG');
  100. });
  101. it('on dual-extruder printers, scopes pairs per extruder side', () => {
  102. // ams 0 = right (0), ams 1 = left (1). Same preset on different sides:
  103. // each comes back as a 1-member entry.
  104. const groups = computeBackupGroups(
  105. [
  106. ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
  107. ams(1, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
  108. ],
  109. { '0': 0, '1': 1 },
  110. true,
  111. );
  112. expect(groups).toHaveLength(2);
  113. expect(groups.every((g) => g.members.length === 1)).toBe(true);
  114. expect(groups[0].extruder).toBe(0);
  115. expect(groups[1].extruder).toBe(1);
  116. });
  117. it('on dual-extruder printers, pairs slots on the same extruder side', () => {
  118. const groups = computeBackupGroups(
  119. [
  120. ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
  121. ams(1, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
  122. ams(2, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
  123. ],
  124. { '0': 0, '1': 1, '2': 0 },
  125. true,
  126. );
  127. // Right-side pair (AMS 0 + 2), left-side lone (AMS 1).
  128. const rightPair = groups.find((g) => g.extruder === 0 && g.members.length === 2);
  129. expect(rightPair).toBeDefined();
  130. expect(rightPair!.members.map((m) => m.globalTrayId).sort((a, b) => a - b)).toEqual([0, 8]);
  131. const leftLone = groups.find((g) => g.extruder === 1);
  132. expect(leftLone).toBeDefined();
  133. expect(leftLone!.members).toHaveLength(1);
  134. });
  135. it('handles AMS-HT (single-tray, id >= 128) via getGlobalTrayId — pairs with regular AMS slot', () => {
  136. const groups = computeBackupGroups(
  137. [
  138. ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
  139. ams(128, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
  140. ],
  141. {},
  142. false,
  143. );
  144. expect(groups).toHaveLength(1);
  145. expect(groups[0].members.map((m) => m.globalTrayId).sort((a, b) => a - b)).toEqual([0, 128]);
  146. });
  147. it('STRICT colour rule: same preset, different colours do NOT pair', () => {
  148. // Reporter screenshot scenario — three PETG HF slots all sharing the
  149. // same Bambu profile ID (e.g. GFG99) but in three different colours
  150. // cannot back each other up; the firmware would correctly swap PETG HF
  151. // but the print would change colour mid-run.
  152. const groups = computeBackupGroups(
  153. [
  154. ams(0, [{ tray_type: 'PETG', tray_color: '#000000', tray_info_idx: 'GFG99' }]),
  155. ams(1, [{ tray_type: 'PETG', tray_color: '#FF0000', tray_info_idx: 'GFG99' }]),
  156. ams(2, [{ tray_type: 'PETG', tray_color: '#00FF00', tray_info_idx: 'GFG99' }]),
  157. ],
  158. {},
  159. false,
  160. );
  161. // Three lone slots — no pair.
  162. expect(groups).toHaveLength(3);
  163. expect(groups.every((g) => g.members.length === 1)).toBe(true);
  164. });
  165. it('colour normalisation: 6-char and 8-char hex of the same RGB pair correctly', () => {
  166. const groups = computeBackupGroups(
  167. [
  168. ams(0, [{ tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' }]),
  169. ams(1, [{ tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'GFA00' }]),
  170. ],
  171. {},
  172. false,
  173. );
  174. expect(groups).toHaveLength(1);
  175. expect(groups[0].members).toHaveLength(2);
  176. });
  177. it('defensively dedupes duplicate ams.id entries (first wins)', () => {
  178. // Observed in the wild: status.ams sometimes contains the same ams.id
  179. // twice (VP-aggregated switch printers, MQTT partial-update edge cases).
  180. // The modal must NOT render the same slot label with conflicting
  181. // materials — first occurrence wins, second is dropped.
  182. const groups = computeBackupGroups(
  183. [
  184. ams(0, [{ tray_type: 'PETG', tray_color: '#000000', tray_info_idx: 'GFG99' }]),
  185. ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
  186. ],
  187. {},
  188. false,
  189. );
  190. expect(groups).toHaveLength(1);
  191. expect(groups[0].displayName).toContain('PETG');
  192. });
  193. it('preserves display name + tray colour from the first slot for the modal swatch', () => {
  194. const groups = computeBackupGroups(
  195. [
  196. ams(0, [{ tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#1A1A1A', tray_info_idx: 'GFA00' }]),
  197. ams(1, [{ tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#1A1A1A', tray_info_idx: 'GFA00' }]),
  198. ],
  199. {},
  200. false,
  201. );
  202. expect(groups[0].displayName).toBe('PLA Basic');
  203. expect(groups[0].trayColor).toBe('#1A1A1A');
  204. });
  205. });