useSpoolBuddyState.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. /**
  2. * Tests for useSpoolBuddyState hook:
  3. * - Reducer handles all action types correctly
  4. * - Computed properties (remainingWeight, netWeight) work
  5. * - Window events dispatch state updates
  6. */
  7. import { describe, it, expect, vi, afterEach } from 'vitest';
  8. import { renderHook, act } from '@testing-library/react';
  9. import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
  10. function dispatchCustomEvent(name: string, detail: Record<string, unknown>) {
  11. window.dispatchEvent(new CustomEvent(name, { detail }));
  12. }
  13. describe('useSpoolBuddyState', () => {
  14. afterEach(() => {
  15. vi.restoreAllMocks();
  16. });
  17. it('starts with initial state', () => {
  18. const { result } = renderHook(() => useSpoolBuddyState());
  19. expect(result.current.weight).toBeNull();
  20. expect(result.current.weightStable).toBe(false);
  21. expect(result.current.rawAdc).toBeNull();
  22. expect(result.current.matchedSpool).toBeNull();
  23. expect(result.current.unknownTagUid).toBeNull();
  24. expect(result.current.deviceOnline).toBe(false);
  25. expect(result.current.deviceId).toBeNull();
  26. expect(result.current.remainingWeight).toBeNull();
  27. expect(result.current.netWeight).toBeNull();
  28. });
  29. it('WEIGHT_UPDATE sets weight, stable, rawAdc, deviceOnline=true', () => {
  30. const { result } = renderHook(() => useSpoolBuddyState());
  31. act(() => {
  32. dispatchCustomEvent('spoolbuddy-weight', {
  33. weight_grams: 250.5,
  34. stable: true,
  35. raw_adc: 12345,
  36. device_id: 'dev-1',
  37. });
  38. });
  39. expect(result.current.weight).toBe(250.5);
  40. expect(result.current.weightStable).toBe(true);
  41. expect(result.current.rawAdc).toBe(12345);
  42. expect(result.current.deviceOnline).toBe(true);
  43. expect(result.current.deviceId).toBe('dev-1');
  44. });
  45. it('TAG_MATCHED sets matchedSpool and clears unknownTagUid', () => {
  46. const { result } = renderHook(() => useSpoolBuddyState());
  47. // First set an unknown tag
  48. act(() => {
  49. dispatchCustomEvent('spoolbuddy-unknown-tag', {
  50. tag_uid: 'AA:BB:CC',
  51. device_id: 'dev-1',
  52. });
  53. });
  54. expect(result.current.unknownTagUid).toBe('AA:BB:CC');
  55. // Now match a spool
  56. act(() => {
  57. dispatchCustomEvent('spoolbuddy-tag-matched', {
  58. tag_uid: 'AA:BB:CC',
  59. device_id: 'dev-1',
  60. spool: {
  61. id: 42,
  62. material: 'PLA',
  63. subtype: 'Silk',
  64. color_name: 'Red',
  65. rgba: 'FF0000FF',
  66. brand: 'Bambu',
  67. label_weight: 1000,
  68. core_weight: 250,
  69. weight_used: 100,
  70. },
  71. });
  72. });
  73. expect(result.current.matchedSpool).not.toBeNull();
  74. expect(result.current.matchedSpool!.id).toBe(42);
  75. expect(result.current.matchedSpool!.material).toBe('PLA');
  76. expect(result.current.matchedSpool!.subtype).toBe('Silk');
  77. expect(result.current.matchedSpool!.color_name).toBe('Red');
  78. expect(result.current.matchedSpool!.brand).toBe('Bambu');
  79. expect(result.current.matchedSpool!.label_weight).toBe(1000);
  80. expect(result.current.matchedSpool!.core_weight).toBe(250);
  81. expect(result.current.matchedSpool!.weight_used).toBe(100);
  82. expect(result.current.unknownTagUid).toBeNull();
  83. });
  84. it('TAG_MATCHED keeps the extra colours and effect the disc is painted with (#3033)', () => {
  85. const { result } = renderHook(() => useSpoolBuddyState());
  86. const spool = { id: 23, material: 'PLA', rgba: '044482FF', label_weight: 1000, core_weight: 250, weight_used: 0 };
  87. act(() => {
  88. dispatchCustomEvent('spoolbuddy-tag-matched', {
  89. tag_uid: 'AA',
  90. spool: { ...spool, extra_colors: '044482,f8d008', effect_type: 'dual-color' },
  91. });
  92. });
  93. expect(result.current.matchedSpool!.extra_colors).toBe('044482,f8d008');
  94. expect(result.current.matchedSpool!.effect_type).toBe('dual-color');
  95. // A backend from before #3033 sends neither field.
  96. act(() => {
  97. dispatchCustomEvent('spoolbuddy-tag-matched', { tag_uid: 'BB', spool });
  98. });
  99. expect(result.current.matchedSpool!.extra_colors).toBeNull();
  100. expect(result.current.matchedSpool!.effect_type).toBeNull();
  101. });
  102. it('UNKNOWN_TAG sets unknownTagUid and clears matchedSpool', () => {
  103. const { result } = renderHook(() => useSpoolBuddyState());
  104. // First match a spool
  105. act(() => {
  106. dispatchCustomEvent('spoolbuddy-tag-matched', {
  107. tag_uid: 'AA:BB:CC',
  108. device_id: 'dev-1',
  109. spool: {
  110. id: 1,
  111. material: 'PLA',
  112. label_weight: 1000,
  113. core_weight: 250,
  114. weight_used: 0,
  115. },
  116. });
  117. });
  118. expect(result.current.matchedSpool).not.toBeNull();
  119. // Now detect unknown tag
  120. act(() => {
  121. dispatchCustomEvent('spoolbuddy-unknown-tag', {
  122. tag_uid: 'DD:EE:FF',
  123. device_id: 'dev-1',
  124. });
  125. });
  126. expect(result.current.unknownTagUid).toBe('DD:EE:FF');
  127. expect(result.current.matchedSpool).toBeNull();
  128. });
  129. it('UNKNOWN_TAG with tray_uuid sets unknownTrayUuid', () => {
  130. const { result } = renderHook(() => useSpoolBuddyState());
  131. act(() => {
  132. dispatchCustomEvent('spoolbuddy-unknown-tag', {
  133. tag_uid: 'AABB1122334455FF',
  134. tray_uuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
  135. device_id: 'dev-1',
  136. });
  137. });
  138. expect(result.current.unknownTagUid).toBe('AABB1122334455FF');
  139. expect(result.current.unknownTrayUuid).toBe('DEADBEEFDEADBEEFDEADBEEFDEADBEEF');
  140. });
  141. it('UNKNOWN_TAG keeps the data decoded from a Bambu tag until the tag is lifted (#2685)', () => {
  142. const tagData = {
  143. material: 'PLA',
  144. subtype: 'Basic',
  145. color_name: 'Cyan',
  146. rgba: '0086D6FF',
  147. extra_colors: null,
  148. effect_type: null,
  149. brand: 'Bambu Lab',
  150. label_weight: 1000,
  151. slicer_filament: 'GFA00',
  152. slicer_filament_name: 'Bambu PLA Basic',
  153. nozzle_temp_min: 190,
  154. nozzle_temp_max: 230,
  155. core_weight: 250,
  156. core_weight_catalog_id: 7,
  157. variant_id: 'A00-B08',
  158. diameter: 1.75,
  159. drying_temp: 55,
  160. drying_time: 8,
  161. };
  162. const { result } = renderHook(() => useSpoolBuddyState());
  163. act(() => {
  164. dispatchCustomEvent('spoolbuddy-unknown-tag', { tag_uid: '865947E2', device_id: 'dev-1', tag_data: tagData });
  165. });
  166. expect(result.current.unknownTagData).toEqual(tagData);
  167. act(() => {
  168. dispatchCustomEvent('spoolbuddy-tag-removed', { tag_uid: '865947E2', device_id: 'dev-1' });
  169. });
  170. expect(result.current.unknownTagData).toBeNull();
  171. });
  172. it('UNKNOWN_TAG without tag_data leaves unknownTagData empty (#2685)', () => {
  173. const { result } = renderHook(() => useSpoolBuddyState());
  174. act(() => {
  175. dispatchCustomEvent('spoolbuddy-unknown-tag', { tag_uid: 'AABB1122334455FF', device_id: 'dev-1' });
  176. });
  177. expect(result.current.unknownTagUid).toBe('AABB1122334455FF');
  178. expect(result.current.unknownTagData).toBeNull();
  179. });
  180. it('UNKNOWN_TAG without tray_uuid leaves unknownTrayUuid null', () => {
  181. const { result } = renderHook(() => useSpoolBuddyState());
  182. act(() => {
  183. dispatchCustomEvent('spoolbuddy-unknown-tag', {
  184. tag_uid: 'AABB1122',
  185. device_id: 'dev-1',
  186. });
  187. });
  188. expect(result.current.unknownTagUid).toBe('AABB1122');
  189. expect(result.current.unknownTrayUuid).toBeNull();
  190. });
  191. it('TAG_MATCHED clears unknownTrayUuid', () => {
  192. const { result } = renderHook(() => useSpoolBuddyState());
  193. // Set tray_uuid via unknown tag
  194. act(() => {
  195. dispatchCustomEvent('spoolbuddy-unknown-tag', {
  196. tag_uid: 'AABB1122334455FF',
  197. tray_uuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
  198. device_id: 'dev-1',
  199. });
  200. });
  201. expect(result.current.unknownTrayUuid).toBe('DEADBEEFDEADBEEFDEADBEEFDEADBEEF');
  202. // Match resolves it
  203. act(() => {
  204. dispatchCustomEvent('spoolbuddy-tag-matched', {
  205. tag_uid: 'AABB1122334455FF',
  206. device_id: 'dev-1',
  207. spool: {
  208. id: 5,
  209. material: 'PLA',
  210. label_weight: 1000,
  211. core_weight: 250,
  212. weight_used: 0,
  213. },
  214. });
  215. });
  216. expect(result.current.unknownTrayUuid).toBeNull();
  217. expect(result.current.matchedSpool).not.toBeNull();
  218. });
  219. it('TAG_REMOVED clears unknownTrayUuid', () => {
  220. const { result } = renderHook(() => useSpoolBuddyState());
  221. act(() => {
  222. dispatchCustomEvent('spoolbuddy-unknown-tag', {
  223. tag_uid: 'AABB1122334455FF',
  224. tray_uuid: 'CAFEBABECAFEBABECAFEBABECAFEBABE',
  225. device_id: 'dev-1',
  226. });
  227. });
  228. expect(result.current.unknownTrayUuid).toBe('CAFEBABECAFEBABECAFEBABECAFEBABE');
  229. act(() => {
  230. dispatchCustomEvent('spoolbuddy-tag-removed', { device_id: 'dev-1' });
  231. });
  232. expect(result.current.unknownTrayUuid).toBeNull();
  233. expect(result.current.unknownTagUid).toBeNull();
  234. });
  235. it('TAG_REMOVED clears both matchedSpool and unknownTagUid', () => {
  236. const { result } = renderHook(() => useSpoolBuddyState());
  237. // Set a matched spool
  238. act(() => {
  239. dispatchCustomEvent('spoolbuddy-tag-matched', {
  240. tag_uid: 'AA:BB:CC',
  241. device_id: 'dev-1',
  242. spool: {
  243. id: 1,
  244. material: 'PLA',
  245. label_weight: 1000,
  246. core_weight: 250,
  247. weight_used: 0,
  248. },
  249. });
  250. });
  251. expect(result.current.matchedSpool).not.toBeNull();
  252. // Remove tag
  253. act(() => {
  254. dispatchCustomEvent('spoolbuddy-tag-removed', { device_id: 'dev-1' });
  255. });
  256. expect(result.current.matchedSpool).toBeNull();
  257. expect(result.current.unknownTagUid).toBeNull();
  258. });
  259. it('DEVICE_ONLINE sets deviceOnline=true', () => {
  260. const { result } = renderHook(() => useSpoolBuddyState());
  261. expect(result.current.deviceOnline).toBe(false);
  262. act(() => {
  263. dispatchCustomEvent('spoolbuddy-online', { device_id: 'dev-1' });
  264. });
  265. expect(result.current.deviceOnline).toBe(true);
  266. expect(result.current.deviceId).toBe('dev-1');
  267. });
  268. it('DEVICE_OFFLINE sets deviceOnline=false and clears weight/rawAdc', () => {
  269. const { result } = renderHook(() => useSpoolBuddyState());
  270. // First get some weight data
  271. act(() => {
  272. dispatchCustomEvent('spoolbuddy-weight', {
  273. weight_grams: 500,
  274. stable: true,
  275. raw_adc: 54321,
  276. device_id: 'dev-1',
  277. });
  278. });
  279. expect(result.current.weight).toBe(500);
  280. expect(result.current.rawAdc).toBe(54321);
  281. expect(result.current.deviceOnline).toBe(true);
  282. // Go offline
  283. act(() => {
  284. dispatchCustomEvent('spoolbuddy-offline', { device_id: 'dev-1' });
  285. });
  286. expect(result.current.deviceOnline).toBe(false);
  287. expect(result.current.weight).toBeNull();
  288. expect(result.current.weightStable).toBe(false);
  289. expect(result.current.rawAdc).toBeNull();
  290. });
  291. it('computes remainingWeight from matchedSpool', () => {
  292. const { result } = renderHook(() => useSpoolBuddyState());
  293. act(() => {
  294. dispatchCustomEvent('spoolbuddy-tag-matched', {
  295. tag_uid: 'AA:BB:CC',
  296. device_id: 'dev-1',
  297. spool: {
  298. id: 1,
  299. material: 'PLA',
  300. label_weight: 1000,
  301. core_weight: 250,
  302. weight_used: 300,
  303. },
  304. });
  305. });
  306. // remainingWeight = label_weight - weight_used = 1000 - 300 = 700
  307. expect(result.current.remainingWeight).toBe(700);
  308. });
  309. it('remainingWeight is clamped to 0 when weight_used exceeds label_weight', () => {
  310. const { result } = renderHook(() => useSpoolBuddyState());
  311. act(() => {
  312. dispatchCustomEvent('spoolbuddy-tag-matched', {
  313. tag_uid: 'AA:BB:CC',
  314. device_id: 'dev-1',
  315. spool: {
  316. id: 1,
  317. material: 'PLA',
  318. label_weight: 1000,
  319. core_weight: 250,
  320. weight_used: 1200,
  321. },
  322. });
  323. });
  324. expect(result.current.remainingWeight).toBe(0);
  325. });
  326. it('computes netWeight from weight and matchedSpool core_weight', () => {
  327. const { result } = renderHook(() => useSpoolBuddyState());
  328. // Set weight first
  329. act(() => {
  330. dispatchCustomEvent('spoolbuddy-weight', {
  331. weight_grams: 800,
  332. stable: true,
  333. raw_adc: 11111,
  334. device_id: 'dev-1',
  335. });
  336. });
  337. // Match a spool
  338. act(() => {
  339. dispatchCustomEvent('spoolbuddy-tag-matched', {
  340. tag_uid: 'AA:BB:CC',
  341. device_id: 'dev-1',
  342. spool: {
  343. id: 1,
  344. material: 'PLA',
  345. label_weight: 1000,
  346. core_weight: 250,
  347. weight_used: 0,
  348. },
  349. });
  350. });
  351. // netWeight = weight - core_weight = 800 - 250 = 550
  352. expect(result.current.netWeight).toBe(550);
  353. });
  354. it('netWeight is null when weight is null', () => {
  355. const { result } = renderHook(() => useSpoolBuddyState());
  356. act(() => {
  357. dispatchCustomEvent('spoolbuddy-tag-matched', {
  358. tag_uid: 'AA:BB:CC',
  359. device_id: 'dev-1',
  360. spool: {
  361. id: 1,
  362. material: 'PLA',
  363. label_weight: 1000,
  364. core_weight: 250,
  365. weight_used: 0,
  366. },
  367. });
  368. });
  369. expect(result.current.netWeight).toBeNull();
  370. });
  371. it('netWeight is null when no matchedSpool', () => {
  372. const { result } = renderHook(() => useSpoolBuddyState());
  373. act(() => {
  374. dispatchCustomEvent('spoolbuddy-weight', {
  375. weight_grams: 800,
  376. stable: true,
  377. raw_adc: 11111,
  378. device_id: 'dev-1',
  379. });
  380. });
  381. expect(result.current.netWeight).toBeNull();
  382. });
  383. it('cleans up event listeners on unmount', () => {
  384. const removeSpy = vi.spyOn(window, 'removeEventListener');
  385. const { unmount } = renderHook(() => useSpoolBuddyState());
  386. unmount();
  387. const removedEvents = removeSpy.mock.calls.map((c) => c[0]);
  388. expect(removedEvents).toContain('spoolbuddy-weight');
  389. expect(removedEvents).toContain('spoolbuddy-tag-matched');
  390. expect(removedEvents).toContain('spoolbuddy-unknown-tag');
  391. expect(removedEvents).toContain('spoolbuddy-tag-removed');
  392. expect(removedEvents).toContain('spoolbuddy-online');
  393. expect(removedEvents).toContain('spoolbuddy-offline');
  394. });
  395. });