SliceJobTrackerContext.test.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. /**
  2. * Tests for SliceJobTrackerProvider's persistent progress toast.
  3. *
  4. * The tracker shows a persistent loading toast (`slice-job-{id}`) that
  5. * updates every second with elapsed time + phase label, then is replaced
  6. * by a transient success/error toast on terminal state. Without the
  7. * persistent toast, long slices on large models produce a "is it still
  8. * running?" UX gap between the start toast and the completion toast.
  9. */
  10. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  11. import { act, render, screen } from '@testing-library/react';
  12. import { type ReactNode } from 'react';
  13. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  14. import { ToastProvider } from '../../contexts/ToastContext';
  15. import { SliceJobTrackerProvider, useSliceJobTracker } from '../../contexts/SliceJobTrackerContext';
  16. import { api } from '../../api/client';
  17. vi.mock('../../api/client', () => ({
  18. api: {
  19. getSliceJob: vi.fn(),
  20. },
  21. }));
  22. const mockApi = api as unknown as { getSliceJob: ReturnType<typeof vi.fn> };
  23. function Wrapper({ children }: { children: ReactNode }) {
  24. // A fresh QueryClient per test so invalidateQueries calls don't leak
  25. // between tests.
  26. const queryClient = new QueryClient({
  27. defaultOptions: { queries: { retry: false } },
  28. });
  29. return (
  30. <QueryClientProvider client={queryClient}>
  31. <ToastProvider>
  32. <SliceJobTrackerProvider>{children}</SliceJobTrackerProvider>
  33. </ToastProvider>
  34. </QueryClientProvider>
  35. );
  36. }
  37. function TrackTrigger({ id, name }: { id: number; name: string }) {
  38. const { trackJob } = useSliceJobTracker();
  39. return (
  40. <button onClick={() => trackJob(id, 'libraryFile', name)}>
  41. track-{id}
  42. </button>
  43. );
  44. }
  45. describe('SliceJobTrackerProvider — persistent progress toast', () => {
  46. beforeEach(() => {
  47. vi.useFakeTimers();
  48. vi.clearAllMocks();
  49. });
  50. afterEach(() => {
  51. vi.useRealTimers();
  52. });
  53. it('renders a persistent toast immediately when a job is tracked', () => {
  54. // Job stays running indefinitely — completion path is its own test.
  55. mockApi.getSliceJob.mockResolvedValue({
  56. job_id: 1,
  57. status: 'running',
  58. kind: 'library_file',
  59. source_id: 100,
  60. source_name: 'BigCube.stl',
  61. created_at: new Date().toISOString(),
  62. started_at: new Date().toISOString(),
  63. completed_at: null,
  64. });
  65. render(
  66. <Wrapper>
  67. <TrackTrigger id={1} name="BigCube.stl" />
  68. </Wrapper>,
  69. );
  70. act(() => {
  71. screen.getByText('track-1').click();
  72. });
  73. // Initial frame: the "Queued" phase before the first poll lands. The
  74. // toast must be on screen at t=0 without waiting for any tick.
  75. expect(screen.getByText(/BigCube\.stl/)).toBeDefined();
  76. expect(screen.getByText(/0s/)).toBeDefined();
  77. });
  78. it('updates elapsed time each second while the job is running', async () => {
  79. let resolveFirstPoll: () => void = () => {};
  80. const firstPollLanded = new Promise<void>((resolve) => {
  81. resolveFirstPoll = resolve;
  82. });
  83. mockApi.getSliceJob.mockImplementation(async () => {
  84. resolveFirstPoll();
  85. return {
  86. job_id: 2,
  87. status: 'running',
  88. kind: 'library_file',
  89. source_id: 101,
  90. source_name: 'TallTower.stl',
  91. created_at: new Date().toISOString(),
  92. started_at: new Date().toISOString(),
  93. completed_at: null,
  94. };
  95. });
  96. render(
  97. <Wrapper>
  98. <TrackTrigger id={2} name="TallTower.stl" />
  99. </Wrapper>,
  100. );
  101. act(() => {
  102. screen.getByText('track-2').click();
  103. });
  104. // Advance fake time past the 1.5s poll so the phase flips
  105. // pending→running before we check the message.
  106. await act(async () => {
  107. vi.advanceTimersByTime(1500);
  108. });
  109. await firstPollLanded;
  110. // Let any pending promise microtasks drain on the test loop.
  111. await act(async () => {
  112. await Promise.resolve();
  113. });
  114. // Tick another 5 seconds — elapsed should now be ~6s total since
  115. // the start, and the toast must reflect it.
  116. act(() => {
  117. vi.advanceTimersByTime(5000);
  118. });
  119. // 1500ms (poll) + 5000ms (tick) = 6500ms ≈ 6s rounded down.
  120. expect(screen.getByText(/6s/)).toBeDefined();
  121. expect(screen.getByText(/TallTower\.stl/)).toBeDefined();
  122. });
  123. it('replaces the persistent toast with a transient success toast on completion', async () => {
  124. let pollCount = 0;
  125. mockApi.getSliceJob.mockImplementation(async () => {
  126. pollCount += 1;
  127. // First poll: running. Second poll: completed.
  128. if (pollCount === 1) {
  129. return {
  130. job_id: 3,
  131. status: 'running',
  132. kind: 'library_file',
  133. source_id: 102,
  134. source_name: 'Done.stl',
  135. created_at: new Date().toISOString(),
  136. started_at: new Date().toISOString(),
  137. completed_at: null,
  138. };
  139. }
  140. return {
  141. job_id: 3,
  142. status: 'completed',
  143. kind: 'library_file',
  144. source_id: 102,
  145. source_name: 'Done.stl',
  146. created_at: new Date().toISOString(),
  147. started_at: new Date().toISOString(),
  148. completed_at: new Date().toISOString(),
  149. };
  150. });
  151. render(
  152. <Wrapper>
  153. <TrackTrigger id={3} name="Done.stl" />
  154. </Wrapper>,
  155. );
  156. act(() => {
  157. screen.getByText('track-3').click();
  158. });
  159. // Drive both polls (each 1.5s). After the second, completeJob should
  160. // dismiss the persistent toast and show the transient success toast.
  161. await act(async () => {
  162. vi.advanceTimersByTime(1500);
  163. });
  164. await act(async () => {
  165. await Promise.resolve();
  166. });
  167. await act(async () => {
  168. vi.advanceTimersByTime(1500);
  169. });
  170. await act(async () => {
  171. await Promise.resolve();
  172. await Promise.resolve();
  173. });
  174. // The progress toast text "Slicing Done.stl —" / "Queued: Done.stl"
  175. // should be gone; the success toast "Sliced Done.stl" should be up.
  176. expect(screen.queryByText(/Slicing Done\.stl —/)).toBeNull();
  177. expect(screen.queryByText(/Queued: Done\.stl/)).toBeNull();
  178. expect(screen.getByText(/Sliced Done\.stl/)).toBeDefined();
  179. });
  180. it('replaces the persistent toast with a transient error toast on failure', async () => {
  181. let pollCount = 0;
  182. mockApi.getSliceJob.mockImplementation(async () => {
  183. pollCount += 1;
  184. if (pollCount === 1) {
  185. return {
  186. job_id: 4,
  187. status: 'running',
  188. kind: 'library_file',
  189. source_id: 103,
  190. source_name: 'Broken.stl',
  191. created_at: new Date().toISOString(),
  192. started_at: new Date().toISOString(),
  193. completed_at: null,
  194. };
  195. }
  196. return {
  197. job_id: 4,
  198. status: 'failed',
  199. kind: 'library_file',
  200. source_id: 103,
  201. source_name: 'Broken.stl',
  202. created_at: new Date().toISOString(),
  203. started_at: new Date().toISOString(),
  204. completed_at: new Date().toISOString(),
  205. error_status: 500,
  206. error_detail: 'sidecar segfault',
  207. };
  208. });
  209. render(
  210. <Wrapper>
  211. <TrackTrigger id={4} name="Broken.stl" />
  212. </Wrapper>,
  213. );
  214. act(() => {
  215. screen.getByText('track-4').click();
  216. });
  217. await act(async () => {
  218. vi.advanceTimersByTime(1500);
  219. });
  220. await act(async () => {
  221. await Promise.resolve();
  222. });
  223. await act(async () => {
  224. vi.advanceTimersByTime(1500);
  225. });
  226. await act(async () => {
  227. await Promise.resolve();
  228. await Promise.resolve();
  229. });
  230. expect(screen.queryByText(/Slicing Broken\.stl —/)).toBeNull();
  231. // The failure detail must surface in the toast — generic "failed"
  232. // strings stripped of the sidecar reason were the original UX
  233. // complaint that motivated `_format_sidecar_error` on the backend.
  234. expect(screen.getByText(/sidecar segfault/)).toBeDefined();
  235. });
  236. // The backend now exposes a `progress` field on each slice-job poll
  237. // result, fed by the sidecar's --pipe channel. When a useful frame
  238. // is present the toast must show "{name} — {stage} ({percent}%) —
  239. // {elapsed}" so the user sees concrete progress instead of a wall of
  240. // elapsed time.
  241. it('weaves stage + percent into the toast when the sidecar reports progress', async () => {
  242. let pollCount = 0;
  243. mockApi.getSliceJob.mockImplementation(async () => {
  244. pollCount += 1;
  245. // First poll: running with a useful progress frame.
  246. if (pollCount === 1) {
  247. return {
  248. job_id: 7,
  249. status: 'running',
  250. kind: 'library_file',
  251. source_id: 200,
  252. source_name: 'Helmet.3mf',
  253. created_at: new Date().toISOString(),
  254. started_at: new Date().toISOString(),
  255. completed_at: null,
  256. progress: {
  257. stage: 'Generating G-code',
  258. total_percent: 75,
  259. plate_percent: 80,
  260. plate_index: 1,
  261. plate_count: 1,
  262. updated_at: Date.now(),
  263. },
  264. };
  265. }
  266. // Subsequent polls keep the same frame so the test loop stays stable.
  267. return {
  268. job_id: 7,
  269. status: 'running',
  270. kind: 'library_file',
  271. source_id: 200,
  272. source_name: 'Helmet.3mf',
  273. created_at: new Date().toISOString(),
  274. started_at: new Date().toISOString(),
  275. completed_at: null,
  276. progress: {
  277. stage: 'Generating G-code',
  278. total_percent: 75,
  279. plate_percent: 80,
  280. plate_index: 1,
  281. plate_count: 1,
  282. updated_at: Date.now(),
  283. },
  284. };
  285. });
  286. render(
  287. <Wrapper>
  288. <TrackTrigger id={7} name="Helmet.3mf" />
  289. </Wrapper>,
  290. );
  291. act(() => {
  292. screen.getByText('track-7').click();
  293. });
  294. // Drive the 1.5s poll so the progress frame lands in the ref, then
  295. // tick the 1s renderer so the toast picks it up.
  296. await act(async () => {
  297. vi.advanceTimersByTime(1500);
  298. });
  299. await act(async () => {
  300. await Promise.resolve();
  301. });
  302. act(() => {
  303. vi.advanceTimersByTime(1000);
  304. });
  305. // Toast contains the stage + percent + filename.
  306. const text = screen.getByText(/Generating G-code/);
  307. expect(text.textContent).toMatch(/Helmet\.3mf/);
  308. expect(text.textContent).toMatch(/75%/);
  309. });
  310. it('shows "Plate X of Y" prefix when the snapshot carries multi-plate loop fields (#1493)', async () => {
  311. // Cross-class slice-all wraps each per-plate sub-slice's progress
  312. // snapshot with multi_plate_index / multi_plate_count so the toast
  313. // surfaces loop position alongside the slicer's stage message.
  314. mockApi.getSliceJob.mockResolvedValue({
  315. job_id: 9,
  316. status: 'running',
  317. kind: 'library_file',
  318. source_id: 300,
  319. source_name: 'Mewtwo.3mf',
  320. created_at: new Date().toISOString(),
  321. started_at: new Date().toISOString(),
  322. completed_at: null,
  323. progress: {
  324. stage: 'Generating G-code',
  325. total_percent: 47,
  326. plate_percent: 47,
  327. plate_index: 2,
  328. plate_count: 5,
  329. updated_at: Date.now(),
  330. multi_plate_index: 2,
  331. multi_plate_count: 5,
  332. },
  333. });
  334. render(
  335. <Wrapper>
  336. <TrackTrigger id={9} name="Mewtwo.3mf" />
  337. </Wrapper>,
  338. );
  339. act(() => {
  340. screen.getByText('track-9').click();
  341. });
  342. await act(async () => {
  343. vi.advanceTimersByTime(1500);
  344. });
  345. await act(async () => {
  346. await Promise.resolve();
  347. });
  348. act(() => {
  349. vi.advanceTimersByTime(1000);
  350. });
  351. // The augmented toast carries the "Plate 2 of 5" prefix.
  352. const text = screen.getByText(/Plate 2 of 5/);
  353. expect(text.textContent).toMatch(/Mewtwo\.3mf/);
  354. expect(text.textContent).toMatch(/Generating G-code/);
  355. expect(text.textContent).toMatch(/47%/);
  356. });
  357. it('does NOT add the "Plate X of Y" prefix when the loop fields are absent', async () => {
  358. // Sanity: a normal single-plate slice (no slice-all loop) still
  359. // renders the existing progress message, no "Plate" prefix.
  360. mockApi.getSliceJob.mockResolvedValue({
  361. job_id: 10,
  362. status: 'running',
  363. kind: 'library_file',
  364. source_id: 301,
  365. source_name: 'Single.3mf',
  366. created_at: new Date().toISOString(),
  367. started_at: new Date().toISOString(),
  368. completed_at: null,
  369. progress: {
  370. stage: 'Generating G-code',
  371. total_percent: 50,
  372. plate_percent: 50,
  373. plate_index: 1,
  374. plate_count: 1,
  375. updated_at: Date.now(),
  376. },
  377. });
  378. render(
  379. <Wrapper>
  380. <TrackTrigger id={10} name="Single.3mf" />
  381. </Wrapper>,
  382. );
  383. act(() => {
  384. screen.getByText('track-10').click();
  385. });
  386. await act(async () => {
  387. vi.advanceTimersByTime(1500);
  388. });
  389. await act(async () => {
  390. await Promise.resolve();
  391. });
  392. act(() => {
  393. vi.advanceTimersByTime(1000);
  394. });
  395. expect(screen.queryByText(/Plate \d+ of \d+/)).toBeNull();
  396. const text = screen.getByText(/Generating G-code/);
  397. expect(text.textContent).toMatch(/Single\.3mf/);
  398. });
  399. it('falls back to elapsed-time message when progress is null', async () => {
  400. // Sidecar without --pipe support / pre-progress feature: state.progress
  401. // stays null and the toast shows the existing "Slicing X — 47s" text.
  402. mockApi.getSliceJob.mockResolvedValue({
  403. job_id: 8,
  404. status: 'running',
  405. kind: 'library_file',
  406. source_id: 201,
  407. source_name: 'OldSidecar.3mf',
  408. created_at: new Date().toISOString(),
  409. started_at: new Date().toISOString(),
  410. completed_at: null,
  411. progress: null,
  412. });
  413. render(
  414. <Wrapper>
  415. <TrackTrigger id={8} name="OldSidecar.3mf" />
  416. </Wrapper>,
  417. );
  418. act(() => {
  419. screen.getByText('track-8').click();
  420. });
  421. await act(async () => {
  422. vi.advanceTimersByTime(1500);
  423. });
  424. await act(async () => {
  425. await Promise.resolve();
  426. });
  427. act(() => {
  428. vi.advanceTimersByTime(1000);
  429. });
  430. // The "Slicing X — Ns" / "Queued: X — Ns" fallback must still render
  431. // — the absence of progress mustn't blank the toast.
  432. expect(screen.getByText(/OldSidecar\.3mf/)).toBeDefined();
  433. // No progress percent is shown when null.
  434. expect(screen.queryByText(/%/)).toBeNull();
  435. });
  436. });