Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions apps/mobile/__tests__/components/gamification/streak-badge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'

import { StreakBadge } from '@/components/gamification/streak-badge'

const TestRenderer = require('react-test-renderer')

const pushMock = vi.fn()

vi.mock('expo-router', () => ({
useRouter: () => ({ push: pushMock }),
}))

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => {
if (params) return `${key}:${JSON.stringify(params)}`
return key
},
}),
}))

vi.mock('@/lib/use-app-theme', () => ({
useAppTheme: () => ({ currentScheme: 'graphite', currentTheme: 'dark' }),
}))

vi.mock('@/lib/theme', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/theme')>()
return {
...actual,
createTokensV2: () => ({
statusFrozen: '#88ccff',
statusBad: '#ff5555',
fg1: '#ffffff',
fg3: '#999999',
hairlineStrong: '#333333',
}),
}
})

vi.mock('@/lib/plural', () => ({
plural: (text: string) => text,
}))

function findButton(root: any) {
return root.findAll(
(node: any) =>
node.props &&
node.props.accessibilityRole === 'button' &&
typeof node.props.onPress === 'function' &&
typeof node.type !== 'string',
)
}

function renderBadge(props: { streak: number; isFrozen?: boolean }) {
let tree: any
TestRenderer.act(() => {
tree = TestRenderer.create(<StreakBadge {...props} />)
})
return tree
}

describe('StreakBadge (mobile)', () => {
beforeEach(() => {
pushMock.mockClear()
})

it('renders nothing when streak is 0', () => {
const tree = renderBadge({ streak: 0 })
expect(tree.toJSON()).toBeNull()
})

it('renders nothing when streak is negative', () => {
const tree = renderBadge({ streak: -1 })
expect(tree.toJSON()).toBeNull()
})

it('renders the badge as a button with an accessible label', () => {
const tree = renderBadge({ streak: 3 })
const [button] = findButton(tree.root)
expect(button).toBeTruthy()
expect(typeof button.props.accessibilityLabel).toBe('string')
})

it('navigates to the streak page on press', () => {
const tree = renderBadge({ streak: 5 })
const [button] = findButton(tree.root)
TestRenderer.act(() => {
button?.props.onPress?.({ stopPropagation: () => {} })
})
expect(pushMock).toHaveBeenCalledWith('/streak')
})

it('stops propagation so the header go-to-today does not fire', () => {
const tree = renderBadge({ streak: 5 })
const [button] = findButton(tree.root)
const stopPropagation = vi.fn()
TestRenderer.act(() => {
button?.props.onPress?.({ stopPropagation })
})
expect(stopPropagation).toHaveBeenCalledTimes(1)
})
})
58 changes: 1 addition & 57 deletions apps/mobile/__tests__/hooks/use-gamification.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { API } from '@orbit/shared/api'
import { createMockGamificationProfile } from '@orbit/shared/__tests__/factories'
import { gamificationKeys, profileKeys } from '@orbit/shared/query'
import type { StreakInfo, StreakFreezeResponse } from '@orbit/shared/types/gamification'
import type { StreakInfo } from '@orbit/shared/types/gamification'

import {
useActivateStreakFreeze,
useGamificationProfile,
useStreakFreeze,
useStreakInfo,
Expand All @@ -30,15 +27,6 @@ const mocks = vi.hoisted(() => {

const queryClient = {
invalidateQueries: vi.fn(async () => {}),
setQueryData: vi.fn((queryKey: readonly unknown[], updater: StreakInfo | ((old: StreakInfo | undefined) => StreakInfo | undefined)) => {
if (JSON.stringify(queryKey) !== JSON.stringify(gamificationKeys.streak())) {
return
}

state.streakInfo = typeof updater === 'function'
? (updater(state.streakInfo) ?? state.streakInfo)
: updater
}),
}

return {
Expand All @@ -63,26 +51,13 @@ const mocks = vi.hoisted(() => {
}
}),
useQueryClient: vi.fn(() => queryClient),
useMutation: vi.fn((options: {
mutationFn: () => Promise<StreakFreezeResponse>
onSuccess?: (data: StreakFreezeResponse) => void
onSettled?: () => void
}) => ({
mutateAsync: async () => {
const data = await options.mutationFn()
options.onSuccess?.(data)
options.onSettled?.()
return data
},
})),
apiClient: vi.fn(),
}
})

vi.mock('@tanstack/react-query', () => ({
useQuery: mocks.useQuery,
useQueryClient: mocks.useQueryClient,
useMutation: mocks.useMutation,
}))

vi.mock('@/lib/api-client', () => ({
Expand Down Expand Up @@ -137,10 +112,8 @@ describe('mobile useGamificationProfile', () => {
beforeEach(() => {
mocks.state.gamificationProfile = createMockGamificationProfile()
mocks.queryClient.invalidateQueries.mockClear()
mocks.queryClient.setQueryData.mockClear()
mocks.useQuery.mockClear()
mocks.useQueryClient.mockClear()
mocks.useMutation.mockClear()
mocks.apiClient.mockClear()
})

Expand Down Expand Up @@ -213,10 +186,8 @@ describe('mobile useStreakInfo and streak freeze', () => {
canEarnMore: true,
}
mocks.queryClient.invalidateQueries.mockClear()
mocks.queryClient.setQueryData.mockClear()
mocks.useQuery.mockClear()
mocks.useQueryClient.mockClear()
mocks.useMutation.mockClear()
mocks.apiClient.mockClear()
})

Expand All @@ -236,31 +207,4 @@ describe('mobile useStreakInfo and streak freeze', () => {
expect(hook.value.currentStreak).toBe(7)
expect(hook.value.canFreeze).toBe(true)
})

it('syncs the streak cache after activating a freeze', async () => {
mocks.apiClient.mockResolvedValue({
freezesRemainingThisMonth: 1,
frozenDate: '2025-01-15',
currentStreak: 7,
})

const hook = await renderHookValue(() => useActivateStreakFreeze())

await hook.value.mutateAsync()

expect(mocks.apiClient).toHaveBeenCalledWith(
API.gamification.streakFreeze,
expect.objectContaining({ method: 'POST' }),
)
expect(mocks.queryClient.setQueryData).toHaveBeenCalledWith(
gamificationKeys.streak(),
expect.any(Function),
)
expect(mocks.queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: gamificationKeys.streak(),
})
expect(mocks.queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: profileKeys.all,
})
})
})
3 changes: 1 addition & 2 deletions apps/mobile/__tests__/lib/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ describe('mobile i18n interpolation', () => {
expect(plural(i18n.t('goals.deadline.daysLeft', { n: 3 }), 3)).toBe('3 days left')
expect(plural(i18n.t('streakDisplay.detail.daysUnit', { count: 1 }), 1)).toBe('day')
expect(plural(i18n.t('streakDisplay.detail.daysUnit', { count: 4 }), 4)).toBe('days')
expect(plural(i18n.t('streakDisplay.freeze.available', { count: 0 }), 0)).toBe('No freezes available')
expect(plural(i18n.t('streakDisplay.freeze.available', { count: 2 }), 2)).toBe('2 freezes available')
expect(i18n.t('streakDisplay.freeze.nextFreeze.inDays', { days: 3 })).toBe('in 3 days')
expect(plural(i18n.t('habits.frequency.everyNWeeks', { n: 2 }), 2)).toBe('Every 2 weeks')
expect(plural(i18n.t('habits.breakdown.createdSuccess', { n: 2 }), 2)).toBe('Created 2 habits successfully')
})
Expand Down
Loading
Loading