diff --git a/src/components/AttachmentPreview.tsx b/src/components/AttachmentPreview.tsx index 767fc2c46510..61627f92aa63 100644 --- a/src/components/AttachmentPreview.tsx +++ b/src/components/AttachmentPreview.tsx @@ -7,7 +7,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {SourceLoadEventPayload, VideoThumbnail} from 'expo-video'; +import type {VideoThumbnail} from 'expo-video'; import {Str} from 'expensify-common'; import {useEvent} from 'expo'; @@ -52,7 +52,7 @@ function AttachmentPreview({source, aspectRatio = 1, onPress, onLoadError}: Atta const [thumbnail, setThumbnail] = useState(null); const videoPlayer = useVideoPlayer(isVideo ? source : null); - const {videoSource} = useEvent(videoPlayer, 'sourceLoad', {videoSource: null} as SourceLoadEventPayload); + const videoSource = useEvent(videoPlayer, 'sourceLoad', null)?.videoSource; useEffect(() => { if (!videoSource) { diff --git a/src/components/Charts/hooks/useChartLabelFormats.ts b/src/components/Charts/hooks/useChartLabelFormats.ts index f38908c4362b..a4967cf6af02 100644 --- a/src/components/Charts/hooks/useChartLabelFormats.ts +++ b/src/components/Charts/hooks/useChartLabelFormats.ts @@ -20,7 +20,8 @@ export default function useChartLabelFormats({data, unit, unitPosition = 'left', const {numberFormat} = useLocalize(); const displayUnit = typeof unit === 'string' ? unit : unit?.value; - const unitToDisplay = fontManager && !canFontRenderText(displayUnit, fontManager) ? (unit as UnitWithFallback)?.fallback : displayUnit; + const fallbackUnit = typeof unit === 'string' ? undefined : unit?.fallback; + const unitToDisplay = fontManager && !canFontRenderText(displayUnit, fontManager) ? fallbackUnit : displayUnit; const withUnit = (formatted: string) => { if (!unitToDisplay) { return formatted; diff --git a/src/hooks/usePreferredCurrency.ts b/src/hooks/usePreferredCurrency.ts index 3894fd42b74b..853d4c094fa6 100644 --- a/src/hooks/usePreferredCurrency.ts +++ b/src/hooks/usePreferredCurrency.ts @@ -27,9 +27,8 @@ function usePreferredCurrency(): PreferredCurrency { return paymentCardCurrency; } - const currentUserLocalCurrency = (currentUserPersonalDetail?.localCurrencyCode ?? CONST.PAYMENT_CARD_CURRENCY.USD) as PreferredCurrency; - - return Object.values(CONST.PAYMENT_CARD_CURRENCY).includes(currentUserLocalCurrency) ? currentUserLocalCurrency : CONST.PAYMENT_CARD_CURRENCY.USD; + const currentUserLocalCurrency = currentUserPersonalDetail?.localCurrencyCode; + return Object.values(CONST.PAYMENT_CARD_CURRENCY).find((currency) => currency === currentUserLocalCurrency) ?? CONST.PAYMENT_CARD_CURRENCY.USD; } export default usePreferredCurrency; diff --git a/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx b/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx index 9828c3c174ba..f17eab01aa88 100644 --- a/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx +++ b/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx @@ -74,7 +74,7 @@ function DynamicWorkspaceOverviewPlanTypePage({policy}: WithPolicyProps) { const isCurrentPolicySubmit = isSubmitPolicy(policy); const workspacePlanTypes = Object.values(CONST.POLICY.TYPE) - .filter((type) => { + .filter((type): type is PersonalPolicyTypeExcludedProps => { if (type === CONST.POLICY.TYPE.PERSONAL) { return false; } @@ -88,8 +88,8 @@ function DynamicWorkspaceOverviewPlanTypePage({policy}: WithPolicyProps) { }) .map((policyType) => ({ value: policyType, - text: translate(`workspace.planTypePage.planTypes.${policyType as PersonalPolicyTypeExcludedProps}.label`), - alternateText: translate(`workspace.planTypePage.planTypes.${policyType as PersonalPolicyTypeExcludedProps}.description`), + text: translate(`workspace.planTypePage.planTypes.${policyType}.label`), + alternateText: translate(`workspace.planTypePage.planTypes.${policyType}.description`), keyForList: policyType, isSelected: policyType === currentPlan, actionElement: diff --git a/src/pages/workspace/upgrade/UpgradeIntro.tsx b/src/pages/workspace/upgrade/UpgradeIntro.tsx index 26f460cb23b7..0a02384bba73 100644 --- a/src/pages/workspace/upgrade/UpgradeIntro.tsx +++ b/src/pages/workspace/upgrade/UpgradeIntro.tsx @@ -116,13 +116,14 @@ function UpgradeIntro({feature, onUpgrade, buttonDisabled, loading, isCategorizi ); } + const illustrationIcon = Object.entries(illustrationIcons).find(([name]) => name === feature.icon)?.[1]; + const illustration = Object.entries(illustrations).find(([name]) => name === feature.icon)?.[1]; const isIllustration = feature.icon in illustrations; - const isIllustrationIcon = feature.icon in illustrationIcons; let iconSrc; - if (isIllustrationIcon) { - iconSrc = illustrationIcons[feature.icon as keyof typeof illustrationIcons]; + if (feature.icon in illustrationIcons) { + iconSrc = illustrationIcon; } else if (isIllustration) { - iconSrc = illustrations[feature.icon as keyof typeof illustrations]; + iconSrc = illustration; } const iconAdditionalStyles = feature.id === CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id ? styles.br0 : undefined; diff --git a/tests/unit/AttachmentPreviewTest.tsx b/tests/unit/AttachmentPreviewTest.tsx new file mode 100644 index 000000000000..25d7a2f6c164 --- /dev/null +++ b/tests/unit/AttachmentPreviewTest.tsx @@ -0,0 +1,69 @@ +import {render, screen, waitFor} from '@testing-library/react-native'; + +import AttachmentPreview from '@components/AttachmentPreview'; +import MockText from '@components/Text'; + +import React from 'react'; +import {View as MockView} from 'react-native'; + +type MockSourceLoadEvent = {videoSource: string} | null; +let mockEvent: MockSourceLoadEvent = null; +const mockGenerateThumbnails = jest.fn(async () => [{uri: 'thumbnail'}]); +const mockPlayer = {generateThumbnailsAsync: mockGenerateThumbnails}; +const mockUseEvent = jest.fn(() => mockEvent); +jest.mock('expo', () => ({useEvent: (...args: Parameters) => mockUseEvent(...args)})); +jest.mock('expo-video', () => ({useVideoPlayer: () => mockPlayer})); +jest.mock('@hooks/useThemeStyles', () => ({__esModule: true, default: () => ({})})); +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({Play: 'play'})})); +jest.mock('@libs/fileDownload/FileUtils', () => ({getFileName: (name: string) => name, cleanFileName: (name: string) => name})); +jest.mock('@components/Attachments/AttachmentView', () => ({checkIsFileImage: () => false})); +jest.mock('@components/Attachments/AttachmentView/DefaultAttachmentView', () => ({__esModule: true, default: () => default attachment})); +jest.mock('@components/Pressable', () => ({PressableWithFeedback: ({children}: {children: React.ReactNode}) => {children}})); +jest.mock('@components/Icon', () => ({__esModule: true, default: () => play icon})); +jest.mock('@components/Image', () => ({__esModule: true, default: () => thumbnail})); +jest.mock('@components/PDFThumbnail', () => ({__esModule: true, default: () => null})); +beforeEach(() => { + mockEvent = null; + mockGenerateThumbnails.mockClear(); + mockUseEvent.mockClear(); +}); +it('waits for sourceLoad before generating a video thumbnail', async () => { + // Given a video player without an initial source event + const {rerender} = render( + , + ); + expect(mockUseEvent).toHaveBeenLastCalledWith(mockPlayer, 'sourceLoad', null); + expect(screen.getByText('play icon')).toBeTruthy(); + expect(mockGenerateThumbnails).not.toHaveBeenCalled(); + // When sourceLoad supplies a video source + mockEvent = {videoSource: 'clip.mp4'}; + rerender( + , + ); + await waitFor(() => expect(mockGenerateThumbnails).toHaveBeenCalledWith([1])); + // Then the production effect requests the thumbnail at one second and renders it + expect(mockGenerateThumbnails).toHaveBeenCalledWith([1]); + expect(screen.getByText('thumbnail')).toBeTruthy(); +}); +it('keeps the non-video branch without generating a thumbnail', () => { + // Given a document attachment + // When the production preview renders it + render( + , + ); + // Then it takes the default branch without a video request + expect(screen.getByText('default attachment')).toBeTruthy(); + expect(mockGenerateThumbnails).not.toHaveBeenCalled(); +}); diff --git a/tests/unit/DynamicWorkspaceOverviewPlanTypePageTest.tsx b/tests/unit/DynamicWorkspaceOverviewPlanTypePageTest.tsx new file mode 100644 index 000000000000..e44c0be65c29 --- /dev/null +++ b/tests/unit/DynamicWorkspaceOverviewPlanTypePageTest.tsx @@ -0,0 +1,88 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import MockText from '@components/Text'; + +import Navigation from '@navigation/Navigation'; + +import DynamicWorkspaceOverviewPlanTypePage from '@pages/workspace/DynamicWorkspaceOverviewPlanTypePage'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; + +import React from 'react'; +import {View as MockView} from 'react-native'; + +import createRandomPolicy from '../utils/collections/policies'; +import createMock from '../utils/createMock'; + +let mockPolicy: Policy; +let mockSubscription: {type: string} | undefined; +let mockSelection: {data: Array<{value: string; isSelected: boolean}>; isDisabled: boolean; onSelectRow: (item: {value: string}) => void} | undefined; +const mockNavigate = jest.fn(); +jest.mock('@pages/workspace/withPolicy', () => ({__esModule: true, default: (Component: React.ComponentType<{policy: Policy}>) => () => })); +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: ({children}: {children: React.ReactNode}) => children})); +jest.mock('@components/ScreenWrapper', () => ({__esModule: true, default: ({children}: {children: React.ReactNode}) => {children}})); +jest.mock('@components/SelectionList', () => ({ + __esModule: true, + default: (props: typeof mockSelection & {footerContent: React.ReactNode}) => { + mockSelection = props; + return {props.footerContent}; + }, +})); +jest.mock('@components/Button', () => ({ + __esModule: true, + default: Object.assign( + ({children, onPress}: {children: React.ReactNode; onPress: () => void}) => ( + true} + onResponderRelease={onPress} + > + {children} + + ), + { + Text: ({children}: {children: React.ReactNode}) => {children}, + }, + ), +})); +jest.mock('@hooks/useLocalize', () => ({__esModule: true, default: () => ({translate: (key: string) => key, dateFnsLocale: undefined})})); +jest.mock('@hooks/usePrivateSubscription', () => ({__esModule: true, default: () => mockSubscription})); +jest.mock('@hooks/useThemeStyles', () => ({__esModule: true, default: () => ({})})); +jest.mock('@hooks/useTheme', () => ({__esModule: true, default: () => ({success: 'green'})})); +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({Lock: 'lock'})})); +jest.mock('@libs/actions/Policy/Plan', () => ({__esModule: true, default: jest.fn()})); +jest.mock('@navigation/Navigation', () => ({__esModule: true, default: {navigate: mockNavigate, goBack: jest.fn(), getActiveRoute: () => ''}, navigate: mockNavigate})); +beforeEach(() => { + mockNavigate.mockClear(); + Navigation.navigate = mockNavigate; + mockSubscription = undefined; + mockSelection = undefined; +}); +it.each([CONST.POLICY.TYPE.SUBMIT, CONST.POLICY.TYPE.TEAM, CONST.POLICY.TYPE.CORPORATE])('shows the allowed rows for %s', (type) => { + // Given a workspace of the specified plan type + mockPolicy = {...createRandomPolicy(1, type), canDowngrade: true}; + // When the real plan picker renders + render(['route']>({})} />); + // Then Personal is absent and Submit is only available for an existing Submit workspace + expect(mockSelection?.data.map((item) => item.value)).toEqual(type === CONST.POLICY.TYPE.SUBMIT ? ['submit2026', 'team', 'corporate'] : ['team', 'corporate']); +}); +it('routes a Submit-to-Team selection through upgrade', () => { + // Given a Submit workspace with Team available + mockPolicy = {...createRandomPolicy(1, CONST.POLICY.TYPE.SUBMIT), canDowngrade: true}; + render(['route']>({})} />); + // When Team is selected and saved + act(() => mockSelection?.onSelectRow({value: CONST.POLICY.TYPE.TEAM})); + fireEvent.press(screen.getByText('common.save')); + // Then the picker navigates to the upgrade route + expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('upgrade')); +}); +it('locks annual Control without downgrade permission', () => { + // Given a locked annual Control workspace + mockPolicy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), canDowngrade: false}; + mockSubscription = {type: CONST.SUBSCRIPTION.TYPE.ANNUAL}; + // When the real picker renders + render(['route']>({})} />); + // Then selection is disabled and confirmation replaces Save + expect(mockSelection?.isDisabled).toBe(true); + expect(screen.getByText('common.buttonConfirm')).toBeTruthy(); +}); diff --git a/tests/unit/UpgradeIntroTest.tsx b/tests/unit/UpgradeIntroTest.tsx new file mode 100644 index 000000000000..2b8543e49dc4 --- /dev/null +++ b/tests/unit/UpgradeIntroTest.tsx @@ -0,0 +1,91 @@ +import {render, screen} from '@testing-library/react-native'; + +import MockText from '@components/Text'; + +import UpgradeIntro from '@pages/workspace/upgrade/UpgradeIntro'; +import type UpgradeIntroView from '@pages/workspace/upgrade/UpgradeIntroView'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +let mockMissingIcon: string | undefined; +let mockOverlapIcon: string | undefined; +let mockIntroProps: React.ComponentProps | undefined; +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyIllustrations: (names: string[]) => Object.fromEntries(names.filter((name) => name !== mockMissingIcon).map((name) => [name, `illustration:${name}`])), + useMemoizedLazyExpensifyIcons: (names: string[]) => + Object.fromEntries([...names, ...(mockOverlapIcon ? [mockOverlapIcon] : [])].filter((name) => name !== mockMissingIcon).map((name) => [name, `icon:${name}`])), +})); +jest.mock('@hooks/useOnyx', () => ({__esModule: true, default: () => [undefined]})); +jest.mock('@hooks/usePreferredCurrency', () => ({__esModule: true, default: () => 'USD'})); +jest.mock('@hooks/useHasTeam2025Pricing', () => ({__esModule: true, default: () => false})); +jest.mock('@hooks/useLocalize', () => ({__esModule: true, default: () => ({translate: (key: string) => key})})); +jest.mock('@hooks/useThemeStyles', () => ({__esModule: true, default: () => ({br0: {borderRadius: 0}})})); +jest.mock('@pages/workspace/upgrade/GenericFeaturesView', () => ({__esModule: true, default: () => generic upgrade})); +jest.mock('@pages/workspace/upgrade/UpgradeIntroView', () => ({ + __esModule: true, + default: (props: React.ComponentProps) => { + mockIntroProps = props; + return feature upgrade; + }, +})); +beforeEach(() => { + mockMissingIcon = undefined; + mockOverlapIcon = undefined; + mockIntroProps = undefined; +}); +describe('UpgradeIntro', () => { + it('prefers the icon registry when both registries contain a feature name', () => { + // Given the report-fields icon appears in both registries + const feature = CONST.UPGRADE_FEATURE_INTRO_MAPPING.reportFields; + mockOverlapIcon = feature.icon; + // When the real upgrade intro selects the icon + render( + , + ); + // Then the icon registry wins while illustration membership is retained + expect(screen.getByText('feature upgrade')).toBeTruthy(); + expect(mockIntroProps?.iconSrc).toBe(`icon:${feature.icon}`); + expect(mockIntroProps?.isIllustration).toBe(true); + }); + it('reports a missing registry icon', () => { + // Given a mapped feature whose icon is absent from both registries + mockMissingIcon = CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.icon; + // When the real upgrade intro checks its registry + // Then it reports the missing icon instead of rendering a broken card + expect(() => + render( + , + ), + ).toThrow(`Missing icons: ${mockMissingIcon}`); + }); + it('shows the generic view without a feature', () => { + // Given no feature was selected + // When the real upgrade intro renders + render(); + // Then the generic view is shown + expect(screen.getByText('generic upgrade')).toBeTruthy(); + }); + it('passes the approvals border style to the feature view', () => { + // Given an approvals feature tied to a workspace + // When the real upgrade intro renders + render( + , + ); + // Then the approvals illustration receives its square-border style + expect(mockIntroProps?.iconAdditionalStyles).toEqual({borderRadius: 0}); + }); +}); diff --git a/tests/unit/components/Charts/useChartLabelFormats.test.ts b/tests/unit/components/Charts/useChartLabelFormats.test.ts index 78ee9e9f1010..c2d9eef81024 100644 --- a/tests/unit/components/Charts/useChartLabelFormats.test.ts +++ b/tests/unit/components/Charts/useChartLabelFormats.test.ts @@ -2,8 +2,18 @@ import {renderHook} from '@testing-library/react-native'; import useChartLabelFormats from '@components/Charts/hooks/useChartLabelFormats'; import type {ChartDataPoint, UnitPosition, UnitWithFallback} from '@components/Charts/types'; +import type * as ChartUtils from '@components/Charts/utils'; import {CHART_SERIES_KEY} from '@components/Search/buildChartSeries'; +import type {SkTypeface, SkTypefaceFontProvider} from '@shopify/react-native-skia'; + +import createMock from '../../../utils/createMock'; + +jest.mock('@components/Charts/utils', () => { + const actual = jest.requireActual('@components/Charts/utils'); + return {...actual, canFontRenderText: (text: string | undefined) => text !== '€'}; +}); + let mockNumberFormat = (n: number, options?: Intl.NumberFormatOptions) => n.toLocaleString('en-US', options); jest.mock('@hooks/useLocalize', () => @@ -22,6 +32,22 @@ beforeEach(() => { }); describe('useChartLabelFormats', () => { + it('uses an object fallback but omits an unsupported plain-string unit', () => { + // Given a simulated glyph check that rejects the symbol, and equivalent object and string units + const typeface = createMock({getGlyphIDs: (text: string) => [...text].map((char) => (char === '€' ? 0 : 1))}); + const fontManager = createMock({matchFamilyStyle: () => typeface}); + + // When the production hook formats both unit categories + const objectFormat = renderHook(() => useChartLabelFormats({data: SAMPLE_DATA, unit: {value: '€', fallback: 'EUR'}, unitPosition: 'right', fontManager})); + const stringFormat = renderHook(() => useChartLabelFormats({data: SAMPLE_DATA, unit: '€', unitPosition: 'right', fontManager})); + + // Then only the object can supply a fallback; position and compact formatting survive + expect(objectFormat.result.current.formatValue(100)).toBe('100 EUR'); + expect(objectFormat.result.current.formatCompactValue(100000)).toBe('100k EUR'); + expect(stringFormat.result.current.formatValue(100)).toBe('100'); + expect(stringFormat.result.current.formatLabel(1)).toBe('Feb'); + }); + it('formats with single-char unit without separator', () => { const {result} = renderHook(() => useChartLabelFormats({data: SAMPLE_DATA, unit: {value: '$', fallback: 'USD'}, unitPosition: 'left'})); diff --git a/tests/unit/usePreferredCurrencyTest.tsx b/tests/unit/usePreferredCurrencyTest.tsx new file mode 100644 index 000000000000..91e1a88dcb78 --- /dev/null +++ b/tests/unit/usePreferredCurrencyTest.tsx @@ -0,0 +1,79 @@ +import {act, cleanup, renderHook} from '@testing-library/react-native'; + +import usePreferredCurrency from '@hooks/usePreferredCurrency'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {FundList} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const ACCOUNT_ID = 123; + +describe('usePreferredCurrency', () => { + beforeAll(() => Onyx.init({keys: ONYXKEYS})); + + beforeEach(async () => { + await act(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.SESSION, {accountID: ACCOUNT_ID}); + await waitForBatchedUpdates(); + }); + }); + afterEach(async () => { + cleanup(); + await act(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + }); + it('uses a supported local currency', async () => { + // Given a user with a supported non-USD local currency + await act(async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {[ACCOUNT_ID]: {accountID: ACCOUNT_ID, localCurrencyCode: CONST.PAYMENT_CARD_CURRENCY.GBP}}); + await waitForBatchedUpdates(); + }); + // When the production preference hook reads Onyx + const {result} = renderHook(usePreferredCurrency); + await waitForBatchedUpdatesWithAct(); + // Then it returns the supported local currency + expect(result.current).toBe(CONST.PAYMENT_CARD_CURRENCY.GBP); + }); + it('rejects an unsupported local currency', async () => { + // Given a user whose local currency cannot be used for card payments + await act(async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {[ACCOUNT_ID]: {accountID: ACCOUNT_ID, localCurrencyCode: 'JPY'}}); + await waitForBatchedUpdates(); + }); + // When the production preference hook reads Onyx + const {result} = renderHook(usePreferredCurrency); + await waitForBatchedUpdatesWithAct(); + // Then it falls back to USD + expect(result.current).toBe(CONST.PAYMENT_CARD_CURRENCY.USD); + }); + it('defaults to USD when the local currency is absent', async () => { + // Given a session without local currency data + // When the production preference hook reads Onyx + const {result} = renderHook(usePreferredCurrency); + await waitForBatchedUpdatesWithAct(); + // Then it defaults to USD + expect(result.current).toBe(CONST.PAYMENT_CARD_CURRENCY.USD); + }); + it('prefers the billing card to the local currency', async () => { + // Given a local currency and a billing card with a different currency + const fundList: FundList = {billingCard: {accountData: {currency: CONST.PAYMENT_CARD_CURRENCY.EUR, additionalData: {isBillingCard: true}}}}; + await act(async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {[ACCOUNT_ID]: {accountID: ACCOUNT_ID, localCurrencyCode: CONST.PAYMENT_CARD_CURRENCY.GBP}}); + await Onyx.set(ONYXKEYS.FUND_LIST, fundList); + await waitForBatchedUpdates(); + }); + // When the production preference hook reads Onyx + const {result} = renderHook(usePreferredCurrency); + await waitForBatchedUpdatesWithAct(); + // Then the billing card wins + expect(result.current).toBe(CONST.PAYMENT_CARD_CURRENCY.EUR); + }); +});