diff --git a/apps/mobile/__tests__/components/chat/action-chips.test.tsx b/apps/mobile/__tests__/components/chat/action-chips.test.tsx index b2739df8c..186dd8e78 100644 --- a/apps/mobile/__tests__/components/chat/action-chips.test.tsx +++ b/apps/mobile/__tests__/components/chat/action-chips.test.tsx @@ -84,18 +84,24 @@ function findPressableByType(root: any) { } describe('ActionChips (mobile)', () => { - it('renders successful Create chip as a Pressable when onChipClick is provided', () => { + it.each<{ name: string; overrides: Partial; handler: boolean; expected: number }>([ + { name: 'renders successful Create chip as a Pressable when onChipClick is provided', overrides: { type: 'CreateHabit', status: 'Success', entityId: 'h-1' }, handler: true, expected: 1 }, + { name: 'does not render Delete chip as Pressable even with handler', overrides: { type: 'DeleteHabit', status: 'Success', entityId: 'h-1' }, handler: true, expected: 0 }, + { name: 'does not render DeleteGoal chip as Pressable even with handler', overrides: { type: 'DeleteGoal', status: 'Success', entityId: 'g-1' }, handler: true, expected: 0 }, + { name: 'does not render Failed chip as Pressable even with handler', overrides: { type: 'CreateHabit', status: 'Failed', entityId: 'h-1', error: 'oops' }, handler: true, expected: 0 }, + { name: 'does not render as Pressable when no handler is provided', overrides: { type: 'CreateHabit', status: 'Success', entityId: 'h-1' }, handler: false, expected: 0 }, + { name: 'does not render chip with null entityId as Pressable', overrides: { type: 'CreateHabit', status: 'Success', entityId: null }, handler: true, expected: 0 }, + ])('$name', ({ overrides, handler, expected }) => { let tree: any TestRenderer.act(() => { tree = TestRenderer.create( {}} + actions={[makeAction(overrides)]} + onChipClick={handler ? () => {} : undefined} />, ) }) - const pressables = findPressableByType(tree.root) - expect(pressables.length).toBe(1) + expect(findPressableByType(tree.root).length).toBe(expected) }) it('calls onChipClick with entityId and actionType on press', () => { @@ -134,20 +140,6 @@ describe('ActionChips (mobile)', () => { expect(onChipClick).toHaveBeenCalledWith('g-42', 'UpdateGoal') }) - it('does not render Delete chip as Pressable even with handler', () => { - let tree: any - TestRenderer.act(() => { - tree = TestRenderer.create( - {}} - />, - ) - }) - const pressables = findPressableByType(tree.root) - expect(pressables.length).toBe(0) - }) - it('does not render tag mutation chips as Pressable even with handler', () => { for (const type of ['CreateTag', 'UpdateTag', 'DeleteTag']) { let tree: any @@ -163,47 +155,6 @@ describe('ActionChips (mobile)', () => { } }) - it('does not render DeleteGoal chip as Pressable even with handler', () => { - let tree: any - TestRenderer.act(() => { - tree = TestRenderer.create( - {}} - />, - ) - }) - const pressables = findPressableByType(tree.root) - expect(pressables.length).toBe(0) - }) - - it('does not render Failed chip as Pressable even with handler', () => { - let tree: any - TestRenderer.act(() => { - tree = TestRenderer.create( - {}} - />, - ) - }) - expect(findPressableByType(tree.root).length).toBe(0) - }) - - it('does not render as Pressable when no handler is provided', () => { - let tree: any - TestRenderer.act(() => { - tree = TestRenderer.create( - , - ) - }) - expect(findPressableByType(tree.root).length).toBe(0) - }) - it('renders localized labels for the new tag and reorder action types', () => { const cases: Array<[string, string]> = [ ['CreateTag', 'chat.action.createdTag'], @@ -228,16 +179,4 @@ describe('ActionChips (mobile)', () => { } }) - it('does not render chip with null entityId as Pressable', () => { - let tree: any - TestRenderer.act(() => { - tree = TestRenderer.create( - {}} - />, - ) - }) - expect(findPressableByType(tree.root).length).toBe(0) - }) }) diff --git a/apps/mobile/__tests__/components/chat/clarification-card.test.tsx b/apps/mobile/__tests__/components/chat/clarification-card.test.tsx index aee2cfda0..0732c8995 100644 --- a/apps/mobile/__tests__/components/chat/clarification-card.test.tsx +++ b/apps/mobile/__tests__/components/chat/clarification-card.test.tsx @@ -186,8 +186,13 @@ describe('ClarificationCard (mobile)', () => { expect(successNodes.length).toBeGreaterThan(0) }) - it('shows expired-error text when resolve throws a 404', async () => { - mutateAsync.mockRejectedValueOnce(Object.assign(new Error('expired'), { status: 404 })) + it.each<{ name: string; error: Error; expectedKey: string }>([ + { name: 'shows expired-error text when resolve throws a 404', error: Object.assign(new Error('expired'), { status: 404 }), expectedKey: 'habits.clarification.errorExpired' }, + { name: 'shows generic-error text when resolve throws a non-404 error', error: new Error('network error'), expectedKey: 'habits.clarification.errorGeneric' }, + { name: 'shows already-resolved error when resolve throws a 409', error: Object.assign(new Error('conflict'), { status: 409 }), expectedKey: 'habits.clarification.errorAlreadyResolved' }, + { name: 'shows expired-error text when resolve throws a 410 Gone', error: Object.assign(new Error('gone'), { status: 410 }), expectedKey: 'habits.clarification.errorExpired' }, + ])('$name', async ({ error, expectedKey }) => { + mutateAsync.mockRejectedValueOnce(error) let tree!: TestInstance await TestRenderer.act(async () => { @@ -200,62 +205,7 @@ describe('ClarificationCard (mobile)', () => { await firstButton.props.onPress!() }) - const errorNodes = findTextNodesWithChild(tree.root, 'habits.clarification.errorExpired') - expect(errorNodes.length).toBeGreaterThan(0) - }) - - it('shows generic-error text when resolve throws a non-404 error', async () => { - // No .status property — exercises the `status === 0` fallback path. - mutateAsync.mockRejectedValueOnce(new Error('network error')) - - let tree!: TestInstance - await TestRenderer.act(async () => { - tree = TestRenderer.create() - }) - - const [firstButton] = findPressables(tree.root) - if (!firstButton?.props.onPress) throw new Error('first button missing onPress') - await TestRenderer.act(async () => { - await firstButton.props.onPress!() - }) - - const errorNodes = findTextNodesWithChild(tree.root, 'habits.clarification.errorGeneric') - expect(errorNodes.length).toBeGreaterThan(0) - }) - - it('shows already-resolved error when resolve throws a 409', async () => { - mutateAsync.mockRejectedValueOnce(Object.assign(new Error('conflict'), { status: 409 })) - - let tree!: TestInstance - await TestRenderer.act(async () => { - tree = TestRenderer.create() - }) - - const [firstButton] = findPressables(tree.root) - if (!firstButton?.props.onPress) throw new Error('first button missing onPress') - await TestRenderer.act(async () => { - await firstButton.props.onPress!() - }) - - const errorNodes = findTextNodesWithChild(tree.root, 'habits.clarification.errorAlreadyResolved') - expect(errorNodes.length).toBeGreaterThan(0) - }) - - it('shows expired-error text when resolve throws a 410 Gone', async () => { - mutateAsync.mockRejectedValueOnce(Object.assign(new Error('gone'), { status: 410 })) - - let tree!: TestInstance - await TestRenderer.act(async () => { - tree = TestRenderer.create() - }) - - const [firstButton] = findPressables(tree.root) - if (!firstButton?.props.onPress) throw new Error('first button missing onPress') - await TestRenderer.act(async () => { - await firstButton.props.onPress!() - }) - - const errorNodes = findTextNodesWithChild(tree.root, 'habits.clarification.errorExpired') + const errorNodes = findTextNodesWithChild(tree.root, expectedKey) expect(errorNodes.length).toBeGreaterThan(0) }) diff --git a/apps/web/__tests__/app/today-page.test.tsx b/apps/web/__tests__/app/today-page.test.tsx index 2c3ddb7a1..28f16c3e2 100644 --- a/apps/web/__tests__/app/today-page.test.tsx +++ b/apps/web/__tests__/app/today-page.test.tsx @@ -366,37 +366,19 @@ describe('TodayPage bulk parent prompts', () => { }) }) - it('navigates to the previous day with a date query param', () => { - dateParamState.value = '2026-04-07' - uiState.isSelectMode = false - - renderPage() - - fireEvent.click(screen.getByLabelText('dates.previousDay')) - - expect(mockRouterPush).toHaveBeenCalledWith('/?date=2026-04-06') - }) - - it('navigates to the next day with a date query param', () => { - dateParamState.value = '2026-04-07' - uiState.isSelectMode = false - - renderPage() - - fireEvent.click(screen.getByLabelText('dates.nextDay')) - - expect(mockRouterPush).toHaveBeenCalledWith('/?date=2026-04-08') - }) - - it('returns to today via the bare route when pressing the date label', () => { - dateParamState.value = '2026-04-06' + it.each([ + { name: 'navigates to the previous day with a date query param', date: '2026-04-07', label: 'dates.previousDay', target: '/?date=2026-04-06' }, + { name: 'navigates to the next day with a date query param', date: '2026-04-07', label: 'dates.nextDay', target: '/?date=2026-04-08' }, + { name: 'returns to today via the bare route when pressing the date label', date: '2026-04-06', label: 'dates.goToToday', target: '/' }, + ])('$name', ({ date, label, target }) => { + dateParamState.value = date uiState.isSelectMode = false renderPage() - fireEvent.click(screen.getByLabelText('dates.goToToday')) + fireEvent.click(screen.getByLabelText(label)) - expect(mockRouterPush).toHaveBeenCalledWith('/') + expect(mockRouterPush).toHaveBeenCalledWith(target) }) it('renders today on the bare route and the pinned day on a date deep link', () => { diff --git a/apps/web/__tests__/components/chat/conflict-warning.test.tsx b/apps/web/__tests__/components/chat/conflict-warning.test.tsx index a0a1acd87..aaa8750ee 100644 --- a/apps/web/__tests__/components/chat/conflict-warning.test.tsx +++ b/apps/web/__tests__/components/chat/conflict-warning.test.tsx @@ -18,29 +18,16 @@ function makeWarning(overrides: Partial = {}): ConflictWarn } describe('ConflictWarning', () => { - it('renders with HIGH severity styling', () => { - const { container } = render( - , - ) - const wrapper = container.firstElementChild - expect(wrapper?.getAttribute('data-severity')).toBe('HIGH') - }) - - it('renders with MEDIUM severity styling', () => { - const { container } = render( - , - ) - const wrapper = container.firstElementChild - expect(wrapper?.getAttribute('data-severity')).toBe('MEDIUM') - }) - - it('renders with LOW severity styling', () => { - const { container } = render( - , - ) - const wrapper = container.firstElementChild - expect(wrapper?.getAttribute('data-severity')).toBe('LOW') - }) + it.each(['HIGH', 'MEDIUM', 'LOW'] as const)( + 'renders with %s severity styling', + (severity) => { + const { container } = render( + , + ) + const wrapper = container.firstElementChild + expect(wrapper?.getAttribute('data-severity')).toBe(severity) + }, + ) it('renders conflicting habits', () => { const warning = makeWarning({ diff --git a/apps/web/__tests__/components/gamification/level-up-overlay.test.tsx b/apps/web/__tests__/components/gamification/level-up-overlay.test.tsx index 6dcb25343..8d555087b 100644 --- a/apps/web/__tests__/components/gamification/level-up-overlay.test.tsx +++ b/apps/web/__tests__/components/gamification/level-up-overlay.test.tsx @@ -35,25 +35,15 @@ describe('LevelUpOverlay', () => { expect(alert).toBeInTheDocument() }) - it('displays the new level number padded to two digits', () => { + it.each([ + { name: 'displays the new level number padded to two digits', text: '05' }, + { name: 'displays level up title', text: 'gamification.levelUp.title' }, + { name: 'displays steady hand quiet copy', text: 'gamification.levelUp.steadyHand' }, + ])('$name', ({ text }) => { render( , ) - expect(document.body.textContent).toContain('05') - }) - - it('displays level up title', () => { - render( - , - ) - expect(document.body.textContent).toContain('gamification.levelUp.title') - }) - - it('displays steady hand quiet copy', () => { - render( - , - ) - expect(document.body.textContent).toContain('gamification.levelUp.steadyHand') + expect(document.body.textContent).toContain(text) }) it('renders the rotating orbit ring SVG', () => { diff --git a/apps/web/__tests__/components/gamification/streak-celebration.test.tsx b/apps/web/__tests__/components/gamification/streak-celebration.test.tsx index 922f9efc7..58b43d958 100644 --- a/apps/web/__tests__/components/gamification/streak-celebration.test.tsx +++ b/apps/web/__tests__/components/gamification/streak-celebration.test.tsx @@ -49,16 +49,14 @@ describe('StreakCelebration', () => { expect(document.querySelector('[role="status"]')).toBeInTheDocument() }) - it('displays the streak count', () => { - mockStreakCelebration = { streak: 14 } + it.each([ + { name: 'displays the streak count', streak: 14, text: '14' }, + { name: 'shows milestone encouragement for milestone streaks', streak: 30, text: 'streakDisplay.celebration.milestone' }, + { name: 'renders the Streak eyebrow label', streak: 3, text: 'streakDisplay.celebration.eyebrow' }, + ])('$name', ({ streak, text }) => { + mockStreakCelebration = { streak } render() - expect(document.body.textContent).toContain('14') - }) - - it('shows milestone encouragement for milestone streaks', () => { - mockStreakCelebration = { streak: 30 } - render() - expect(document.body.textContent).toContain('streakDisplay.celebration.milestone') + expect(document.body.textContent).toContain(text) }) it('renders Saturn-ring concentric rings via RingMotif', () => { @@ -68,12 +66,6 @@ describe('StreakCelebration', () => { expect(rings?.querySelectorAll('span').length).toBe(4) }) - it('renders the Streak eyebrow label', () => { - mockStreakCelebration = { streak: 3 } - render() - expect(document.body.textContent).toContain('streakDisplay.celebration.eyebrow') - }) - it('dismisses on click', () => { mockStreakCelebration = { streak: 5 } render() diff --git a/apps/web/__tests__/components/goals/goal-detail-drawer.test.tsx b/apps/web/__tests__/components/goals/goal-detail-drawer.test.tsx index 4224f49f4..c891e7545 100644 --- a/apps/web/__tests__/components/goals/goal-detail-drawer.test.tsx +++ b/apps/web/__tests__/components/goals/goal-detail-drawer.test.tsx @@ -96,53 +96,19 @@ describe('GoalDetailDrawer', () => { expect(screen.getByText('Read 12 books')).toBeInTheDocument() }) - it('renders progress section', () => { + it.each([ + { name: 'renders progress section', text: 'goals.progress' }, + { name: 'renders progress info text', text: 'goals.progressOf' }, + { name: 'renders update progress button for active goals', text: 'goals.updateProgress' }, + { name: 'renders edit action', text: 'goals.detail.edit' }, + { name: 'renders mark completed action for active goals', text: 'goals.detail.markCompleted' }, + { name: 'renders mark abandoned action for active goals', text: 'goals.detail.markAbandoned' }, + { name: 'renders delete action', text: 'goals.detail.delete' }, + ])('$name', ({ text }) => { render( , ) - expect(document.body.textContent).toContain('goals.progress') - }) - - it('renders progress info text', () => { - render( - , - ) - expect(document.body.textContent).toContain('goals.progressOf') - }) - - it('renders update progress button for active goals', () => { - render( - , - ) - expect(document.body.textContent).toContain('goals.updateProgress') - }) - - it('renders edit action', () => { - render( - , - ) - expect(document.body.textContent).toContain('goals.detail.edit') - }) - - it('renders mark completed action for active goals', () => { - render( - , - ) - expect(document.body.textContent).toContain('goals.detail.markCompleted') - }) - - it('renders mark abandoned action for active goals', () => { - render( - , - ) - expect(document.body.textContent).toContain('goals.detail.markAbandoned') - }) - - it('renders delete action', () => { - render( - , - ) - expect(document.body.textContent).toContain('goals.detail.delete') + expect(document.body.textContent).toContain(text) }) it('renders metrics panel for active goals', () => { diff --git a/apps/web/__tests__/components/habits/habit-form-fields.test.tsx b/apps/web/__tests__/components/habits/habit-form-fields.test.tsx index b361c3f91..ef0b31e4e 100644 --- a/apps/web/__tests__/components/habits/habit-form-fields.test.tsx +++ b/apps/web/__tests__/components/habits/habit-form-fields.test.tsx @@ -188,7 +188,13 @@ describe('HabitFormFields', () => { mockHasProAccess = false }) - it('renders without crashing', () => { + it.each([ + { name: 'renders without crashing', text: 'habits.form.title' }, + { name: 'renders the title input label', text: 'habits.form.title' }, + { name: 'renders the description input label', text: 'habits.form.description' }, + { name: 'shows tags section label', text: 'habits.form.tags' }, + { name: 'shows checklist section', text: 'habits.form.checklist' }, + ])('$name', ({ text }) => { const formHelpers = createMockFormHelpers() const tags = createMockTags() renderWithProviders( @@ -202,41 +208,7 @@ describe('HabitFormFields', () => { onReminderTimesChange={vi.fn()} />, ) - expect(screen.getByText('habits.form.title')).toBeDefined() - }) - - it('renders the title input label', () => { - const formHelpers = createMockFormHelpers() - const tags = createMockTags() - renderWithProviders( - , - ) - expect(screen.getByText('habits.form.title')).toBeDefined() - }) - - it('renders the description input label', () => { - const formHelpers = createMockFormHelpers() - const tags = createMockTags() - renderWithProviders( - , - ) - expect(screen.getByText('habits.form.description')).toBeDefined() + expect(screen.getByText(text)).toBeDefined() }) it('opens a searchable emoji picker from the whole emoji field', async () => { @@ -383,23 +355,6 @@ describe('HabitFormFields', () => { expect(screen.getByText('habits.form.habitTypeAvoid')).toBeDefined() }) - it('shows tags section label', () => { - const formHelpers = createMockFormHelpers() - const tags = createMockTags() - renderWithProviders( - , - ) - expect(screen.getByText('habits.form.tags')).toBeDefined() - }) - it('shows day picker when showDayPicker is true', () => { const formHelpers = createMockFormHelpers({ showDayPicker: true, isGeneral: false }) const tags = createMockTags() @@ -437,23 +392,6 @@ describe('HabitFormFields', () => { expect(screen.getByText('Custom Content')).toBeDefined() }) - it('shows checklist section', () => { - const formHelpers = createMockFormHelpers() - const tags = createMockTags() - renderWithProviders( - , - ) - expect(screen.getByText('habits.form.checklist')).toBeDefined() - }) - it('shows goal linking field', () => { mockHasProAccess = true const formHelpers = createMockFormHelpers() diff --git a/apps/web/__tests__/components/onboarding/feature-guide-drawer.test.tsx b/apps/web/__tests__/components/onboarding/feature-guide-drawer.test.tsx index 74f40ba29..b16f9f30e 100644 --- a/apps/web/__tests__/components/onboarding/feature-guide-drawer.test.tsx +++ b/apps/web/__tests__/components/onboarding/feature-guide-drawer.test.tsx @@ -52,68 +52,21 @@ describe('FeatureGuideDrawer', () => { expect(document.body.textContent).toContain('onboarding.featureGuide.astraSection.canDoTitle') }) - it('switches to connect section when connect tab clicked', () => { + it.each([ + { tab: 'connect', title: 'onboarding.featureGuide.connectSection.mcpTitle' }, + { tab: 'social', title: 'onboarding.featureGuide.socialSection.optInTitle' }, + { tab: 'habits', title: 'onboarding.featureGuide.habitsSection.creatingTitle' }, + { tab: 'goals', title: 'onboarding.featureGuide.goalsSection.creatingTitle' }, + { tab: 'calendar', title: 'onboarding.featureGuide.calendarSection.colorsTitle' }, + { tab: 'rewards', title: 'onboarding.featureGuide.rewardsSection.xpLevelsTitle' }, + { tab: 'settings', title: 'onboarding.featureGuide.settingsSection.colorSchemeTitle' }, + { tab: 'notifications', title: 'onboarding.featureGuide.notificationsSection.bellTitle' }, + ])('switches to the $tab section when its tab is clicked', ({ tab, title }) => { render( , ) - fireEvent.click(screen.getByText('onboarding.featureGuide.connect')) - expect(document.body.textContent).toContain('onboarding.featureGuide.connectSection.mcpTitle') - }) - - it('switches to social section when social tab clicked', () => { - render( - , - ) - fireEvent.click(screen.getByText('onboarding.featureGuide.social')) - expect(document.body.textContent).toContain('onboarding.featureGuide.socialSection.optInTitle') - }) - - it('switches to habits section when habits tab clicked', () => { - render( - , - ) - fireEvent.click(screen.getByText('onboarding.featureGuide.habits')) - expect(document.body.textContent).toContain('onboarding.featureGuide.habitsSection.creatingTitle') - }) - - it('switches to goals section when goals tab clicked', () => { - render( - , - ) - fireEvent.click(screen.getByText('onboarding.featureGuide.goals')) - expect(document.body.textContent).toContain('onboarding.featureGuide.goalsSection.creatingTitle') - }) - - it('switches to calendar section', () => { - render( - , - ) - fireEvent.click(screen.getByText('onboarding.featureGuide.calendar')) - expect(document.body.textContent).toContain('onboarding.featureGuide.calendarSection.colorsTitle') - }) - - it('switches to rewards section', () => { - render( - , - ) - fireEvent.click(screen.getByText('onboarding.featureGuide.rewards')) - expect(document.body.textContent).toContain('onboarding.featureGuide.rewardsSection.xpLevelsTitle') - }) - - it('switches to settings section', () => { - render( - , - ) - fireEvent.click(screen.getByText('onboarding.featureGuide.settings')) - expect(document.body.textContent).toContain('onboarding.featureGuide.settingsSection.colorSchemeTitle') - }) - - it('switches to notifications section', () => { - render( - , - ) - fireEvent.click(screen.getByText('onboarding.featureGuide.notifications')) - expect(document.body.textContent).toContain('onboarding.featureGuide.notificationsSection.bellTitle') + fireEvent.click(screen.getByText(`onboarding.featureGuide.${tab}`)) + expect(document.body.textContent).toContain(title) }) it('highlights active tab with aria-selected', () => { diff --git a/apps/web/__tests__/components/referral/referral-card.test.tsx b/apps/web/__tests__/components/referral/referral-card.test.tsx index 91740f72a..adf5b9415 100644 --- a/apps/web/__tests__/components/referral/referral-card.test.tsx +++ b/apps/web/__tests__/components/referral/referral-card.test.tsx @@ -21,18 +21,15 @@ vi.mock('@/hooks/use-referral', () => ({ import { ReferralCard } from '@/components/referral/referral-card' describe('ReferralCard', () => { - it('renders the card', () => { - mockIsLoading = false - mockStats = null - render() - expect(screen.getByText('referral.card.title')).toBeInTheDocument() - }) - - it('shows hint text when loading', () => { - mockIsLoading = true + it.each([ + { name: 'renders the card', isLoading: false, text: 'referral.card.title' }, + { name: 'shows hint text when loading', isLoading: true, text: 'referral.card.hint' }, + { name: 'shows hint when not loading and no stats', isLoading: false, text: 'referral.card.hint' }, + ])('$name', ({ isLoading, text }) => { + mockIsLoading = isLoading mockStats = null render() - expect(screen.getByText('referral.card.hint')).toBeInTheDocument() + expect(screen.getByText(text)).toBeInTheDocument() }) it('shows progress when stats are loaded', () => { @@ -42,13 +39,6 @@ describe('ReferralCard', () => { expect(document.body.textContent).toContain('referral.card.progress') }) - it('shows hint when not loading and no stats', () => { - mockIsLoading = false - mockStats = null - render() - expect(screen.getByText('referral.card.hint')).toBeInTheDocument() - }) - it('calls onOpen when clicked', () => { mockIsLoading = false mockStats = null diff --git a/apps/web/__tests__/components/referral/referral-drawer.test.tsx b/apps/web/__tests__/components/referral/referral-drawer.test.tsx index 28659b2cd..5a1dcce38 100644 --- a/apps/web/__tests__/components/referral/referral-drawer.test.tsx +++ b/apps/web/__tests__/components/referral/referral-drawer.test.tsx @@ -46,9 +46,13 @@ describe('ReferralDrawer', () => { expect(container.innerHTML).toBe('') }) - it('renders title when open', () => { + it.each([ + 'referral.drawer.title', + 'referral.drawer.howItWorks', + 'referral.drawer.yourLink', + ])('renders %s when open', (text) => { render() - expect(screen.getByText('referral.drawer.title')).toBeInTheDocument() + expect(screen.getByText(text)).toBeInTheDocument() }) it('shows loading state', () => { @@ -116,18 +120,8 @@ describe('ReferralDrawer', () => { expect(screen.getByText('referral.drawer.couponsEarned')).toBeInTheDocument() }) - it('shows how it works section', () => { - render() - expect(screen.getByText('referral.drawer.howItWorks')).toBeInTheDocument() - }) - it('shows disclaimer', () => { render() expect(document.body.textContent).toContain('referral.drawer.disclaimer') }) - - it('shows your link label', () => { - render() - expect(screen.getByText('referral.drawer.yourLink')).toBeInTheDocument() - }) }) diff --git a/apps/web/__tests__/components/ui/pro-badge.test.tsx b/apps/web/__tests__/components/ui/pro-badge.test.tsx index 80b71bc9d..a12d0b6cb 100644 --- a/apps/web/__tests__/components/ui/pro-badge.test.tsx +++ b/apps/web/__tests__/components/ui/pro-badge.test.tsx @@ -21,24 +21,14 @@ describe('ProBadge', () => { expect(container.innerHTML).toBe('') }) - it('renders trial badge when trial is active', () => { - mockProfile.isTrialActive = true - mockProfile.hasProAccess = false - render() - expect(screen.getByText('trial.proBadge')).toBeInTheDocument() - }) - - it('renders pro badge when user has pro access', () => { - mockProfile.isTrialActive = false - mockProfile.hasProAccess = true - render() - expect(screen.getByText('common.proBadge')).toBeInTheDocument() - }) - - it('renders trial badge when both trial and pro are active', () => { - mockProfile.isTrialActive = true - mockProfile.hasProAccess = true + it.each([ + { name: 'renders trial badge when trial is active', isTrialActive: true, hasProAccess: false, text: 'trial.proBadge' }, + { name: 'renders pro badge when user has pro access', isTrialActive: false, hasProAccess: true, text: 'common.proBadge' }, + { name: 'renders trial badge when both trial and pro are active', isTrialActive: true, hasProAccess: true, text: 'trial.proBadge' }, + ])('$name', ({ isTrialActive, hasProAccess, text }) => { + mockProfile.isTrialActive = isTrialActive + mockProfile.hasProAccess = hasProAccess render() - expect(screen.getByText('trial.proBadge')).toBeInTheDocument() + expect(screen.getByText(text)).toBeInTheDocument() }) }) diff --git a/apps/web/__tests__/lib/api-fetch-i18n.test.ts b/apps/web/__tests__/lib/api-fetch-i18n.test.ts index 748d2844b..67c0d1f06 100644 --- a/apps/web/__tests__/lib/api-fetch-i18n.test.ts +++ b/apps/web/__tests__/lib/api-fetch-i18n.test.ts @@ -54,15 +54,21 @@ describe('setApiFetchTranslate', () => { ) }) - it('translates 404 errors', async () => { + it.each([ + { name: 'translates 404 errors', status: 404, key: 't:toast.errors.notFound' }, + { name: 'translates 409 errors', status: 409, key: 't:toast.errors.conflict' }, + { name: 'translates 429 errors', status: 429, key: 't:toast.errors.tooManyRequests' }, + { name: 'translates 500+ errors', status: 503, key: 't:toast.errors.server' }, + { name: 'translates unknown status errors', status: 418, key: 't:toast.errors.unknown' }, + ])('$name', async ({ status, key }) => { const { setApiFetchTranslate, apiFetch } = await import('@/lib/api-fetch') - const translate = (key: string) => `t:${key}` + const translate = (translationKey: string) => `t:${translationKey}` setApiFetchTranslate(translate) mockFetch.mockResolvedValue({ ok: false, - status: 404, + status, json: () => Promise.resolve({}), }) @@ -72,99 +78,7 @@ describe('setApiFetchTranslate', () => { } expect(mockToastError).toHaveBeenCalledWith( - 't:toast.errors.notFound', - expect.any(Object), - ) - }) - - it('translates 409 errors', async () => { - const { setApiFetchTranslate, apiFetch } = await import('@/lib/api-fetch') - - const translate = (key: string) => `t:${key}` - setApiFetchTranslate(translate) - - mockFetch.mockResolvedValue({ - ok: false, - status: 409, - json: () => Promise.resolve({}), - }) - - try { - await apiFetch('/api/test') - } catch { - } - - expect(mockToastError).toHaveBeenCalledWith( - 't:toast.errors.conflict', - expect.any(Object), - ) - }) - - it('translates 429 errors', async () => { - const { setApiFetchTranslate, apiFetch } = await import('@/lib/api-fetch') - - const translate = (key: string) => `t:${key}` - setApiFetchTranslate(translate) - - mockFetch.mockResolvedValue({ - ok: false, - status: 429, - json: () => Promise.resolve({}), - }) - - try { - await apiFetch('/api/test') - } catch { - } - - expect(mockToastError).toHaveBeenCalledWith( - 't:toast.errors.tooManyRequests', - expect.any(Object), - ) - }) - - it('translates 500+ errors', async () => { - const { setApiFetchTranslate, apiFetch } = await import('@/lib/api-fetch') - - const translate = (key: string) => `t:${key}` - setApiFetchTranslate(translate) - - mockFetch.mockResolvedValue({ - ok: false, - status: 503, - json: () => Promise.resolve({}), - }) - - try { - await apiFetch('/api/test') - } catch { - } - - expect(mockToastError).toHaveBeenCalledWith( - 't:toast.errors.server', - expect.any(Object), - ) - }) - - it('translates unknown status errors', async () => { - const { setApiFetchTranslate, apiFetch } = await import('@/lib/api-fetch') - - const translate = (key: string) => `t:${key}` - setApiFetchTranslate(translate) - - mockFetch.mockResolvedValue({ - ok: false, - status: 418, - json: () => Promise.resolve({}), - }) - - try { - await apiFetch('/api/test') - } catch { - } - - expect(mockToastError).toHaveBeenCalledWith( - 't:toast.errors.unknown', + key, expect.any(Object), ) }) diff --git a/apps/web/__tests__/pages/advanced.test.tsx b/apps/web/__tests__/pages/advanced.test.tsx index 9364025dc..2b5ccde37 100644 --- a/apps/web/__tests__/pages/advanced.test.tsx +++ b/apps/web/__tests__/pages/advanced.test.tsx @@ -169,9 +169,13 @@ describe('AdvancedPage', () => { expect(screen.getByTestId('pro-badge')).toBeInTheDocument() }) - it('renders MCP description', () => { + it.each([ + 'orbitMcp.description', + 'orbitMcp.noKeys', + 'orbitMcp.connectionInstructions', + ])('renders %s', (text) => { render() - expect(screen.getByText('orbitMcp.description')).toBeInTheDocument() + expect(screen.getByText(text)).toBeInTheDocument() }) it('shows upgrade link for non-Pro users', () => { @@ -208,11 +212,6 @@ describe('AdvancedPage', () => { expect(screen.getByRole('button', { name: 'orbitMcp.createKey' })).toBeEnabled() }) - it('shows empty state when no API keys', () => { - render() - expect(screen.getByText('orbitMcp.noKeys')).toBeInTheDocument() - }) - it('shows API keys loading state', () => { mockApiKeysLoading = true const { container } = render() @@ -288,11 +287,6 @@ describe('AdvancedPage', () => { }) - it('renders connection instructions toggle', () => { - render() - expect(screen.getByText('orbitMcp.connectionInstructions')).toBeInTheDocument() - }) - it('expands connection instructions on click', () => { render() const instructionsBtn = screen.getByText('orbitMcp.connectionInstructions').closest('button')! diff --git a/apps/web/__tests__/pages/error.test.tsx b/apps/web/__tests__/pages/error.test.tsx index 4262d65fe..9e04db9bc 100644 --- a/apps/web/__tests__/pages/error.test.tsx +++ b/apps/web/__tests__/pages/error.test.tsx @@ -34,8 +34,11 @@ describe('AppError', () => { vi.clearAllMocks() }) - it('renders generic error message in non-development env', () => { - const error = new Error('Something went wrong') as Error & { digest?: string } + it.each([ + { name: 'renders generic error message in non-development env', error: new Error('Something went wrong') as Error & { digest?: string } }, + { name: 'falls back to generic error key when message is empty', error: new Error('') as Error & { digest?: string } }, + { name: 'handles error with digest property', error: Object.assign(new Error('Server error'), { digest: 'abc123' }) }, + ])('$name', ({ error }) => { render() expect(screen.getByText('common.somethingWentWrong')).toBeInTheDocument() }) @@ -46,12 +49,6 @@ describe('AppError', () => { expect(screen.getByTestId('triangle-alert')).toBeInTheDocument() }) - it('falls back to generic error key when message is empty', () => { - const error = new Error('') as Error & { digest?: string } - render() - expect(screen.getByText('common.somethingWentWrong')).toBeInTheDocument() - }) - it('renders the retry button with correct label', () => { const error = new Error('fail') as Error & { digest?: string } render() @@ -64,12 +61,6 @@ describe('AppError', () => { fireEvent.click(screen.getByText('common.retry')) expect(mockReset).toHaveBeenCalledTimes(1) }) - - it('handles error with digest property', () => { - const error = Object.assign(new Error('Server error'), { digest: 'abc123' }) - render() - expect(screen.getByText('common.somethingWentWrong')).toBeInTheDocument() - }) }) @@ -80,14 +71,11 @@ describe('AuthError', () => { vi.clearAllMocks() }) - it('renders generic error message in non-development env', () => { - const error = new Error('Auth failed') as Error & { digest?: string } - render() - expect(screen.getByText('auth.genericError')).toBeInTheDocument() - }) - - it('falls back to generic error key when message is empty', () => { - const error = new Error('') as Error & { digest?: string } + it.each([ + { name: 'renders generic error message in non-development env', error: new Error('Auth failed') as Error & { digest?: string } }, + { name: 'falls back to generic error key when message is empty', error: new Error('') as Error & { digest?: string } }, + { name: 'handles error with digest property', error: Object.assign(new Error('Session expired'), { digest: 'xyz789' }) }, + ])('$name', ({ error }) => { render() expect(screen.getByText('auth.genericError')).toBeInTheDocument() }) @@ -111,12 +99,6 @@ describe('AuthError', () => { const svg = container.querySelector('svg') expect(svg).toBeTruthy() }) - - it('handles error with digest property', () => { - const error = Object.assign(new Error('Session expired'), { digest: 'xyz789' }) - render() - expect(screen.getByText('auth.genericError')).toBeInTheDocument() - }) }) diff --git a/apps/web/__tests__/pages/streak.test.tsx b/apps/web/__tests__/pages/streak.test.tsx index 45a8a6c62..974b4b7e0 100644 --- a/apps/web/__tests__/pages/streak.test.tsx +++ b/apps/web/__tests__/pages/streak.test.tsx @@ -108,14 +108,13 @@ describe('StreakPage', () => { expect(countEl?.textContent).toBe('10') }) - it('renders days unit text', () => { + it.each([ + { name: 'renders days unit text', text: 'streakDisplay.detail.daysUnit' }, + { name: 'renders encouragement message for streak >= 7', text: 'streakDisplay.profile.encouragement7' }, + { name: 'renders longest streak value from profile', text: '30' }, + ])('$name', ({ text }) => { render() - expect(document.body.textContent).toContain('streakDisplay.detail.daysUnit') - }) - - it('renders encouragement message for streak >= 7', () => { - render() - expect(document.body.textContent).toContain('streakDisplay.profile.encouragement7') + expect(document.body.textContent).toContain(text) }) it('renders no encouragement for streak 0', () => { @@ -126,9 +125,13 @@ describe('StreakPage', () => { }) - it('renders 7-day timeline', () => { + it.each([ + { name: 'renders 7-day timeline', text: 'streakDisplay.detail.thisWeek' }, + { name: 'renders the freeze section title', text: 'streakDisplay.freeze.title' }, + { name: 'renders the auto explainer copy', text: 'streakDisplay.freeze.auto.explainer' }, + ])('$name', ({ text }) => { render() - expect(screen.getByText('streakDisplay.detail.thisWeek')).toBeInTheDocument() + expect(screen.getByText(text)).toBeInTheDocument() }) it('renders legend items', () => { @@ -145,21 +148,6 @@ describe('StreakPage', () => { expect(screen.getByText('streakDisplay.detail.longestStreak')).toBeInTheDocument() }) - it('renders longest streak value from profile', () => { - render() - expect(document.body.textContent).toContain('30') - }) - - - it('renders the freeze section title', () => { - render() - expect(screen.getByText('streakDisplay.freeze.title')).toBeInTheDocument() - }) - - it('renders the auto explainer copy', () => { - render() - expect(screen.getByText('streakDisplay.freeze.auto.explainer')).toBeInTheDocument() - }) it('renders the banked charge gauge with the banked count', () => { render() diff --git a/packages/shared/src/__tests__/error-utils-extended.test.ts b/packages/shared/src/__tests__/error-utils-extended.test.ts index 9ce75a19c..42e4ea719 100644 --- a/packages/shared/src/__tests__/error-utils-extended.test.ts +++ b/packages/shared/src/__tests__/error-utils-extended.test.ts @@ -224,20 +224,13 @@ describe('getFriendlyErrorKey (extended coverage)', () => { expect(getFriendlyErrorKey(err, 'errors.generic')).toBe('habits.errors.circularReference') }) - it('maps HABIT_NOT_FOUND code', () => { - const err = createApiClientError(400, { errorCode: 'HABIT_NOT_FOUND' }, 'fallback') - expect(getFriendlyErrorKey(err, 'errors.generic')).toBe('toast.errors.notFound') - }) - - it('maps GOAL_NOT_FOUND code', () => { - const err = createApiClientError(400, { errorCode: 'GOAL_NOT_FOUND' }, 'fallback') - expect(getFriendlyErrorKey(err, 'errors.generic')).toBe('toast.errors.notFound') - }) - - it('maps TAG_NOT_FOUND code', () => { - const err = createApiClientError(400, { errorCode: 'TAG_NOT_FOUND' }, 'fallback') - expect(getFriendlyErrorKey(err, 'errors.generic')).toBe('toast.errors.notFound') - }) + it.each(['HABIT_NOT_FOUND', 'GOAL_NOT_FOUND', 'TAG_NOT_FOUND'])( + 'maps %s code to notFound', + (errorCode) => { + const err = createApiClientError(400, { errorCode }, 'fallback') + expect(getFriendlyErrorKey(err, 'errors.generic')).toBe('toast.errors.notFound') + }, + ) it('maps "please wait" message to too many requests', () => { const err = createApiClientError(400, { error: 'Please wait before trying again' }, 'fallback') diff --git a/packages/shared/src/__tests__/utils.test.ts b/packages/shared/src/__tests__/utils.test.ts index 36ec01503..b3ff29bb3 100644 --- a/packages/shared/src/__tests__/utils.test.ts +++ b/packages/shared/src/__tests__/utils.test.ts @@ -102,24 +102,14 @@ describe('getTimezoneList', () => { expect(list.length).toBeGreaterThan(0) }) - it('includes America/New_York', () => { + it.each([ + 'America/New_York', + 'Europe/London', + 'Asia/Tokyo', + 'America/Sao_Paulo', + ])('includes %s', (timeZone) => { const list = getTimezoneList() - expect(list).toContain('America/New_York') - }) - - it('includes Europe/London', () => { - const list = getTimezoneList() - expect(list).toContain('Europe/London') - }) - - it('includes Asia/Tokyo', () => { - const list = getTimezoneList() - expect(list).toContain('Asia/Tokyo') - }) - - it('includes America/Sao_Paulo', () => { - const list = getTimezoneList() - expect(list).toContain('America/Sao_Paulo') + expect(list).toContain(timeZone) }) it('returns strings in every entry', () => {