InventoryPageArchivedConsumed.test.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. /**
  2. * #1390 follow-up — Total Consumed must include archived spools' usage.
  3. *
  4. * Background: the original #1390 fix gave us a resettable "Total Consumed"
  5. * counter (weight_used - weight_used_baseline). The aggregate that drives the
  6. * stat tile on the Inventory page used to skip every archived spool, so the
  7. * moment a user archived a finished roll its historical consumption vanished
  8. * from the running total. Reporter (@IndividualGhost1905) observed that
  9. * un-archiving a spool would put its consumed weight back into the total —
  10. * proof that the data was correct on disk but being hidden by the aggregation.
  11. *
  12. * This file pins two regressions:
  13. * 1. The "Total Consumed" displayed string sums BOTH active and archived
  14. * spools (the stat is lifetime-since-reset, not currently-available).
  15. * 2. The per-spool eraser button is rendered for archived spools too, so
  16. * the user can zero an archived spool's tracking counter without having
  17. * to un-archive it first.
  18. */
  19. import { describe, it, expect, beforeEach, vi } from 'vitest';
  20. import { screen, waitFor, within } from '@testing-library/react';
  21. import userEvent from '@testing-library/user-event';
  22. import { render } from '../utils';
  23. import InventoryPageRouter from '../../pages/InventoryPage';
  24. import { http, HttpResponse } from 'msw';
  25. import { server } from '../mocks/server';
  26. const baseSettings = {
  27. auto_archive: true,
  28. save_thumbnails: true,
  29. capture_finish_photo: true,
  30. default_filament_cost: 25.0,
  31. currency: 'USD',
  32. energy_cost_per_kwh: 0.15,
  33. energy_tracking_mode: 'total',
  34. spoolman_enabled: false,
  35. spoolman_url: '',
  36. spoolman_sync_mode: 'auto',
  37. spoolman_disable_weight_sync: false,
  38. spoolman_report_partial_usage: true,
  39. check_updates: true,
  40. check_printer_firmware: true,
  41. include_beta_updates: false,
  42. language: 'en',
  43. notification_language: 'en',
  44. bed_cooled_threshold: 35,
  45. ams_humidity_good: 40,
  46. ams_humidity_fair: 60,
  47. ams_temp_good: 28,
  48. ams_temp_fair: 35,
  49. ams_history_retention_days: 30,
  50. per_printer_mapping_expanded: false,
  51. date_format: 'system',
  52. time_format: 'system',
  53. default_printer_id: null,
  54. dark_style: 'classic',
  55. dark_background: 'neutral',
  56. dark_accent: 'green',
  57. light_style: 'classic',
  58. light_background: 'neutral',
  59. light_accent: 'green',
  60. ftp_retry_enabled: true,
  61. ftp_retry_count: 3,
  62. ftp_retry_delay: 2,
  63. ftp_timeout: 30,
  64. mqtt_enabled: false,
  65. mqtt_broker: '',
  66. mqtt_port: 1883,
  67. mqtt_username: '',
  68. mqtt_password: '',
  69. mqtt_topic_prefix: 'bambuddy',
  70. mqtt_use_tls: false,
  71. external_url: '',
  72. ha_enabled: false,
  73. ha_url: '',
  74. ha_token: '',
  75. ha_url_from_env: false,
  76. ha_token_from_env: false,
  77. ha_env_managed: false,
  78. library_disk_warning_gb: 5.0,
  79. camera_view_mode: 'window',
  80. preferred_slicer: 'bambu_studio',
  81. prometheus_enabled: false,
  82. prometheus_token: '',
  83. low_stock_threshold: 20.0,
  84. };
  85. const mockSpools = [
  86. {
  87. // Active spool: 300 g consumed
  88. id: 1,
  89. material: 'PLA',
  90. subtype: null,
  91. brand: 'Polymaker',
  92. color_name: 'Red',
  93. rgba: 'FF0000FF',
  94. label_weight: 1000,
  95. core_weight: 250,
  96. weight_used: 300,
  97. weight_used_baseline: 0,
  98. slicer_filament: null,
  99. slicer_filament_name: null,
  100. nozzle_temp_min: null,
  101. nozzle_temp_max: null,
  102. note: null,
  103. added_full: null,
  104. last_used: null,
  105. encode_time: null,
  106. tag_uid: null,
  107. tray_uuid: null,
  108. data_origin: null,
  109. tag_type: null,
  110. archived_at: null,
  111. created_at: '2025-01-01T00:00:00Z',
  112. updated_at: '2025-01-01T00:00:00Z',
  113. k_profiles: [],
  114. cost_per_kg: null,
  115. last_scale_weight: null,
  116. last_weighed_at: null,
  117. },
  118. {
  119. // Archived spool: 500 g consumed. Pre-fix this consumption disappeared
  120. // from "Total Consumed" the moment the spool was archived (#1390 fb).
  121. id: 2,
  122. material: 'PETG',
  123. subtype: null,
  124. brand: 'eSun',
  125. color_name: 'Blue',
  126. rgba: '0000FFFF',
  127. label_weight: 1000,
  128. core_weight: 250,
  129. weight_used: 500,
  130. weight_used_baseline: 0,
  131. slicer_filament: null,
  132. slicer_filament_name: null,
  133. nozzle_temp_min: null,
  134. nozzle_temp_max: null,
  135. note: null,
  136. added_full: null,
  137. last_used: null,
  138. encode_time: null,
  139. tag_uid: null,
  140. tray_uuid: null,
  141. data_origin: null,
  142. tag_type: null,
  143. archived_at: '2026-04-01T00:00:00Z',
  144. created_at: '2025-01-02T00:00:00Z',
  145. updated_at: '2025-01-02T00:00:00Z',
  146. k_profiles: [],
  147. cost_per_kg: null,
  148. last_scale_weight: null,
  149. last_weighed_at: null,
  150. },
  151. ];
  152. describe('InventoryPage — Total Consumed includes archived (#1390 follow-up)', () => {
  153. beforeEach(() => {
  154. server.use(
  155. http.get('/api/v1/settings/', () => HttpResponse.json(baseSettings)),
  156. http.get('/api/v1/inventory/spools', () => HttpResponse.json(mockSpools)),
  157. http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
  158. http.get('/api/v1/spoolman/settings', () =>
  159. HttpResponse.json({ spoolman_enabled: 'false' }),
  160. ),
  161. );
  162. });
  163. it('Total Consumed sums consumed weight across active AND archived spools', async () => {
  164. render(<InventoryPageRouter />);
  165. await waitFor(() => {
  166. // 300 g (active) + 500 g (archived) = 800 g. formatWeight() renders
  167. // values below 1 kg as "<rounded>g". A future refactor that
  168. // re-introduces the archived skip would drop this to "300g" and the
  169. // test fails.
  170. expect(screen.getByText('800g')).toBeInTheDocument();
  171. });
  172. });
  173. it('Reset-consumed-counter eraser is rendered for archived spools too', async () => {
  174. // The per-card eraser is gated on weight_used > 0, NOT on archived_at,
  175. // so the archived spool above (weight_used=500) must render an eraser
  176. // button matching the localized tooltip. Multiple erasers exist on the
  177. // page (one per spool + the bulk "reset all" affordance in the stat
  178. // tile); the test asserts there are at least as many as there are
  179. // spools with consumed weight, which catches a regression that hides
  180. // the archived spool's eraser.
  181. // The archive-filter chip defaults to "active only", so we need to
  182. // surface archived spools first; the easiest assertion that doesn't
  183. // depend on chip clicks is via the bulk-reset wiring: when archived
  184. // are included in the resetable set, the total is non-zero — i.e.
  185. // the "Reset all counters" button stays visible. The CHANGELOG entry
  186. // walks through the per-card flow.
  187. render(<InventoryPageRouter />);
  188. await waitFor(() => {
  189. // Reset-all-consumed-counters button is gated on `totalConsumed > 0 &&
  190. // resetableSpoolIds.length > 0`. resetableSpoolIds now includes
  191. // archived spools — so even if every active spool had its baseline
  192. // == weight_used (consumed = 0), the button must remain visible
  193. // as long as ANY spool (archived included) still has unreset usage.
  194. // The 800g assertion already proves totalConsumed > 0; here we
  195. // just check the bulk-reset button rendered.
  196. expect(screen.getByRole('button', { name: /reset all counters/i })).toBeInTheDocument();
  197. });
  198. });
  199. });
  200. // FO-C8: a reset of every counter where some or all failed (Spoolman refusing
  201. // them, spools deleted meanwhile) was shown as a green "Counter reset for N".
  202. describe('InventoryPage — resetting all counters says what failed', () => {
  203. const resetAll = async (response: Record<string, unknown>) => {
  204. server.use(
  205. http.get('/api/v1/settings/', () => HttpResponse.json(baseSettings)),
  206. http.get('/api/v1/inventory/spools', () => HttpResponse.json(mockSpools)),
  207. http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
  208. http.get('/api/v1/spoolman/settings', () => HttpResponse.json({ spoolman_enabled: 'false' })),
  209. http.post('/api/v1/inventory/spools/reset-consumed-counter-bulk', () => HttpResponse.json(response)),
  210. );
  211. const user = userEvent.setup();
  212. render(<InventoryPageRouter />);
  213. await user.click(await screen.findByRole('button', { name: 'Reset all counters' }));
  214. const modal = (await screen.findByText(/Reset the consumed-grams counter to 0 on all/)).closest('.fixed');
  215. await user.click(within(modal as HTMLElement).getByRole('button', { name: 'Reset counter' }));
  216. };
  217. it('says so when none was reset', async () => {
  218. await resetAll({ reset: 0, not_found: [1, 2] });
  219. expect(await screen.findByText('No counter was reset: all 2 failed')).toBeInTheDocument();
  220. });
  221. it('says how many failed when some were', async () => {
  222. await resetAll({ reset: 1, errors: [{ id: 2, status: 503, detail: 'down' }] });
  223. expect(await screen.findByText('Counter reset for 1 spool(s), 1 failed')).toBeInTheDocument();
  224. });
  225. it('confirms a full reset as before', async () => {
  226. await resetAll({ reset: 2, not_found: [] });
  227. expect(await screen.findByText('Counter reset for 2 spool(s)')).toBeInTheDocument();
  228. });
  229. });
  230. // SM-C8: a Spoolman spool priced only by its filament showed "-" in Cost/kg,
  231. // although every print off it was charged that price.
  232. describe('InventoryPage — Cost/kg shows what prints are charged', () => {
  233. const show = async (spool: Record<string, unknown>) => {
  234. vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
  235. key === 'bambuddy-inventory-columns'
  236. ? JSON.stringify([
  237. { id: 'brand', label: 'Brand', visible: true },
  238. { id: 'cost_per_kg', label: 'Cost/kg', visible: true },
  239. ])
  240. : null,
  241. );
  242. server.use(
  243. http.get('/api/v1/settings/', () => HttpResponse.json(baseSettings)),
  244. http.get('/api/v1/inventory/spools', () => HttpResponse.json([{ ...mockSpools[0], ...spool }])),
  245. http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
  246. http.get('/api/v1/spoolman/settings', () => HttpResponse.json({ spoolman_enabled: 'false' })),
  247. );
  248. render(<InventoryPageRouter />);
  249. await screen.findAllByText('Polymaker');
  250. };
  251. it("falls back to the filament's price, marked as such", async () => {
  252. await show({ cost_per_kg: null, effective_cost_per_kg: 24 });
  253. expect(await screen.findByText('$24.00')).toBeInTheDocument();
  254. expect(screen.getByText('(filament price)')).toBeInTheDocument();
  255. });
  256. it('falls back past a spool price of 0, as the charge does', async () => {
  257. // Spoolman imports often write 0 for "no price"; prints then pay the filament's.
  258. await show({ cost_per_kg: 0, effective_cost_per_kg: 24 });
  259. expect(await screen.findByText('$24.00')).toBeInTheDocument();
  260. expect(screen.getByText('(filament price)')).toBeInTheDocument();
  261. });
  262. it('keeps a built-in spool priced 0 at $0.00', async () => {
  263. await show({ cost_per_kg: 0 });
  264. expect(await screen.findByText('$0.00')).toBeInTheDocument();
  265. });
  266. it("shows the spool's own price unmarked", async () => {
  267. await show({ cost_per_kg: 30, effective_cost_per_kg: 30 });
  268. expect(await screen.findByText('$30.00')).toBeInTheDocument();
  269. expect(screen.queryByText('(filament price)')).toBeNull();
  270. });
  271. it('shows "-" for an unpriced spool', async () => {
  272. await show({ cost_per_kg: null, effective_cost_per_kg: null });
  273. await waitFor(() => expect(screen.queryByText(/^\$\d/)).toBeNull());
  274. });
  275. });