onboarding.test.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. import { describe, it, expect, vi, beforeEach } from 'vitest';
  2. import { render, screen, waitFor } from './utils';
  3. import userEvent from '@testing-library/user-event';
  4. import { OnboardingFlow } from '../components/onboarding/OnboardingFlow';
  5. import * as OnboardingContextModule from '../contexts/OnboardingContext';
  6. const setStatusMock = vi.fn().mockResolvedValue(undefined);
  7. function mockOnboarding(overrides: Partial<ReturnType<typeof OnboardingContextModule.useOnboarding>>) {
  8. vi.spyOn(OnboardingContextModule, 'useOnboarding').mockReturnValue({
  9. status: null,
  10. snoozedUntil: null,
  11. isLoaded: true,
  12. loadFailed: false,
  13. setStatus: setStatusMock,
  14. ...overrides,
  15. });
  16. }
  17. beforeEach(() => {
  18. setStatusMock.mockClear();
  19. });
  20. describe('OnboardingFlow eligibility', () => {
  21. it('does not render anything while the provider is still loading', () => {
  22. mockOnboarding({ isLoaded: false });
  23. render(<OnboardingFlow />);
  24. expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
  25. });
  26. it('does not render when the initial GET errored — distinguishing "new user" from "API down" is unsafe', () => {
  27. mockOnboarding({ loadFailed: true });
  28. render(<OnboardingFlow />);
  29. expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
  30. });
  31. it('renders the welcome modal when status is null (new user)', () => {
  32. mockOnboarding({ status: null });
  33. render(<OnboardingFlow />);
  34. expect(screen.getByRole('dialog')).toBeInTheDocument();
  35. });
  36. it('stays hidden when status is dismissed', () => {
  37. mockOnboarding({ status: 'dismissed' });
  38. render(<OnboardingFlow />);
  39. expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
  40. });
  41. it('stays hidden when status is completed_tour', () => {
  42. mockOnboarding({ status: 'completed_tour' });
  43. render(<OnboardingFlow />);
  44. expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
  45. });
  46. it('stays hidden when status is dismissed_at_migration', () => {
  47. mockOnboarding({ status: 'dismissed_at_migration' });
  48. render(<OnboardingFlow />);
  49. expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
  50. });
  51. it('stays hidden when the snooze window has not yet elapsed', () => {
  52. const future = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
  53. mockOnboarding({ status: 'snoozed', snoozedUntil: future });
  54. render(<OnboardingFlow />);
  55. expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
  56. });
  57. it('renders the welcome modal again once the snooze window has elapsed', () => {
  58. const past = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
  59. mockOnboarding({ status: 'snoozed', snoozedUntil: past });
  60. render(<OnboardingFlow />);
  61. expect(screen.getByRole('dialog')).toBeInTheDocument();
  62. });
  63. it('falls back to hidden when snoozedUntil is malformed', () => {
  64. mockOnboarding({ status: 'snoozed', snoozedUntil: 'not-a-date' });
  65. render(<OnboardingFlow />);
  66. expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
  67. });
  68. });
  69. describe('Welcome modal interactions', () => {
  70. it('persists "dismissed" when the user clicks "I\'m experienced"', async () => {
  71. mockOnboarding({ status: null });
  72. render(<OnboardingFlow />);
  73. const user = userEvent.setup();
  74. // The "experienced" button has the second-largest weight in the modal
  75. // (first is "Start tour", which advances rather than persists).
  76. const buttons = screen.getAllByRole('button');
  77. const experiencedButton = buttons[1];
  78. await user.click(experiencedButton);
  79. expect(setStatusMock).toHaveBeenCalledWith('dismissed');
  80. });
  81. it('persists "snoozed" with a future ISO timestamp when the user clicks "Remind me later"', async () => {
  82. mockOnboarding({ status: null });
  83. render(<OnboardingFlow />);
  84. const user = userEvent.setup();
  85. const buttons = screen.getAllByRole('button');
  86. const snoozeButton = buttons[2];
  87. await user.click(snoozeButton);
  88. expect(setStatusMock).toHaveBeenCalledTimes(1);
  89. const [status, snoozedUntil] = setStatusMock.mock.calls[0];
  90. expect(status).toBe('snoozed');
  91. expect(typeof snoozedUntil).toBe('string');
  92. const snoozeMs = new Date(snoozedUntil as string).getTime();
  93. const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
  94. // Allow a small skew window — the test runs concurrently with the click.
  95. expect(snoozeMs).toBeGreaterThan(Date.now() + sevenDaysMs - 5000);
  96. expect(snoozeMs).toBeLessThan(Date.now() + sevenDaysMs + 5000);
  97. });
  98. it('advances to the About modal when the user clicks "Start tour"', async () => {
  99. mockOnboarding({ status: null });
  100. render(<OnboardingFlow />);
  101. const user = userEvent.setup();
  102. const buttons = screen.getAllByRole('button');
  103. // First button is "Start tour"
  104. await user.click(buttons[0]);
  105. // setStatus should NOT be called yet — advance happens via local phase state
  106. expect(setStatusMock).not.toHaveBeenCalled();
  107. // The dialog is still mounted (now the About modal); its labelled-by id changes.
  108. await waitFor(() => {
  109. const dialog = screen.getByRole('dialog');
  110. expect(dialog.getAttribute('aria-labelledby')).toBe('onboarding-about-title');
  111. });
  112. });
  113. });
  114. describe('About modal interactions', () => {
  115. it('launches the tour engine (tour_in_progress:<first-step>) when the user clicks Done from the About modal', async () => {
  116. mockOnboarding({ status: null });
  117. render(<OnboardingFlow />);
  118. const user = userEvent.setup();
  119. // Click Start tour to get to the About modal
  120. const welcomeButtons = screen.getAllByRole('button');
  121. await user.click(welcomeButtons[0]);
  122. await waitFor(() => {
  123. const dialog = screen.getByRole('dialog');
  124. expect(dialog.getAttribute('aria-labelledby')).toBe('onboarding-about-title');
  125. });
  126. // About modal has two buttons: Skip (left), Done (right)
  127. const aboutButtons = screen.getAllByRole('button');
  128. await user.click(aboutButtons[aboutButtons.length - 1]);
  129. // Done should set status to the first tour step, NOT completed_tour. The
  130. // engine takes over once OnboardingFlow sees the tour_in_progress prefix.
  131. expect(setStatusMock).toHaveBeenCalledTimes(1);
  132. const [status] = setStatusMock.mock.calls[0];
  133. expect(status).toMatch(/^tour_in_progress:/);
  134. });
  135. it('persists "dismissed" when the user clicks Skip from the About modal', async () => {
  136. mockOnboarding({ status: null });
  137. render(<OnboardingFlow />);
  138. const user = userEvent.setup();
  139. const welcomeButtons = screen.getAllByRole('button');
  140. await user.click(welcomeButtons[0]);
  141. await waitFor(() => {
  142. const dialog = screen.getByRole('dialog');
  143. expect(dialog.getAttribute('aria-labelledby')).toBe('onboarding-about-title');
  144. });
  145. const aboutButtons = screen.getAllByRole('button');
  146. // Skip is the first of the two action buttons
  147. await user.click(aboutButtons[0]);
  148. expect(setStatusMock).toHaveBeenCalledWith('dismissed');
  149. });
  150. });