Skip to content
Draft
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
4 changes: 2 additions & 2 deletions src/components/AttachmentPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -54,7 +54,7 @@ function AttachmentPreview({source, aspectRatio = 1, onPress, onLoadError}: Atta
const [thumbnail, setThumbnail] = useState<VideoThumbnail | null>(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) {
Expand Down
3 changes: 2 additions & 1 deletion src/components/Charts/hooks/useChartLabelFormats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 2 additions & 3 deletions src/hooks/usePreferredCurrency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -88,8 +88,8 @@ function DynamicWorkspaceOverviewPlanTypePage({policy}: WithPolicyProps) {
})
.map<WorkspacePlanTypeItem>((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:
Expand Down
9 changes: 5 additions & 4 deletions src/pages/workspace/upgrade/UpgradeIntro.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,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;
Expand Down
69 changes: 69 additions & 0 deletions tests/unit/AttachmentPreviewTest.tsx
Original file line number Diff line number Diff line change
@@ -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<MockSourceLoadEvent, [typeof mockPlayer, 'sourceLoad', null]>(() => mockEvent);
jest.mock('expo', () => ({useEvent: (...args: Parameters<typeof mockUseEvent>) => 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: () => <MockText>default attachment</MockText>}));
jest.mock('@components/Pressable', () => ({PressableWithFeedback: ({children}: {children: React.ReactNode}) => <MockView>{children}</MockView>}));
jest.mock('@components/Icon', () => ({__esModule: true, default: () => <MockText>play icon</MockText>}));
jest.mock('@components/Image', () => ({__esModule: true, default: () => <MockText>thumbnail</MockText>}));
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(
<AttachmentPreview
source="clip.mp4"
aspectRatio={1}
onPress={jest.fn()}
/>,
);
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(
<AttachmentPreview
source="clip.mp4"
aspectRatio={1}
onPress={jest.fn()}
/>,
);
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(
<AttachmentPreview
source="notes.txt"
aspectRatio={1}
onPress={jest.fn()}
/>,
);
// Then it takes the default branch without a video request
expect(screen.getByText('default attachment')).toBeTruthy();
expect(mockGenerateThumbnails).not.toHaveBeenCalled();
});
88 changes: 88 additions & 0 deletions tests/unit/DynamicWorkspaceOverviewPlanTypePageTest.tsx
Original file line number Diff line number Diff line change
@@ -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}>) => () => <Component policy={mockPolicy} />}));
jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: ({children}: {children: React.ReactNode}) => children}));
jest.mock('@components/ScreenWrapper', () => ({__esModule: true, default: ({children}: {children: React.ReactNode}) => <MockView>{children}</MockView>}));
jest.mock('@components/SelectionList', () => ({
__esModule: true,
default: (props: typeof mockSelection & {footerContent: React.ReactNode}) => {
mockSelection = props;
return <MockView>{props.footerContent}</MockView>;
},
}));
jest.mock('@components/Button', () => ({
__esModule: true,
default: Object.assign(
({children, onPress}: {children: React.ReactNode; onPress: () => void}) => (
<MockView
onStartShouldSetResponder={() => true}
onResponderRelease={onPress}
>
{children}
</MockView>
),
{
Text: ({children}: {children: React.ReactNode}) => <MockText>{children}</MockText>,
},
),
}));
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(<DynamicWorkspaceOverviewPlanTypePage route={createMock<React.ComponentProps<typeof DynamicWorkspaceOverviewPlanTypePage>['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(<DynamicWorkspaceOverviewPlanTypePage route={createMock<React.ComponentProps<typeof DynamicWorkspaceOverviewPlanTypePage>['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(<DynamicWorkspaceOverviewPlanTypePage route={createMock<React.ComponentProps<typeof DynamicWorkspaceOverviewPlanTypePage>['route']>({})} />);
// Then selection is disabled and confirmation replaces Save
expect(mockSelection?.isDisabled).toBe(true);
expect(screen.getByText('common.buttonConfirm')).toBeTruthy();
});
91 changes: 91 additions & 0 deletions tests/unit/UpgradeIntroTest.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof UpgradeIntroView> | 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: () => <MockText>generic upgrade</MockText>}));
jest.mock('@pages/workspace/upgrade/UpgradeIntroView', () => ({
__esModule: true,
default: (props: React.ComponentProps<typeof UpgradeIntroView>) => {
mockIntroProps = props;
return <MockText>feature upgrade</MockText>;
},
}));
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(
<UpgradeIntro
feature={feature}
policyID="policy-1"
onUpgrade={jest.fn()}
/>,
);
// 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(
<UpgradeIntro
feature={CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals}
policyID="policy-1"
onUpgrade={jest.fn()}
/>,
),
).toThrow(`Missing icons: ${mockMissingIcon}`);
});
it('shows the generic view without a feature', () => {
// Given no feature was selected
// When the real upgrade intro renders
render(<UpgradeIntro onUpgrade={jest.fn()} />);
// 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(
<UpgradeIntro
feature={CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals}
policyID="policy-1"
onUpgrade={jest.fn()}
/>,
);
// Then the approvals illustration receives its square-border style
expect(mockIntroProps?.iconAdditionalStyles).toEqual({borderRadius: 0});
});
});
26 changes: 26 additions & 0 deletions tests/unit/components/Charts/useChartLabelFormats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ChartUtils>('@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', () =>
Expand All @@ -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<SkTypeface>({getGlyphIDs: (text: string) => [...text].map((char) => (char === '€' ? 0 : 1))});
const fontManager = createMock<SkTypefaceFontProvider>({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'}));

Expand Down
Loading
Loading