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
51 changes: 51 additions & 0 deletions apps/mobile/__tests__/lib/anchored-menu.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_ANCHORED_MENU_MARGIN,
getAnchoredMenuPosition,
} from '@/lib/anchored-menu'

describe('anchored menu positioning', () => {
it('right-aligns the menu to the anchor by default', () => {
const position = getAnchoredMenuPosition({
anchorRect: { x: 300, y: 100, width: 24, height: 24 },
viewportWidth: 400,
viewportHeight: 800,
menuWidth: 200,
menuHeight: 180,
})

expect(position).toEqual({
left: 124,
top: 132,
opensUp: false,
})
})

it('clamps the menu within the viewport edges', () => {
const position = getAnchoredMenuPosition({
anchorRect: { x: 8, y: 120, width: 24, height: 24 },
viewportWidth: 240,
viewportHeight: 640,
menuWidth: 220,
menuHeight: 180,
})

expect(position.left).toBe(DEFAULT_ANCHORED_MENU_MARGIN)
expect(position.top).toBe(152)
expect(position.opensUp).toBe(false)
})

it('opens upward when there is not enough room below the trigger', () => {
const position = getAnchoredMenuPosition({
anchorRect: { x: 260, y: 580, width: 24, height: 24 },
viewportWidth: 360,
viewportHeight: 640,
menuWidth: 200,
menuHeight: 180,
})

expect(position.left).toBe(84)
expect(position.top).toBe(392)
expect(position.opensUp).toBe(true)
})
})
119 changes: 119 additions & 0 deletions apps/mobile/__tests__/lib/google-auth-callback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import {
AUTH_CALLBACK_URL,
buildGoogleAuthFallbackUrl,
extractGoogleAuthParams,
hasGoogleAuthCallbackPayload,
resolveGoogleAuthCallbackUrl,
} from '@/lib/google-auth-callback'

describe('google auth callback helpers', () => {
const nativeCallbackUrl = 'orbit://auth-callback'

it('treats a bare callback route as having no payload', () => {
const params = extractGoogleAuthParams(AUTH_CALLBACK_URL)

expect(params).toEqual({
access_token: undefined,
refresh_token: undefined,
provider_token: undefined,
provider_refresh_token: undefined,
error: undefined,
error_description: undefined,
token: undefined,
refreshToken: undefined,
userId: undefined,
name: undefined,
email: undefined,
})
expect(hasGoogleAuthCallbackPayload(params)).toBe(false)
expect(
resolveGoogleAuthCallbackUrl({
rawUrl: AUTH_CALLBACK_URL,
params: {},
}),
).toBeNull()
})

it('detects callback error params from the query string', () => {
const url = `${AUTH_CALLBACK_URL}?error=access_denied&error_description=User%20cancelled`
const params = extractGoogleAuthParams(url)

expect(params.error).toBe('access_denied')
expect(params.error_description).toBe('User cancelled')
expect(hasGoogleAuthCallbackPayload(params)).toBe(true)
expect(
resolveGoogleAuthCallbackUrl({
rawUrl: url,
params: {},
}),
).toBe(url)
})

it('detects direct backend token payloads', () => {
const fallbackUrl = buildGoogleAuthFallbackUrl({
token: 'backend-token',
refreshToken: 'refresh-token',
userId: 'user-1',
name: 'Thomas',
email: 'thomas@example.com',
})

expect(fallbackUrl).toBe(
`${AUTH_CALLBACK_URL}?token=backend-token&refreshToken=refresh-token&userId=user-1&name=Thomas&email=thomas%40example.com`,
)
expect(
resolveGoogleAuthCallbackUrl({
rawUrl: null,
params: {
token: 'backend-token',
refreshToken: 'refresh-token',
userId: 'user-1',
name: 'Thomas',
email: 'thomas@example.com',
},
}),
).toBe(fallbackUrl)
})

it('detects supabase access and refresh tokens from the hash fragment', () => {
const url = `${AUTH_CALLBACK_URL}#access_token=supa-access&refresh_token=supa-refresh`
const params = extractGoogleAuthParams(url)

expect(params.access_token).toBe('supa-access')
expect(params.refresh_token).toBe('supa-refresh')
expect(hasGoogleAuthCallbackPayload(params)).toBe(true)
expect(
resolveGoogleAuthCallbackUrl({
rawUrl: url,
params: {},
}),
).toBe(url)
})

it('detects native callback URLs with payloads', () => {
const url = `${nativeCallbackUrl}#access_token=supa-access&refresh_token=supa-refresh`

expect(
resolveGoogleAuthCallbackUrl({
rawUrl: url,
params: {},
}),
).toBe(url)
})

it('prefers the auth-session callback URL over route state', () => {
const sessionUrl = `${nativeCallbackUrl}#access_token=session-access&refresh_token=session-refresh`
const routeUrl = `${AUTH_CALLBACK_URL}?error=server_error`

expect(
resolveGoogleAuthCallbackUrl({
sessionCallbackUrl: sessionUrl,
rawUrl: routeUrl,
params: {
error: 'server_error',
},
}),
).toBe(sessionUrl)
})
})
28 changes: 28 additions & 0 deletions apps/mobile/__tests__/lib/habit-progress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import {
getHabitProgressStrokeDasharray,
HABIT_PROGRESS_RING_CIRCUMFERENCE,
} from '@/lib/habit-progress'

describe('habit progress ring', () => {
it('returns a partial arc for incomplete progress', () => {
expect(getHabitProgressStrokeDasharray(33, false)).toBe(
`31.10 ${HABIT_PROGRESS_RING_CIRCUMFERENCE}`,
)
})

it('returns a full arc for completed parents', () => {
expect(getHabitProgressStrokeDasharray(33, true)).toBe(
`94.25 ${HABIT_PROGRESS_RING_CIRCUMFERENCE}`,
)
})

it('clamps progress percent to the valid range', () => {
expect(getHabitProgressStrokeDasharray(180, false)).toBe(
`94.25 ${HABIT_PROGRESS_RING_CIRCUMFERENCE}`,
)
expect(getHabitProgressStrokeDasharray(-10, false)).toBe(
`0.00 ${HABIT_PROGRESS_RING_CIRCUMFERENCE}`,
)
})
})
54 changes: 54 additions & 0 deletions apps/mobile/__tests__/lib/habit-selection-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import {
getHabitListExtraData,
shouldResetSelectionForViewChange,
} from '@/lib/habit-selection-state'

describe('habit selection state helpers', () => {
it('does not reset selection when the active view is unchanged', () => {
expect(shouldResetSelectionForViewChange('today', 'today')).toBe(false)
})

it('resets selection when the active view changes', () => {
expect(shouldResetSelectionForViewChange('today', 'all')).toBe(true)
})

it('changes the list extraData key when select mode changes', () => {
const selectedIds = new Set<string>()
const recentlyCompletedIds = new Set<string>()

expect(
getHabitListExtraData(false, selectedIds, recentlyCompletedIds),
).not.toBe(
getHabitListExtraData(true, selectedIds, recentlyCompletedIds),
)
})

it('changes the list extraData key when selected ids change', () => {
const recentlyCompletedIds = new Set<string>()

expect(
getHabitListExtraData(false, new Set<string>(), recentlyCompletedIds),
).not.toBe(
getHabitListExtraData(
false,
new Set<string>(['habit-1']),
recentlyCompletedIds,
),
)
})

it('changes the list extraData key when recently completed ids change', () => {
const selectedIds = new Set<string>(['habit-1'])

expect(
getHabitListExtraData(false, selectedIds, new Set<string>()),
).not.toBe(
getHabitListExtraData(
false,
selectedIds,
new Set<string>(['habit-2']),
),
)
})
})
26 changes: 26 additions & 0 deletions apps/mobile/__tests__/stores/ui-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,32 @@ describe('mobile ui store', () => {
expect(useUIStore.getState().selectedHabitIds.size).toBe(0)
})

it('enters bulk select mode without selecting habits', () => {
useUIStore.getState().toggleSelectMode()

expect(useUIStore.getState().isSelectMode).toBe(true)
expect(useUIStore.getState().selectedHabitIds.size).toBe(0)
})

it('enters select mode with the tapped habit and descendants selected', () => {
const { toggleSelectMode, toggleSelectionCascade } = useUIStore.getState()

if (!useUIStore.getState().isSelectMode) {
toggleSelectMode()
}

toggleSelectionCascade(
'habit-1',
() => ['child-1', 'child-2'],
() => false,
)

expect(useUIStore.getState().isSelectMode).toBe(true)
expect(useUIStore.getState().selectedHabitIds).toEqual(
new Set(['habit-1', 'child-1', 'child-2']),
)
})

it('shows all-done celebration only for completed top-level habits on today filters', () => {
useUIStore.setState({
activeFilters: { dateFrom: '2026-04-06', dateTo: '2026-04-06' },
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"version": "1.0.0",
"sdkVersion": "54.0.0",
"orientation": "portrait",
"icon": "./assets/logo.png",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"scheme": "orbit",
"newArchEnabled": true,
Expand All @@ -24,7 +24,7 @@
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/logo.png",
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#07060e"
},
"package": "org.useorbit.app",
Expand Down
Loading
Loading