From 4313716078d935890c49c50594d9a6d6a930844e Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:44:13 +0000 Subject: [PATCH 1/4] Remove type assertions from emoji picker, workspace room and settlement account components Seatbelt-Operation: e7694751-9deb-4d30-b155-87815525cae0 --- assets/emojis/types.ts | 3 +- .../EmojiPickerMenu/index.native.tsx | 5 +- .../EmojiPicker/EmojiPickerMenu/index.tsx | 5 +- src/components/Icon/BankIconsUtils.ts | 3 +- src/components/SettlementAccountSelector.tsx | 9 +- src/pages/workspace/WorkspaceNewRoomPage.tsx | 17 +- .../DynamicWorkspaceSettlementAccountPage.tsx | 9 +- ...amicWorkspaceSettlementAccountPageTest.tsx | 339 ++++++++ tests/ui/EmojiPickerMenuComponentTest.tsx | 734 ++++++++++++++++++ tests/ui/WorkspaceNewRoomPageTest.tsx | 287 +++++++ 10 files changed, 1391 insertions(+), 20 deletions(-) create mode 100644 tests/ui/DynamicWorkspaceSettlementAccountPageTest.tsx create mode 100644 tests/ui/EmojiPickerMenuComponentTest.tsx create mode 100644 tests/ui/WorkspaceNewRoomPageTest.tsx diff --git a/assets/emojis/types.ts b/assets/emojis/types.ts index a0dbeb41add0..54488f1c41d0 100644 --- a/assets/emojis/types.ts +++ b/assets/emojis/types.ts @@ -1,3 +1,4 @@ +import type en from '@src/languages/en'; import type IconAsset from '@src/types/utils/IconAsset'; type Emoji = { @@ -16,7 +17,7 @@ type Emoji = { type HeaderEmoji = { header: true; icon: IconAsset; - code: string; + code: keyof typeof en.emojiPicker.headers; }; type PickerEmoji = Emoji | HeaderEmoji; diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx index 75a683bdc258..a6e3a9c9f380 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx @@ -17,7 +17,6 @@ import type {EmojiPickerList, EmojiPickerListItem} from '@libs/EmojiUtils'; import {getRemovedSkinToneEmoji} from '@libs/EmojiUtils'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import type {ListRenderItem} from '@shopify/flash-list'; import type {ComponentRef} from 'react'; @@ -161,11 +160,11 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro ref={getHeaderRef(index)} accessible accessibilityRole="header" - accessibilityLabel={translate(`emojiPicker.headers.${code}` as TranslationPaths)} + accessibilityLabel={translate(`emojiPicker.headers.${item.code}`)} style={[styles.emojiHeaderContainer, target === 'StickyHeader' ? styles.mh4 : {width: windowWidth}]} onLayout={() => handleHeaderLayout(index)} > - {translate(`emojiPicker.headers.${code}` as TranslationPaths)} + {translate(`emojiPicker.headers.${item.code}`)} ); } diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx index 813b5e35cdbb..7910299fd78b 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx @@ -22,7 +22,6 @@ import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposit import {shouldAutoFocusOnKeyPress} from '@libs/ReportUtils'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import type {ListRenderItem} from '@shopify/flash-list'; import type {ComponentRef} from 'react'; @@ -84,7 +83,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro } pendingHeaderFocusIndexRef.current = null; - (headerRef.current as unknown as HTMLElement).focus?.(); + headerRef.current.focus?.(); return true; }, []); @@ -343,7 +342,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro target === 'StickyHeader' ? styles.stickyHeaderEmoji : undefined, ]} > - {translate(`emojiPicker.headers.${code}` as TranslationPaths)} + {translate(`emojiPicker.headers.${item.code}`)} ); } diff --git a/src/components/Icon/BankIconsUtils.ts b/src/components/Icon/BankIconsUtils.ts index 722437bb39b3..e8d160b7747f 100644 --- a/src/components/Icon/BankIconsUtils.ts +++ b/src/components/Icon/BankIconsUtils.ts @@ -1,12 +1,13 @@ import type {ThemeStyles} from '@styles/index'; import CONST from '@src/CONST'; +import type AccountData from '@src/types/onyx/AccountData'; import type {BankName, BankNameKey} from '@src/types/onyx/Bank'; import type IconAsset from '@src/types/utils/IconAsset'; type BankIconParams = { styles: ThemeStyles; - bankName?: BankName; + bankName?: AccountData['addressName']; isCard?: boolean; maxIconSize?: number; }; diff --git a/src/components/SettlementAccountSelector.tsx b/src/components/SettlementAccountSelector.tsx index 6a1dcddc0543..589cf4c6d300 100644 --- a/src/components/SettlementAccountSelector.tsx +++ b/src/components/SettlementAccountSelector.tsx @@ -2,11 +2,10 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {BankName} from '@src/types/onyx/Bank'; - import React from 'react'; import {View} from 'react-native'; +import type {BankIconParams} from './Icon/BankIconsUtils'; import type {ListItem} from './SelectionList/types'; import Icon from './Icon'; @@ -31,7 +30,11 @@ type SettlementAccountSelectorProps = { initiallyFocusedItemKey?: string; }; -function BankAccountListItemLeftElement({bankName}: {bankName: BankName}) { +type BankAccountListItemLeftElementProps = { + bankName: NonNullable; +}; + +function BankAccountListItemLeftElement({bankName}: BankAccountListItemLeftElementProps) { const styles = useThemeStyles(); const {icon, iconSize, iconStyles} = getBankIcon({bankName, styles}); diff --git a/src/pages/workspace/WorkspaceNewRoomPage.tsx b/src/pages/workspace/WorkspaceNewRoomPage.tsx index 2dd3f7fd04ee..4a1c668560ff 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.tsx +++ b/src/pages/workspace/WorkspaceNewRoomPage.tsx @@ -346,7 +346,8 @@ function WorkspaceNewRoomPage({ref, policyID: lockedPolicyID}: WorkspaceNewRoomP label={translate('workspace.common.workspace')} items={workspaceOptions} value={policyID} - onValueChange={(value) => setPolicyID(value as typeof policyID)} + valueType="string" + onValueChange={(value) => setPolicyID(value)} /> )} @@ -358,7 +359,12 @@ function WorkspaceNewRoomPage({ref, policyID: lockedPolicyID}: WorkspaceNewRoomP label={translate('writeCapabilityPage.label')} items={writeCapabilityOptions} value={writeCapability} - onValueChange={(value) => setWriteCapability(value as typeof writeCapability)} + onValueChange={(value) => { + const selectedOption = writeCapabilityOptions.find((option) => option.value === value); + if (selectedOption) { + setWriteCapability(selectedOption.value); + } + }} /> )} @@ -368,7 +374,12 @@ function WorkspaceNewRoomPage({ref, policyID: lockedPolicyID}: WorkspaceNewRoomP inputID={INPUT_IDS.VISIBILITY} label={translate('newRoomPage.visibility')} items={visibilityOptions} - onValueChange={(value) => setVisibility(value as typeof visibility)} + onValueChange={(value) => { + const selectedOption = visibilityOptions.find((option) => option.value === value); + if (selectedOption) { + setVisibility(selectedOption.value); + } + }} value={visibility} furtherDetails={visibilityDescription} shouldShowTooltips={false} diff --git a/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx b/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx index af1bef800429..52868fac6d25 100644 --- a/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx @@ -34,8 +34,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {BankName} from '@src/types/onyx/Bank'; -import type {ConnectionName} from '@src/types/onyx/Policy'; import {useNavigationState} from '@react-navigation/native'; import {isExpensifyCardContinuousReconciliationEnabledSelector} from '@selectors/Card'; @@ -104,7 +102,7 @@ function DynamicWorkspaceSettlementAccountPage({route}: WorkspaceSettlementAccou }, [cardSettings, hasActiveAccountingConnection, continuousReconciliation, reconciliationConnection, fetchPolicyAccountingData]); const eligibleBankAccountsOptions: BankAccountListItem[] = eligibleBankAccounts.map((bankAccount) => { - const bankName = (bankAccount.accountData?.addressName ?? '') as BankName; + const bankName = bankAccount.accountData?.addressName ?? ''; const bankAccountNumber = bankAccount.accountData?.accountNumber ?? ''; const bankAccountID = bankAccount.accountData?.bankAccountID ?? bankAccount.methodID; @@ -121,7 +119,7 @@ function DynamicWorkspaceSettlementAccountPage({route}: WorkspaceSettlementAccou const fallbackBankAccountOption: BankAccountListItem = { value: paymentBankAccountID, text: paymentBankAccountAddressName ?? '', - leftElement: , + leftElement: , alternateText: `${translate('workspace.expensifyCard.accountEndingIn')} ${getLastFourDigits(paymentBankAccountNumberFromCardSettings ?? '')}`, keyForList: paymentBankAccountID?.toString() ?? '', isSelected: true, @@ -139,8 +137,7 @@ function DynamicWorkspaceSettlementAccountPage({route}: WorkspaceSettlementAccou }; const getCustomListHeaderContent = () => { - const connectionName = reconciliationConnection ?? ''; - const connectionParam = getRouteParamForConnection(connectionName as ConnectionName); + const connectionParam = reconciliationConnection ? getRouteParamForConnection(reconciliationConnection) : undefined; return ( <> diff --git a/tests/ui/DynamicWorkspaceSettlementAccountPageTest.tsx b/tests/ui/DynamicWorkspaceSettlementAccountPageTest.tsx new file mode 100644 index 000000000000..8cd6377def4e --- /dev/null +++ b/tests/ui/DynamicWorkspaceSettlementAccountPageTest.tsx @@ -0,0 +1,339 @@ +import {act, cleanup, fireEvent, render, screen} from '@testing-library/react-native'; + +import BankOfAmerica from '@assets/images/bank-icons/bank-of-america.svg'; +import Chase from '@assets/images/bank-icons/chase.svg'; +import GenericBank from '@assets/images/bank-icons/generic-bank-account.svg'; + +import ComposeProviders from '@components/ComposeProviders'; +import HTMLEngineProvider from '@components/HTMLEngineProvider'; +import Icon from '@components/Icon'; +import type * as WebBankIconsModule from '@components/Icon/BankIcons/index'; +import type * as NativeBankIconsModule from '@components/Icon/BankIcons/index.native'; +import type * as BankIconsUtilsModule from '@components/Icon/BankIconsUtils'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import RenderHTML from '@components/RenderHTML'; +import type {SelectionListProps} from '@components/SelectionList/types'; +import type {BankAccountListItem} from '@components/SettlementAccountSelector'; + +import {getRouteParamForConnection} from '@libs/AccountingUtils'; +import type * as PolicyConnectionsModule from '@libs/actions/PolicyConnections'; +import {openPolicyAccountingPage} from '@libs/actions/PolicyConnections'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; + +import Navigation from '@navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import DynamicWorkspaceSettlementAccountPage from '@pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage'; + +import type {ThemeStyles} from '@styles/index'; +import variables from '@styles/variables'; + +import type * as CardActionsModule from '@userActions/Card'; +import {updateSettlementAccount} from '@userActions/Card'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type BankAccount from '@src/types/onyx/BankAccount'; +import type {BankAccountList} from '@src/types/onyx/BankAccount'; +import type ExpensifyCardSettings from '@src/types/onyx/ExpensifyCardSettings'; +import type Policy from '@src/types/onyx/Policy'; +import type {PolicyConnectionName} from '@src/types/onyx/Policy'; + +import type * as NativeNavigation from '@react-navigation/native'; +import type ReactNative from 'react-native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createMock from '../utils/createMock'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: () => true, + useNavigationState: () => false, + useRoute: () => ({key: 'settlement', name: 'Workspace_Dynamic_ExpensifyCard_Settings_Account', params: {policyID: 'policy123'}}), + usePreventRemove: () => {}, +})); +jest.mock('@src/hooks/useResponsiveLayout'); +jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({__esModule: true, default: () => ({didScreenTransitionEnd: true})})); +jest.mock('@hooks/useWorkspaceAccountID', () => ({__esModule: true, default: () => 424242})); +jest.mock( + '@pages/workspace/AccessOrNotFoundWrapper', + () => + ({children}: React.PropsWithChildren) => + children, +); +jest.mock('@userActions/Card', () => ({ + ...jest.requireActual('@userActions/Card'), + updateSettlementAccount: jest.fn, Parameters>(), +})); +jest.mock('@libs/actions/PolicyConnections', () => ({ + ...jest.requireActual('@libs/actions/PolicyConnections'), + openPolicyAccountingPage: jest.fn, Parameters>(), +})); + +// List virtualization is below SettlementAccountSelector. The actual row icon, header and selection callback still execute. +jest.mock('@components/SelectionList', () => { + const RN = jest.requireActual('react-native'); + return ({data, onSelectRow, customListHeaderContent, initiallyFocusedItemKey}: SelectionListProps) => ( + + {customListHeaderContent} + {data.map((item) => ( + onSelectRow(item)} + > + {item.leftElement} + {item.text} + {item.alternateText} + + ))} + + ); +}); + +const getNativeBankIcon = jest.requireActual('../../src/components/Icon/BankIcons/index.native.ts').default; + +const policyID = 'policy123'; +const fundID = 424242; +const cardSettingsKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${fundID}` as const; +const programKey = CONST.COUNTRY.US; +const policy = createMock({id: policyID, name: 'Card workspace', role: CONST.POLICY.ROLE.ADMIN, outputCurrency: CONST.CURRENCY.USD}); +const cardSettings: ExpensifyCardSettings = { + [programKey]: {domainName: 'example.com', paymentBankAccountID: 11, paymentBankAccountAddressName: 'Unknown Credit Union', paymentBankAccountNumber: '12340011'}, +}; +const eligibleChase = createMock({ + methodID: 22, + title: 'Chase business', + bankCurrency: CONST.CURRENCY.USD, + bankCountry: CONST.COUNTRY.US, + accountData: {type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, allowDebit: true, state: CONST.BANK_ACCOUNT.STATE.OPEN, addressName: 'CHASE Bank', accountNumber: '12340022', bankAccountID: 22}, +}); +const eligibleUnknown = createMock({ + methodID: 33, + title: 'Credit union business', + bankCurrency: CONST.CURRENCY.USD, + bankCountry: CONST.COUNTRY.US, + accountData: {type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, allowDebit: true, state: CONST.BANK_ACCOUNT.STATE.OPEN, addressName: 'Unknown Credit Union', accountNumber: '12340033'}, +}); +const ineligible = createMock({ + methodID: 44, + title: 'Pending account', + bankCurrency: CONST.CURRENCY.USD, + bankCountry: CONST.COUNTRY.US, + accountData: {type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, allowDebit: true, state: CONST.BANK_ACCOUNT.STATE.SETUP, addressName: 'Chase Bank'}, +}); +type SettlementPageProps = PlatformStackScreenProps; +const route: SettlementPageProps['route'] = {key: 'settlement', name: SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_SETTINGS_ACCOUNT, params: {policyID}}; + +function renderSettlementPage() { + return render( + + ({})} + /> + , + ); +} + +describe('DynamicWorkspaceSettlementAccountPage', () => { + beforeEach(async () => { + Onyx.init({keys: ONYXKEYS}); + jest.mocked(updateSettlementAccount).mockClear(); + jest.mocked(openPolicyAccountingPage).mockClear(); + jest.spyOn(Navigation, 'goBack').mockImplementation(() => {}); + await act(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + await Onyx.set(cardSettingsKey, cardSettings); + }); + await waitForBatchedUpdatesWithAct(); + }); + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); + + it('uses eligible accounts ahead of the fallback and emits account IDs', async () => { + // Given real backend account fields, only debit-enabled business accounts past setup are eligible. + const accounts: BankAccountList = {[`22`]: eligibleChase, [`33`]: eligibleUnknown, [`44`]: ineligible}; + await act(async () => { + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, accounts); + }); + await waitForBatchedUpdatesWithAct(); + const page = renderSettlementPage(); + await waitForBatchedUpdatesWithAct(); + + // When the actual page composes list rows, institution names must reach the real icon matcher unchanged. + expect(screen.getByText('Chase business')).toBeOnTheScreen(); + expect(screen.getByText('Credit union business')).toBeOnTheScreen(); + expect(screen.queryByText('Pending account')).toBeNull(); + expect(screen.queryByTestId('settlement-account-11')).toBeNull(); + expect(screen.getByText('Account ending in 0022')).toBeOnTheScreen(); + expect(page.UNSAFE_getAllByType(Icon).some((node) => node.props.src === Chase)).toBe(true); + expect(page.UNSAFE_getAllByType(Icon).some((node) => node.props.src === GenericBank)).toBe(true); + fireEvent.press(screen.getByTestId('settlement-account-22')); + fireEvent.press(screen.getByTestId('settlement-account-33')); + + // Then accountData.bankAccountID and methodID fallback both retain their exact action arguments. + expect(updateSettlementAccount).toHaveBeenCalledTimes(2); + expect(jest.mocked(updateSettlementAccount).mock.calls).toEqual([ + ['example.com', fundID, policyID, programKey, 22, 11], + ['example.com', fundID, policyID, programKey, 33, 11], + ]); + expect(Navigation.goBack).toHaveBeenCalledTimes(2); + }); + + it.each(['Unknown Credit Union', 'BankOfAmerica', ''])('renders the selected fallback for institution "%s" without eligible accounts', async (bankName) => { + // Given no eligible account, nested card settings are the source of the selected fallback and its last four digits. + await act(async () => { + await Onyx.set(cardSettingsKey, {[programKey]: {...cardSettings[programKey], paymentBankAccountAddressName: bankName}}); + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, {[`44`]: ineligible}); + }); + await waitForBatchedUpdatesWithAct(); + const page = renderSettlementPage(); + await waitForBatchedUpdatesWithAct(); + + // When the fallback row is selected, its existing account ID and selection state must survive arbitrary names. + const row = screen.getByTestId('settlement-account-11'); + expect(row.props.accessibilityState).toEqual({selected: true}); + expect(screen.getByText('Account ending in 0011')).toBeOnTheScreen(); + expect(screen.getByTestId('settlement-focused-11')).toBeOnTheScreen(); + const expectedIcon = bankName === 'BankOfAmerica' ? BankOfAmerica : GenericBank; + expect(page.UNSAFE_getAllByType(Icon).some((node) => node.props.src === expectedIcon)).toBe(true); + fireEvent.press(row); + + // Then the real selector delivers the selected ID rather than a display name or list index. + expect(updateSettlementAccount).toHaveBeenCalledTimes(1); + expect(updateSettlementAccount).toHaveBeenCalledWith('example.com', fundID, policyID, programKey, 11, 11); + }); + + it.each([ + {connection: undefined, enabled: true, active: true, visible: false}, + {connection: CONST.POLICY.CONNECTIONS.NAME.QBO, enabled: false, active: true, visible: false}, + {connection: CONST.POLICY.CONNECTIONS.NAME.QBO, enabled: true, active: false, visible: false}, + ...Object.values(CONST.POLICY.CONNECTIONS.NAME).map((connection) => ({connection, enabled: true, active: true, visible: !!getRouteParamForConnection(connection)})), + ] satisfies Array<{connection: PolicyConnectionName | undefined; enabled: boolean; active: boolean; visible: boolean}>)( + 'preserves reconciliation link visibility for $connection, enabled=$enabled, active=$active', + async ({connection, enabled, active, visible}) => { + // Given independent connection and reconciliation state, no single flag is enough to authorize a link. + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, active ? {...policy, connections: {quickbooksOnline: {}}} : policy); + await Onyx.set(`${ONYXKEYS.COLLECTION.EXPENSIFY_CARD_USE_CONTINUOUS_RECONCILIATION}${fundID}`, enabled); + if (connection) { + await Onyx.set(`${ONYXKEYS.COLLECTION.EXPENSIFY_CARD_CONTINUOUS_RECONCILIATION_CONNECTION}${fundID}`, connection); + } + }); + await waitForBatchedUpdatesWithAct(); + + // When the actual page uses the real accounting mapping, absent or disabled connections remain hidden. + const page = renderSettlementPage(); + await waitForBatchedUpdatesWithAct(); + const html = page.UNSAFE_queryAllByType(RenderHTML); + + // Then known mappings retain the route, fund and masked account while other states render no reconciliation HTML. + expect(html).toHaveLength(visible ? 1 : 0); + if (visible && connection) { + const routeParam = getRouteParamForConnection(connection); + expect(routeParam).toBeDefined(); + if (!routeParam) { + throw new Error('Expected a mapped connection'); + } + expect(html.at(0)?.props.html).toContain( + `${ROUTES.WORKSPACE_ACCOUNTING_CARD_RECONCILIATION.getRoute(policyID, routeParam)}/${DYNAMIC_ROUTES.WORKSPACE_ACCOUNTING_RECONCILIATION_ACCOUNT_SETTINGS.path}?fundID=${fundID}`, + ); + expect(html.at(0)?.props.html).toContain(`${CONST.MASKED_PAN_PREFIX}0011`); + } + }, + ); +}); + +describe.each(['web', 'native'])('real %s bank icon names', (platform) => { + it.each([ + {bankName: 'CHASE Bank', key: 'CHASE', cachedComponent: Chase}, + {bankName: 'BankOfAmerica', key: 'BANK_OF_AMERICA', cachedComponent: BankOfAmerica}, + {bankName: 'Unknown Credit Union', key: '', cachedComponent: GenericBank}, + {bankName: '', key: '', cachedComponent: GenericBank}, + ] as const)('preserves matching and fallback for $bankName', ({bankName, key, cachedComponent}) => { + // Given setup-owned SVG functions, web extraction needs a distinct real transformed component at their default property. + const realChase = jest.requireActual('@assets/images/bank-icons/chase.svg'); + const realBankOfAmerica = jest.requireActual('@assets/images/bank-icons/bank-of-america.svg'); + for (const component of [Chase, BankOfAmerica, GenericBank, realChase, realBankOfAmerica]) { + expect(component).toBeDefined(); + expect(component).not.toBeNull(); + } + expect(Chase).not.toBe(BankOfAmerica); + expect(GenericBank).not.toBe(Chase); + expect(GenericBank).not.toBe(BankOfAmerica); + expect(realChase).not.toBe(Chase); + expect(realBankOfAmerica).not.toBe(BankOfAmerica); + expect(realChase).not.toBe(realBankOfAmerica); + const chaseDefault = Object.getOwnPropertyDescriptor(Chase, 'default'); + const americaDefault = Object.getOwnPropertyDescriptor(BankOfAmerica, 'default'); + expect(chaseDefault).toBeUndefined(); + expect(americaDefault).toBeUndefined(); + expect(Object.isExtensible(Chase)).toBe(true); + expect(Object.isExtensible(BankOfAmerica)).toBe(true); + const styles = createMock({bankIconContainer: {}, cardIcon: {}}); + const getWebBankIcon = jest.requireActual('../../src/components/Icon/BankIcons/index.ts').default; + const {getBankIconAsset, getBankNameKey} = jest.requireActual('@components/Icon/BankIconsUtils'); + try { + if (platform === 'web') { + const chaseNamespace = Object.assign(Chase, {default: realChase}); + const americaNamespace = Object.assign(BankOfAmerica, {default: realBankOfAmerica}); + expect(chaseNamespace.default).toBe(realChase); + expect(americaNamespace.default).toBe(realBankOfAmerica); + } + + // When the actual matcher, loader and platform consumer run, neither the loader nor the result is stubbed. + expect(getBankNameKey(bankName.toLowerCase())).toBe(key); + const result = (platform === 'web' ? getWebBankIcon : getNativeBankIcon)({bankName, styles}); + const webComponent = key === 'CHASE' ? realChase : realBankOfAmerica; + + // Then distinct nonempty platform oracles expose omitted extraction, wrong module bindings and missing fallbacks. + expect(result.icon).toBeDefined(); + expect(result.icon).not.toBeNull(); + if (key) { + const asset = getBankIconAsset(key, false); + expect(asset).toBe(cachedComponent); + if (platform === 'web') { + expect(result.icon).toBe(webComponent); + expect(result.icon).not.toBe(asset); + if (asset === null || (typeof asset !== 'object' && typeof asset !== 'function') || !('default' in asset)) { + throw new Error('Expected the web SVG namespace default'); + } + expect(asset.default).toBe(webComponent); + } else { + expect(result.icon).toBe(cachedComponent); + expect(result.icon).not.toBe(webComponent); + expect(Object.getOwnPropertyDescriptor(cachedComponent, 'default')).toBeUndefined(); + } + } else { + expect(result.icon).toBe(GenericBank); + } + expect(result.iconSize).toBe(variables.iconSizeExtraLarge); + expect(result.iconStyles).toEqual([styles.bankIconContainer]); + } finally { + if (chaseDefault) { + Object.defineProperty(Chase, 'default', chaseDefault); + } else { + expect(Reflect.deleteProperty(Chase, 'default')).toBe(true); + } + if (americaDefault) { + Object.defineProperty(BankOfAmerica, 'default', americaDefault); + } else { + expect(Reflect.deleteProperty(BankOfAmerica, 'default')).toBe(true); + } + expect(Object.getOwnPropertyDescriptor(Chase, 'default')).toEqual(chaseDefault); + expect(Object.getOwnPropertyDescriptor(BankOfAmerica, 'default')).toEqual(americaDefault); + } + }); +}); diff --git a/tests/ui/EmojiPickerMenuComponentTest.tsx b/tests/ui/EmojiPickerMenuComponentTest.tsx new file mode 100644 index 000000000000..9b958470646e --- /dev/null +++ b/tests/ui/EmojiPickerMenuComponentTest.tsx @@ -0,0 +1,734 @@ +import {act, cleanup, fireEvent, render, screen} from '@testing-library/react-native'; + +import emojis, {importEmojiLocale} from '@assets/emojis'; +import type {Emoji} from '@assets/emojis/types'; + +import ComposeProviders from '@components/ComposeProviders'; +import type * as WebEmojiMenuModule from '@components/EmojiPicker/EmojiPickerMenu/index'; +import type * as NativeEmojiMenuModule from '@components/EmojiPicker/EmojiPickerMenu/index.native'; +import type EmojiPickerMenuProps from '@components/EmojiPicker/EmojiPickerMenu/types'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import {buildEmojisTrie} from '@libs/EmojiTrie'; +import {getHeaderEmojis, mergeEmojisWithFrequentlyUsedEmojis, processFrequentlyUsedEmojis} from '@libs/EmojiUtils'; +import type {EmojiPickerListItem} from '@libs/EmojiUtils'; +import {isRecord} from '@libs/ObjectUtils'; + +import CONST from '@src/CONST'; +import en from '@src/languages/en'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import type {FlashListProps, FlashListRef, RenderTarget} from '@shopify/flash-list'; +import type {ComponentRef} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; +import type ReactNative from 'react-native'; +import type * as KeyCommand from 'react-native-key-command'; + +import {PortalProvider} from '@gorhom/portal'; +import React from 'react'; +import {AccessibilityInfo, View} from 'react-native'; +import Onyx from 'react-native-onyx'; + +import type * as CreateMockModule from '../utils/createMock'; + +import createMock from '../utils/createMock'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const mockKeyRegistrations: Array<{key: Parameters[0]; callback: Parameters[1]}> = []; + +// Keep real command registrations and subscribers. Native delivery carries the registered trigger, not the DOM key name. +jest.mock('react-native-key-command', () => { + const addListener: typeof KeyCommand.addListener = (key, callback) => { + const registration = {key, callback}; + mockKeyRegistrations.push(registration); + return () => { + const index = mockKeyRegistrations.indexOf(registration); + if (index >= 0) { + mockKeyRegistrations.splice(index, 1); + } + }; + }; + return {constants: {}, addListener, registerKeyCommands: () => {}, unregisterKeyCommands: () => {}}; +}); + +const mockScrollToOffset = jest.fn['scrollToOffset']>>(); +let mockEmojiListProps: FlashListProps; +let mockHiddenHeaders = new Set(); +let mockRenderTarget: RenderTarget = 'Cell'; +let mockVisibleEmojiCode = '👋'; +let refreshEmojiWindow: (() => void) | undefined; +const mockHeaderLabels = en.emojiPicker.headers; +let mockSelectedHeaderGeneration = 0; +const mockFocusDescriptors: Array<{host: ComponentRef; descriptor: PropertyDescriptor | undefined}> = []; +const originalFocusDescriptor = Object.getOwnPropertyDescriptor(View.prototype, 'focus'); + +// The window is controlled below FlashList so both real menus keep their own data, rendering and focus behavior. +jest.mock('@shopify/flash-list', () => { + const ReactLocal = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + const createMockLocal = jest.requireActual('../utils/createMock').default; + const FlashList = ReactLocal.forwardRef, FlashListProps>((props, ref) => { + const [, setWindowRevision] = ReactLocal.useState(0); + refreshEmojiWindow = () => setWindowRevision((revision) => revision + 1); + mockEmojiListProps = props; + const data = props.data; + const renderItem = props.renderItem; + expect(data).toBeDefined(); + expect(renderItem).toBeDefined(); + if (!data || !renderItem) { + throw new Error('Expected actual emoji list data and renderer'); + } + ReactLocal.useImperativeHandle(ref, () => createMockLocal>({scrollToOffset: mockScrollToOffset})); + return ( + + {data.map((item, index) => { + if ('header' in item ? mockHiddenHeaders.has(item.code) : item.code !== mockVisibleEmojiCode && index !== 0 && !('spacer' in item)) { + return null; + } + return ( + 'header' in entry)?.code}-${item.code}-${'header' in item && item.code === 'animalsAndNature' ? mockSelectedHeaderGeneration : 0}`} + testID={`emoji-row-${index}`} + > + {renderItem({item, index, target: mockRenderTarget})} + + ); + })} + + ); + }); + return {FlashList}; +}); + +const WebEmojiPickerMenu = jest.requireActual('../../src/components/EmojiPicker/EmojiPickerMenu/index.tsx').default; +const NativeEmojiPickerMenu = jest.requireActual('../../src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx').default; +const platformCases = [ + {platform: 'web', Menu: WebEmojiPickerMenu}, + {platform: 'native', Menu: NativeEmojiPickerMenu}, +]; +const wave = emojis.find((item): item is Emoji => 'name' in item && item.name === 'wave'); +const smile = emojis.find((item): item is Emoji => 'name' in item && item.name === 'smile'); +const headerLayout = createMock({nativeEvent: {layout: {x: 0, y: 0, width: 100, height: 20}}}); + +function renderEmojiMenu(Menu: typeof WebEmojiPickerMenu, onEmojiSelected: EmojiPickerMenuProps['onEmojiSelected']) { + return render( + + + + + , + ); +} + +// The renderer erases instance. Constructor identity narrows only that actual host, without relabeling its props. +function getEmojiHeaderHost(index: number, platform: string) { + const item = mockEmojiListProps.data?.at(index); + expect(item).toBeDefined(); + if (!item || !('header' in item)) { + throw new Error('Expected a real emoji header item'); + } + const heading = screen.getByTestId(`emoji-row-${index}`).find((node) => node.type === View && (node.props.role === CONST.ROLE.HEADING || node.props.accessibilityRole === 'header')); + if (platform === 'native') { + expect(heading.props.accessibilityRole).toBe('header'); + expect(heading.props.accessibilityLabel).toBe(mockHeaderLabels[item.code]); + } else { + expect(heading.props.role).toBe(CONST.ROLE.HEADING); + expect(heading.props.tabIndex).toBe(-1); + expect(heading.findAll((node) => node.props.children === mockHeaderLabels[item.code]).length).toBeGreaterThan(0); + } + const instance: unknown = heading.instance; + expect(instance).toBeInstanceOf(View); + if (!(instance instanceof View)) { + throw new Error('Expected the rendered native View instance'); + } + return {heading, host: instance}; +} + +beforeAll(async () => { + await importEmojiLocale(CONST.LOCALES.EN); + buildEmojisTrie(CONST.LOCALES.EN); +}); + +describe.each(platformCases)('actual $platform EmojiPickerMenu', ({platform, Menu}) => { + beforeEach(async () => { + jest.useFakeTimers(); + Onyx.init({keys: ONYXKEYS}); + await act(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + await Onyx.set(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, 1); + await Onyx.set(ONYXKEYS.FREQUENTLY_USED_EMOJIS, []); + }); + await waitForBatchedUpdatesWithAct(); + mockHiddenHeaders = new Set(); + mockRenderTarget = 'Cell'; + mockVisibleEmojiCode = '👋'; + mockScrollToOffset.mockClear(); + mockSelectedHeaderGeneration = 0; + refreshEmojiWindow = undefined; + }); + + afterEach(() => { + cleanup(); + for (const {host, descriptor} of mockFocusDescriptors) { + if (descriptor) { + Object.defineProperty(host, 'focus', descriptor); + } else { + expect(Reflect.deleteProperty(host, 'focus')).toBe(true); + } + expect(Object.getOwnPropertyDescriptor(host, 'focus')).toEqual(descriptor); + } + mockFocusDescriptors.length = 0; + expect(Object.getOwnPropertyDescriptor(View.prototype, 'focus')).toEqual(originalFocusDescriptor); + refreshEmojiWindow = undefined; + jest.clearAllTimers(); + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it.each(['Cell', 'StickyHeader'] satisfies RenderTarget[])('renders every catalog header and no spacers as %s', async (target) => { + // Given the canonical catalog without recent history, every non-recent category must be reachable. + mockRenderTarget = target; + const onEmojiSelected = jest.fn>(); + renderEmojiMenu(Menu, onEmojiSelected); + await waitForBatchedUpdatesWithAct(); + const data = mockEmojiListProps.data; + expect(data).toBeDefined(); + if (!data || !mockEmojiListProps.renderItem) { + throw new Error('Expected real FlashList data and renderer'); + } + if (platform === 'native') { + expect(mockEmojiListProps.extraData).toEqual([data, 1]); + } + const headers = getHeaderEmojis([...data]); + expect(headers).toHaveLength(8); + expect(headers.map(({code}) => code)).toEqual(emojis.flatMap((item) => ('header' in item ? [item.code] : []))); + + // When FlashList renders real menu items, header text must use the owner's catalog key. + for (const {index} of headers) { + const item = data.at(index); + expect(item).toBeDefined(); + if (!item || !('header' in item)) { + throw new Error('Expected a catalog header'); + } + const row = screen.getByTestId(`emoji-row-${index}`); + const header = row.find((node) => node.type === View && (node.props.role === CONST.ROLE.HEADING || node.props.accessibilityRole === 'header')); + expect(screen.getAllByText(mockHeaderLabels[item.code]).length).toBeGreaterThan(0); + // Then each platform retains its own accessibility contract instead of borrowing the other renderer. + if (platform === 'native') { + expect(header.props.accessibilityRole).toBe('header'); + expect(header.props.accessibilityLabel).toBe(mockHeaderLabels[item.code]); + } else { + expect(header.props.role).toBe(CONST.ROLE.HEADING); + expect(header.props.tabIndex).toBe(-1); + } + } + expect(screen.queryByText(en.emojiPicker.headers.frequentlyUsed)).toBeNull(); + const spacers = data.flatMap((item, index) => ('spacer' in item ? [{item, index}] : [])); + expect(spacers.length).toBeGreaterThan(0); + for (const {item, index} of spacers) { + expect(mockEmojiListProps.renderItem({item, index, target})).toBeNull(); + } + expect(mockEmojiListProps.stickyHeaderIndices).toEqual(headers.map(({index}) => index)); + expect(onEmojiSelected).not.toHaveBeenCalled(); + }); + + it('restores recent data and exact sticky indices after real search', async () => { + // Given one persisted catalog emoji, recent history changes the renderer's section positions. + expect(smile).toBeDefined(); + if (!smile) { + throw new Error('Expected smile in the production catalog'); + } + const recent = [{code: smile.code, name: smile.name, count: 1, lastUpdatedAt: 1}]; + await act(async () => { + await Onyx.set(ONYXKEYS.FREQUENTLY_USED_EMOJIS, recent); + }); + await waitForBatchedUpdatesWithAct(); + renderEmojiMenu(Menu, jest.fn>()); + await waitForBatchedUpdatesWithAct(); + const initialData = mergeEmojisWithFrequentlyUsedEmojis(emojis, processFrequentlyUsedEmojis(recent)); + const initialIndices = getHeaderEmojis(initialData).map(({index}) => index); + expect(mockEmojiListProps.data).toEqual(initialData); + expect(getHeaderEmojis(initialData)).toHaveLength(9); + expect(screen.getByText(en.emojiPicker.headers.frequentlyUsed)).toBeOnTheScreen(); + + // When a real search is entered, neither header rows nor sticky indices belong in the results. + fireEvent.changeText(screen.getByPlaceholderText(en.common.search), 'smile'); + await act(async () => jest.advanceTimersByTime(350)); + expect(mockEmojiListProps.data?.length).toBeGreaterThan(0); + expect(mockEmojiListProps.data?.some((item) => 'name' in item && item.name === smile.name)).toBe(true); + expect(mockEmojiListProps.data?.some((item) => 'header' in item)).toBe(false); + expect(mockEmojiListProps.stickyHeaderIndices).toEqual([]); + expect(screen.queryByRole('button', {name: en.emojiPicker.headers.smileysAndEmotion})).toBeNull(); + fireEvent.changeText(screen.getByPlaceholderText(en.common.search), ''); + await act(async () => jest.advanceTimersByTime(350)); + + // Then clearing search restores the full producer output and scroll origin, including the recent section. + expect(mockEmojiListProps.data).toEqual(initialData); + expect(mockEmojiListProps.stickyHeaderIndices).toEqual(initialIndices); + expect(screen.getByText(en.emojiPicker.headers.frequentlyUsed)).toBeOnTheScreen(); + expect(mockScrollToOffset).toHaveBeenLastCalledWith({offset: 0, animated: false}); + }); + + it.each([ + {name: 'wave', preference: 1}, + {name: 'wave', preference: -1}, + {name: 'smile', preference: 1}, + ])('emits the real $name item with preference $preference', async ({name, preference}) => { + // Given catalog data and a persisted preference, the renderer owns selection of the emitted variant. + const emoji = name === 'wave' ? wave : smile; + expect(emoji).toBeDefined(); + if (!emoji) { + throw new Error('Expected the selection fixture in the catalog'); + } + mockVisibleEmojiCode = emoji.code; + await act(async () => { + await Onyx.set(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, preference); + }); + await waitForBatchedUpdatesWithAct(); + const onEmojiSelected = jest.fn>(); + renderEmojiMenu(Menu, onEmojiSelected); + await waitForBatchedUpdatesWithAct(); + const expectedCode = preference !== -1 ? (emoji.types?.at(preference) ?? emoji.code) : emoji.code; + + // When the actual menu item is pressed, the callback must receive the rendered item and selected code once. + fireEvent.press(screen.getByText(expectedCode)); + + // Then a dropped, duplicated or incorrectly indexed selection fails independently of header rendering. + expect(onEmojiSelected).toHaveBeenCalledTimes(1); + expect(onEmojiSelected).toHaveBeenCalledWith(expectedCode, emoji, preference); + }); + + it.each(['layout', 'timer'])('focuses only the remounted selected header through %s', async (path) => { + // Given an absent category and an independent mounted host, retries must wait for the chosen header. + mockHiddenHeaders.add('animalsAndNature'); + const delivery = jest.spyOn(AccessibilityInfo, 'sendAccessibilityEvent').mockImplementation(() => {}); + delivery.mockClear(); + renderEmojiMenu(Menu, jest.fn>()); + await waitForBatchedUpdatesWithAct(); + const natureIndex = mockEmojiListProps.data?.findIndex((item) => 'header' in item && item.code === 'animalsAndNature'); + expect(natureIndex).toBeGreaterThan(0); + if (natureIndex === undefined || natureIndex < 0) { + throw new Error('Expected the actual nature header index'); + } + const {host: unrelatedHost} = getEmojiHeaderHost(0, platform); + const unrelatedFocus = jest.fn, Parameters>(); + mockFocusDescriptors.push({host: unrelatedHost, descriptor: Object.getOwnPropertyDescriptor(unrelatedHost, 'focus')}); + unrelatedHost.focus = unrelatedFocus; + expect(screen.queryByTestId(`emoji-row-${natureIndex}`)).toBeNull(); + fireEvent.press(screen.getByRole('button', {name: en.emojiPicker.headers.animalsAndNature})); + if (platform === 'web') { + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION)); + } + expect(delivery).not.toHaveBeenCalled(); + expect(unrelatedFocus).not.toHaveBeenCalled(); + expect(mockScrollToOffset).toHaveBeenCalledWith({offset: Math.floor(natureIndex / CONST.EMOJI_NUM_PER_ROW) * CONST.EMOJI_PICKER_HEADER_HEIGHT, animated: true}); + + // When only the chosen delivery lane runs, removing layout or the delayed retry leaves its own oracle unsatisfied. + mockHiddenHeaders.delete('animalsAndNature'); + expect(refreshEmojiWindow).toBeDefined(); + act(() => refreshEmojiWindow?.()); + const {heading, host} = getEmojiHeaderHost(natureIndex, platform); + const focus = jest.fn, Parameters>(); + mockFocusDescriptors.push({host, descriptor: Object.getOwnPropertyDescriptor(host, 'focus')}); + host.focus = focus; + expect(focus).not.toBe(unrelatedFocus); + expect(getEmojiHeaderHost(0, platform).host).toBe(unrelatedHost); + if (path === 'layout') { + fireEvent(heading, 'layout', headerLayout); + } else { + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION)); + } + + // Then only the actual selected host receives one delivery, with its normal method receiver. + if (platform === 'native') { + expect(delivery).toHaveBeenCalledTimes(1); + expect(delivery).toHaveBeenCalledWith(host, 'focus'); + } else { + expect(focus).toHaveBeenCalledTimes(1); + expect(focus.mock.contexts.at(0)).toBe(host); + } + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION * 3)); + if (platform === 'native') { + expect(delivery).toHaveBeenCalledTimes(1); + } else { + expect(focus).toHaveBeenCalledTimes(1); + } + expect(unrelatedFocus).not.toHaveBeenCalled(); + expect(delivery).not.toHaveBeenCalledWith(unrelatedHost, 'focus'); + }); + + it('exhausts absent-header retries without later stale delivery', async () => { + // Given an absent category, another mounted header must never become its substitute. + mockHiddenHeaders.add('animalsAndNature'); + const delivery = jest.spyOn(AccessibilityInfo, 'sendAccessibilityEvent').mockImplementation(() => {}); + delivery.mockClear(); + renderEmojiMenu(Menu, jest.fn>()); + await waitForBatchedUpdatesWithAct(); + const natureIndex = mockEmojiListProps.data?.findIndex((item) => 'header' in item && item.code === 'animalsAndNature'); + expect(natureIndex).toBeGreaterThan(0); + if (natureIndex === undefined || natureIndex < 0) { + throw new Error('Expected the nature header'); + } + const {host: unrelatedHost} = getEmojiHeaderHost(0, platform); + const unrelatedFocus = jest.fn, Parameters>(); + mockFocusDescriptors.push({host: unrelatedHost, descriptor: Object.getOwnPropertyDescriptor(unrelatedHost, 'focus')}); + unrelatedHost.focus = unrelatedFocus; + const schedule = jest.spyOn(globalThis, 'setTimeout'); + + // When every bounded attempt finds no host, scheduling stops before it returns. + fireEvent.press(screen.getByRole('button', {name: en.emojiPicker.headers.animalsAndNature})); + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION * 4)); + expect(schedule.mock.calls.filter(([, delay]) => delay === CONST.ANIMATED_TRANSITION)).toHaveLength(platform === 'web' ? 3 : 2); + expect(screen.queryByTestId(`emoji-row-${natureIndex}`)).toBeNull(); + expect(delivery).not.toHaveBeenCalled(); + expect(unrelatedFocus).not.toHaveBeenCalled(); + schedule.mockClear(); + mockHiddenHeaders.delete('animalsAndNature'); + expect(refreshEmojiWindow).toBeDefined(); + act(() => refreshEmojiWindow?.()); + const {host} = getEmojiHeaderHost(natureIndex, platform); + const focus = jest.fn, Parameters>(); + mockFocusDescriptors.push({host, descriptor: Object.getOwnPropertyDescriptor(host, 'focus')}); + host.focus = focus; + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION * 4)); + + // Then a later host receives no stale delivery and the retry count remains bounded. + expect(schedule.mock.calls.filter(([, delay]) => delay === CONST.ANIMATED_TRANSITION)).toHaveLength(0); + expect(focus).not.toHaveBeenCalled(); + expect(delivery).not.toHaveBeenCalled(); + expect(unrelatedFocus).not.toHaveBeenCalled(); + }); + + it('cancels stale category delivery when another category is selected', async () => { + // Given an absent selected category, its queued retry must lose authority on a category switch. + mockHiddenHeaders.add('animalsAndNature'); + const delivery = jest.spyOn(AccessibilityInfo, 'sendAccessibilityEvent').mockImplementation(() => {}); + delivery.mockClear(); + renderEmojiMenu(Menu, jest.fn>()); + await waitForBatchedUpdatesWithAct(); + const natureIndex = mockEmojiListProps.data?.findIndex((item) => 'header' in item && item.code === 'animalsAndNature'); + const foodIndex = mockEmojiListProps.data?.findIndex((item) => 'header' in item && item.code === 'foodAndDrink'); + expect(natureIndex).toBeGreaterThan(0); + expect(foodIndex).toBeGreaterThan(0); + if (natureIndex === undefined || foodIndex === undefined || natureIndex < 0 || foodIndex < 0) { + throw new Error('Expected both real category indices'); + } + const {host: foodHost} = getEmojiHeaderHost(foodIndex, platform); + const foodFocus = jest.fn, Parameters>(); + mockFocusDescriptors.push({host: foodHost, descriptor: Object.getOwnPropertyDescriptor(foodHost, 'focus')}); + foodHost.focus = foodFocus; + fireEvent.press(screen.getByRole('button', {name: en.emojiPicker.headers.animalsAndNature})); + if (platform === 'web') { + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION)); + } + + // When the real food shortcut becomes current, remounting the stale header cannot restore its earlier request. + fireEvent.press(screen.getByRole('button', {name: en.emojiPicker.headers.foodAndDrink})); + mockHiddenHeaders.delete('animalsAndNature'); + expect(refreshEmojiWindow).toBeDefined(); + act(() => refreshEmojiWindow?.()); + const {host: staleHost} = getEmojiHeaderHost(natureIndex, platform); + const staleFocus = jest.fn, Parameters>(); + mockFocusDescriptors.push({host: staleHost, descriptor: Object.getOwnPropertyDescriptor(staleHost, 'focus')}); + staleHost.focus = staleFocus; + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION * 4)); + + // Then only the current category receives one delivery and the old ref remains unused. + expect(staleFocus).not.toHaveBeenCalled(); + if (platform === 'native') { + expect(delivery).toHaveBeenCalledTimes(1); + expect(delivery).toHaveBeenCalledWith(foodHost, 'focus'); + expect(delivery).not.toHaveBeenCalledWith(staleHost, 'focus'); + } else { + expect(foodFocus).toHaveBeenCalledTimes(1); + expect(foodFocus.mock.contexts.at(0)).toBe(foodHost); + } + }); + + if (platform === 'native') { + it('retries the current remounted category at native momentum end', async () => { + // Given a native category outside the window, momentum end must use its newly attached ref. + mockHiddenHeaders.add('animalsAndNature'); + const delivery = jest.spyOn(AccessibilityInfo, 'sendAccessibilityEvent').mockImplementation(() => {}); + delivery.mockClear(); + renderEmojiMenu(Menu, jest.fn>()); + await waitForBatchedUpdatesWithAct(); + const natureIndex = mockEmojiListProps.data?.findIndex((item) => 'header' in item && item.code === 'animalsAndNature'); + expect(natureIndex).toBeGreaterThan(0); + if (natureIndex === undefined || natureIndex < 0) { + throw new Error('Expected the native nature header'); + } + const {host: unrelatedHost} = getEmojiHeaderHost(0, platform); + fireEvent.press(screen.getByRole('button', {name: en.emojiPicker.headers.animalsAndNature})); + expect(delivery).not.toHaveBeenCalled(); + mockHiddenHeaders.delete('animalsAndNature'); + expect(refreshEmojiWindow).toBeDefined(); + act(() => refreshEmojiWindow?.()); + const {host} = getEmojiHeaderHost(natureIndex, platform); + const momentumEnd = mockEmojiListProps.onMomentumScrollEnd; + expect(momentumEnd).toBeDefined(); + if (!momentumEnd) { + throw new Error('Expected the actual native momentum handler'); + } + + // When the real momentum callback runs before its retry, the selected host receives accessibility focus. + act(() => momentumEnd(createMock[0]>({}))); + + // Then one delivery and no duplicate distinguish momentum handling from the timer lane. + expect(delivery).toHaveBeenCalledTimes(1); + expect(delivery).toHaveBeenCalledWith(host, 'focus'); + expect(delivery).not.toHaveBeenCalledWith(unrelatedHost, 'focus'); + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION * 3)); + expect(delivery).toHaveBeenCalledTimes(1); + }); + } +}); + +describe('actual web keyboard and optional host focus', () => { + beforeEach(async () => { + jest.useFakeTimers(); + await act(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + await Onyx.set(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, -1); + await Onyx.set(ONYXKEYS.FREQUENTLY_USED_EMOJIS, []); + }); + await waitForBatchedUpdatesWithAct(); + mockHiddenHeaders = new Set(); + mockRenderTarget = 'Cell'; + mockVisibleEmojiCode = '👋'; + mockScrollToOffset.mockClear(); + mockSelectedHeaderGeneration = 0; + refreshEmojiWindow = undefined; + }); + afterEach(() => { + cleanup(); + for (const {host, descriptor} of mockFocusDescriptors) { + if (descriptor) { + Object.defineProperty(host, 'focus', descriptor); + } else { + expect(Reflect.deleteProperty(host, 'focus')).toBe(true); + } + expect(Object.getOwnPropertyDescriptor(host, 'focus')).toEqual(descriptor); + } + mockFocusDescriptors.length = 0; + expect(Object.getOwnPropertyDescriptor(View.prototype, 'focus')).toEqual(originalFocusDescriptor); + refreshEmojiWindow = undefined; + jest.clearAllTimers(); + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it('selects the first real search result while suppressing the Enter event', async () => { + // Given real nonempty search output, keyboard selection must use that output instead of catalog headers. + const onEmojiSelected = jest.fn>(); + renderEmojiMenu(WebEmojiPickerMenu, onEmojiSelected); + await waitForBatchedUpdatesWithAct(); + fireEvent.changeText(screen.getByPlaceholderText(en.common.search), 'smile'); + act(() => jest.advanceTimersByTime(200)); + const first = mockEmojiListProps.data?.[0]; + expect(first).toBeDefined(); + if (!first || !('name' in first)) { + throw new Error('Expected a real search result'); + } + const enter = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}); + const preventDefault = jest.spyOn(enter, 'preventDefault'); + const stopPropagation = jest.spyOn(enter, 'stopPropagation'); + const registration = mockKeyRegistrations.find(({key}) => key.input === CONST.KEYBOARD_SHORTCUTS.ENTER.trigger.DEFAULT.input); + expect(registration).toBeDefined(); + if (!registration) { + throw new Error('Expected the real Enter registration'); + } + + // When search highlights the first result, Enter cannot leak to the composer or child pressable. + act(() => { + document.dispatchEvent(enter); + registration.callback(registration.key, enter); + }); + + // Then the actual selected result and both event barriers are observed. + expect(onEmojiSelected).toHaveBeenCalledTimes(1); + expect(onEmojiSelected).toHaveBeenCalledWith(first.code, first, -1); + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + }); + + it('moves through two separately committed arrows to the distinct second search result', async () => { + // Given multiple real search results, first-result highlighting cannot prove arrow movement. + const onEmojiSelected = jest.fn>(); + renderEmojiMenu(WebEmojiPickerMenu, onEmojiSelected); + await waitForBatchedUpdatesWithAct(); + fireEvent.changeText(screen.getByPlaceholderText(en.common.search), 'smile'); + await act(async () => jest.advanceTimersByTime(200)); + const data = mockEmojiListProps.data; + expect(data?.length).toBeGreaterThanOrEqual(2); + expect(data?.some((item) => 'header' in item || 'spacer' in item)).toBe(false); + expect(mockEmojiListProps.stickyHeaderIndices).toEqual([]); + const first = data?.[0]; + const second = data?.[1]; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + if (!first || !second || !('name' in first) || !('name' in second)) { + throw new Error('Expected two actual search emojis'); + } + expect(second).not.toBe(first); + expect(second.code).not.toBe(first.code); + mockVisibleEmojiCode = second.code; + expect(refreshEmojiWindow).toBeDefined(); + act(() => refreshEmojiWindow?.()); + fireEvent(screen.getByPlaceholderText(en.common.search), 'blur'); + await act(async () => jest.advanceTimersByTime(0)); + const arrowRegistration = mockKeyRegistrations.find(({key}) => key.input === CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT.trigger.DEFAULT.input); + const enterRegistration = mockKeyRegistrations.find(({key}) => key.input === CONST.KEYBOARD_SHORTCUTS.ENTER.trigger.DEFAULT.input); + expect(arrowRegistration).toBeDefined(); + expect(enterRegistration).toBeDefined(); + if (!arrowRegistration || !enterRegistration) { + throw new Error('Expected real arrow and Enter registrations'); + } + + // When each arrow commits independently, the real hook advances from -1 through 0 to 1 before Enter. + const firstArrow = new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true, cancelable: true}); + await act(async () => { + document.dispatchEvent(firstArrow); + arrowRegistration.callback(arrowRegistration.key, firstArrow); + }); + const secondArrow = new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true, cancelable: true}); + await act(async () => { + document.dispatchEvent(secondArrow); + arrowRegistration.callback(arrowRegistration.key, secondArrow); + }); + const enter = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}); + const preventDefault = jest.spyOn(enter, 'preventDefault'); + const stopPropagation = jest.spyOn(enter, 'stopPropagation'); + await act(async () => { + document.dispatchEvent(enter); + enterRegistration.callback(enterRegistration.key, enter); + }); + + // Then missing either movement selects the wrong item and fails this independent second-item oracle. + expect(mockEmojiListProps.extraData).toEqual([1, -1]); + expect(onEmojiSelected).toHaveBeenCalledTimes(1); + expect(onEmojiSelected).toHaveBeenCalledWith(second.code, second, -1); + expect(onEmojiSelected).not.toHaveBeenCalledWith(first.code, first, -1); + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + }); + + it('skips the real first header and all seven spacers before selecting grinning', async () => { + // Given an unfiltered real catalog, both header and spacer barriers stand between -1 and the first emoji. + const onEmojiSelected = jest.fn>(); + renderEmojiMenu(WebEmojiPickerMenu, onEmojiSelected); + await waitForBatchedUpdatesWithAct(); + const data = mockEmojiListProps.data; + expect(data?.[0]).toEqual(expect.objectContaining({header: true, code: 'smileysAndEmotion'})); + expect(data?.slice(1, 8)).toHaveLength(7); + expect(data?.slice(1, 8).every((item) => 'spacer' in item)).toBe(true); + const grinning = data?.[8]; + expect(grinning).toBeDefined(); + if (!grinning || !('name' in grinning)) { + throw new Error('Expected the real grinning item after the padded header'); + } + expect(grinning.name).toBe('grinning'); + expect(grinning.code).toBe('😀'); + expect(mockEmojiListProps.stickyHeaderIndices).toContain(0); + expect(screen.getByPlaceholderText(en.common.search).props.value).toBe(''); + mockVisibleEmojiCode = grinning.code; + expect(refreshEmojiWindow).toBeDefined(); + act(() => refreshEmojiWindow?.()); + fireEvent(screen.getByPlaceholderText(en.common.search), 'blur'); + await act(async () => jest.advanceTimersByTime(0)); + const arrowRegistration = mockKeyRegistrations.find(({key}) => key.input === CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT.trigger.DEFAULT.input); + const enterRegistration = mockKeyRegistrations.find(({key}) => key.input === CONST.KEYBOARD_SHORTCUTS.ENTER.trigger.DEFAULT.input); + expect(arrowRegistration).toBeDefined(); + expect(enterRegistration).toBeDefined(); + if (!arrowRegistration || !enterRegistration) { + throw new Error('Expected real arrow and Enter registrations'); + } + + // When one registered arrow commits, traversal crosses index 0 and spacers 1 through 7 to index 8. + const arrow = new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true, cancelable: true}); + await act(async () => { + document.dispatchEvent(arrow); + arrowRegistration.callback(arrowRegistration.key, arrow); + }); + const enter = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}); + const preventDefault = jest.spyOn(enter, 'preventDefault'); + const stopPropagation = jest.spyOn(enter, 'stopPropagation'); + await act(async () => { + document.dispatchEvent(enter); + enterRegistration.callback(enterRegistration.key, enter); + }); + + // Then a disabled-index or traversal regression yields a wrong item or no selection, with both Enter barriers retained. + expect(onEmojiSelected).toHaveBeenCalledTimes(1); + expect(onEmojiSelected).toHaveBeenCalledWith(grinning.code, grinning, -1); + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + }); + + it('clears a queued retry when layout succeeds on an actual host without focus', async () => { + // Given an absent selected header, the first failed attempt queues the known delay-T retry. + mockHiddenHeaders.add('animalsAndNature'); + renderEmojiMenu(WebEmojiPickerMenu, jest.fn>()); + await waitForBatchedUpdatesWithAct(); + const natureIndex = mockEmojiListProps.data?.findIndex((item) => 'header' in item && item.code === 'animalsAndNature'); + expect(natureIndex).toBeGreaterThan(0); + if (natureIndex === undefined || natureIndex < 0) { + throw new Error('Expected the actual nature header index'); + } + const {host: unrelatedHost} = getEmojiHeaderHost(0, 'web'); + const unrelatedFocus = jest.fn, Parameters>(); + mockFocusDescriptors.push({host: unrelatedHost, descriptor: Object.getOwnPropertyDescriptor(unrelatedHost, 'focus')}); + unrelatedHost.focus = unrelatedFocus; + expect(screen.queryByTestId(`emoji-row-${natureIndex}`)).toBeNull(); + fireEvent.press(screen.getByRole('button', {name: en.emojiPicker.headers.animalsAndNature})); + expect(mockScrollToOffset).toHaveBeenCalledWith({offset: Math.floor(natureIndex / CONST.EMOJI_NUM_PER_ROW) * CONST.EMOJI_PICKER_HEADER_HEIGHT, animated: true}); + const schedule = jest.spyOn(globalThis, 'setTimeout'); + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION)); + expect(schedule.mock.calls.some(([, delay]) => delay === CONST.ANIMATED_TRANSITION)).toBe(true); + expect(screen.queryByTestId(`emoji-row-${natureIndex}`)).toBeNull(); + expect(unrelatedFocus).not.toHaveBeenCalled(); + + // When the actual selected host mounts at T, layout succeeds even for the exact external missing-focus compatibility case. + mockHiddenHeaders.delete('animalsAndNature'); + expect(refreshEmojiWindow).toBeDefined(); + act(() => refreshEmojiWindow?.()); + const {heading, host} = getEmojiHeaderHost(natureIndex, 'web'); + mockFocusDescriptors.push({host, descriptor: Object.getOwnPropertyDescriptor(host, 'focus')}); + // @ts-expect-error The external web host can lack focus. All remaining native host fields and ref identity stay real. + host.focus = undefined; + const selectedRef: unknown = heading.props.ref; + expect(isRecord(selectedRef)).toBe(true); + if (!isRecord(selectedRef)) { + throw new Error('Expected the actual source-produced object ref'); + } + expect(selectedRef.current).toBe(host); + expect(Object.getOwnPropertyDescriptor(host, 'focus')?.value).toBeUndefined(); + expect(() => fireEvent(heading, 'layout', headerLayout)).not.toThrow(); + mockSelectedHeaderGeneration += 1; + act(() => refreshEmojiWindow?.()); + const {heading: replacementHeading, host: replacementHost} = getEmojiHeaderHost(natureIndex, 'web'); + expect(replacementHost).not.toBe(host); + expect(replacementHeading.props.ref).toBe(selectedRef); + expect(selectedRef.current).toBe(replacementHost); + expect(getEmojiHeaderHost(0, 'web').host).toBe(unrelatedHost); + const focus = jest.fn, Parameters>(); + const focusAccess = jest.fn(() => focus); + mockFocusDescriptors.push({host: replacementHost, descriptor: Object.getOwnPropertyDescriptor(replacementHost, 'focus')}); + Object.defineProperty(replacementHost, 'focus', {configurable: true, get: focusAccess}); + expect(focusAccess).not.toHaveBeenCalled(); + expect(focus).not.toHaveBeenCalled(); + + // Then the known queued attempt at 2T sees cleared pending and never accesses the distinct replacement's focus. + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION)); + expect(focusAccess).not.toHaveBeenCalled(); + expect(focus).not.toHaveBeenCalled(); + expect(unrelatedFocus).not.toHaveBeenCalled(); + act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION * 3)); + expect(focusAccess).not.toHaveBeenCalled(); + expect(focus).not.toHaveBeenCalled(); + expect(unrelatedFocus).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/WorkspaceNewRoomPageTest.tsx b/tests/ui/WorkspaceNewRoomPageTest.tsx new file mode 100644 index 000000000000..28f80caa9515 --- /dev/null +++ b/tests/ui/WorkspaceNewRoomPageTest.tsx @@ -0,0 +1,287 @@ +import {act, cleanup, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import type * as FormProviderModule from '@components/Form/FormProvider'; +import HTMLEngineProvider from '@components/HTMLEngineProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import type * as MenuItemModule from '@components/MenuItemWithTopDescription'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type {SelectionListProps} from '@components/SelectionList/types'; +import type * as ValuePickerModule from '@components/ValuePicker'; +import ValuePicker from '@components/ValuePicker'; +import type {ValuePickerProps, ValueSelectorModalProps} from '@components/ValuePicker/types'; +import type * as ValueSelectionListModule from '@components/ValuePicker/ValueSelectionList'; + +import type * as ReportActionsModule from '@libs/actions/Report'; +import {addPolicyReport} from '@libs/actions/Report'; + +import WorkspaceNewRoomPage from '@pages/workspace/WorkspaceNewRoomPage'; + +import CONST from '@src/CONST'; +import type * as FormKeysModule from '@src/ONYXKEYS'; +import type Policy from '@src/types/onyx/Policy'; + +import type * as NavigationCore from '@react-navigation/core'; +import type * as NativeNavigation from '@react-navigation/native'; +import type ReactNative from 'react-native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createMock from '../utils/createMock'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const ONYXKEYS = jest.requireActual('@src/ONYXKEYS').default; + +jest.mock('@react-navigation/core', () => ({ + ...jest.requireActual('@react-navigation/core'), + useIsFocused: () => true, +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: () => true, + useRoute: () => ({key: 'new-room', name: 'NewRoom'}), + usePreventRemove: () => {}, +})); +jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({__esModule: true, default: () => ({didScreenTransitionEnd: true})})); +jest.mock('@src/hooks/useResponsiveLayout'); +jest.mock('@libs/actions/Report', () => ({ + ...jest.requireActual('@libs/actions/Report'), + addPolicyReport: jest.fn, Parameters>(), +})); + +let mockRoomForm: FormProviderModule.FormProviderProps | undefined; +const mockRoomPickers = new Map(); + +jest.mock('@components/Form/FormProvider', () => { + const formKeys = jest.requireActual('@src/ONYXKEYS').default; + const ActualForm = jest.requireActual('@components/Form/FormProvider').default; + function isRoomForm( + props: FormProviderModule.FormProviderProps, + ): props is FormProviderModule.FormProviderProps & FormProviderModule.FormProviderProps { + return props.formID === formKeys.FORMS.NEW_ROOM_FORM; + } + function ObservedForm(props: FormProviderModule.FormProviderProps) { + if (isRoomForm(props)) { + mockRoomForm = props; + } + return ; + } + return {__esModule: true, default: ObservedForm}; +}); + +jest.mock('@components/ValuePicker', () => { + const ActualPicker = jest.requireActual('@components/ValuePicker').default; + function ObservedPicker(props: ValuePickerProps) { + mockRoomPickers.set(props.label ?? '', props); + return ; + } + return {__esModule: true, default: ObservedPicker}; +}); + +// Modal presentation stays below the real picker. The real list owns mapping and selection order. +jest.mock('@components/ValuePicker/ValueSelectorModal', () => { + const RN = jest.requireActual('react-native'); + const ValueSelectionList = jest.requireActual('@components/ValuePicker/ValueSelectionList').default; + return ({label, ...props}: ValueSelectorModalProps) => + props.isVisible ? ( + + + + ) : null; +}); + +// The presentation boundary receives complete items mapped by ValueSelectionList, including keyForList. +jest.mock('@components/SelectionList', () => { + const RN = jest.requireActual('react-native'); + return ({data, onSelectRow}: SelectionListProps>[0]>) => ( + + {data.map((item) => ( + onSelectRow(item)} + > + {item.text} + + ))} + + ); +}); + +const adminPolicy = createMock({id: 'admin-workspace', name: 'Admin workspace', type: CONST.POLICY.TYPE.TEAM, role: CONST.POLICY.ROLE.ADMIN}); +const memberPolicy = createMock({id: 'member-workspace', name: 'Member workspace', type: CONST.POLICY.TYPE.TEAM, role: CONST.POLICY.ROLE.USER}); + +function renderNewRoomPage(policyID?: string) { + return render( + + + , + ); +} + +function selectRoomOption(page: ReturnType, label: string, value: string) { + const picker = page.UNSAFE_getAllByType(ValuePicker).find((node) => node.props.label === label); + expect(picker).toBeDefined(); + if (!picker) { + throw new Error('Expected the room picker'); + } + const props = mockRoomPickers.get(label); + expect(props).toBeDefined(); + if (!props) { + throw new Error('Expected the observed room picker props'); + } + expect(props.items?.some((item) => item.value === value)).toBe(true); + // The actual ValuePicker owns its modal visibility through the menu item's press event. + const MenuItem = jest.requireActual('@components/MenuItemWithTopDescription').default; + fireEvent.press(picker.find((node) => node.type === MenuItem)); + fireEvent.press(screen.getByTestId(`room-option-${value}`)); +} + +describe('WorkspaceNewRoomPage option state and submit', () => { + beforeEach(async () => { + mockRoomForm = undefined; + mockRoomPickers.clear(); + Onyx.init({keys: ONYXKEYS}); + jest.mocked(addPolicyReport).mockClear(); + await act(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + await Onyx.set(ONYXKEYS.SESSION, {email: 'member@example.com', accountID: 123}); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${adminPolicy.id}`, adminPolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${memberPolicy.id}`, memberPolicy); + await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, adminPolicy.id); + }); + await waitForBatchedUpdatesWithAct(); + }); + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); + + it.each(Object.values(CONST.REPORT.WRITE_CAPABILITIES))('submits selected capability %s and each offered visibility', async (capability) => { + // Given an admin workspace, every current option should retain its exact production value. + const page = renderNewRoomPage(); + await waitForBatchedUpdatesWithAct(); + const capabilities = page.UNSAFE_getAllByType(ValuePicker).find((node) => node.props.label === 'Who can post'); + expect(capabilities).toBeDefined(); + selectRoomOption(page, 'Who can post', capability); + const visibilityPicker = page.UNSAFE_getAllByType(ValuePicker).find((node) => node.props.label === 'Visibility'); + expect(visibilityPicker).toBeDefined(); + const visibilityProps = mockRoomPickers.get('Visibility'); + expect(visibilityProps).toBeDefined(); + if (!visibilityProps) { + throw new Error('Expected the visibility picker'); + } + expect(visibilityProps.items?.map((item) => item.value)).toEqual(Object.values(CONST.REPORT.VISIBILITY).filter((value) => value !== CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE)); + + // When each real picker option reaches the mounted form's submit callback, state and submitted policy must agree. + for (const visibility of Object.values(CONST.REPORT.VISIBILITY).filter((value) => value !== CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE)) { + selectRoomOption(page, 'Visibility', visibility); + const form = mockRoomForm; + expect(form).toBeDefined(); + if (!form) { + throw new Error('Expected the actual room form'); + } + act(() => form.onSubmit({roomName: '#test-room', reportDescription: '', policyID: adminPolicy.id, writeCapability: capability, visibility})); + + // Then the real optimistic report builder exposes any option-to-state or policy mapping mistake. + expect(addPolicyReport).toHaveBeenCalled(); + const submitted = jest.mocked(addPolicyReport).mock.calls.at(-1); + expect(submitted).toBeDefined(); + expect(submitted?.[0]).toEqual(expect.objectContaining({policyID: adminPolicy.id, writeCapability: capability, visibility})); + } + }); + + it('switches workspace and resets admin-only capability before submit', async () => { + // Given an admin-only posting choice, switching to a member workspace must remove that capability. + const page = renderNewRoomPage(); + await waitForBatchedUpdatesWithAct(); + selectRoomOption(page, 'Who can post', CONST.REPORT.WRITE_CAPABILITIES.ADMINS); + + // When a real workspace option is selected, the new workspace owns the report and resets restricted posting. + selectRoomOption(page, 'Workspace', memberPolicy.id); + await waitForBatchedUpdatesWithAct(); + const pickers = page.UNSAFE_getAllByType(ValuePicker); + expect(pickers.some((node) => node.props.label === 'Who can post')).toBe(false); + const form = mockRoomForm; + expect(form).toBeDefined(); + if (!form) { + throw new Error('Expected the actual room form'); + } + act(() => + form.onSubmit({ + roomName: '#member-room', + reportDescription: '', + policyID: memberPolicy.id, + visibility: CONST.REPORT.VISIBILITY.RESTRICTED, + writeCapability: CONST.REPORT.WRITE_CAPABILITIES.ALL, + }), + ); + + // Then neither the old policy ID nor its admin capability leaks into the actual report. + expect(addPolicyReport).toHaveBeenCalledTimes(1); + expect(jest.mocked(addPolicyReport).mock.calls.at(0)?.[0]).toEqual(expect.objectContaining({policyID: memberPolicy.id, writeCapability: CONST.REPORT.WRITE_CAPABILITIES.ALL})); + }); + + it('clears a removed workspace and adopts a valid active workspace', async () => { + // Given a selected workspace that becomes unavailable, retaining it would submit stale policy state. + renderNewRoomPage(); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText(adminPolicy.name)).toBeOnTheScreen(); + + // When that policy disappears and the active policy changes, the real Onyx subscription must rebuild options. + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${adminPolicy.id}`, null); + await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, memberPolicy.id); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the picker and submitted report both use the surviving workspace. + expect(screen.getByText(memberPolicy.name)).toBeOnTheScreen(); + const form = mockRoomForm; + expect(form).toBeDefined(); + if (!form) { + throw new Error('Expected the actual room form'); + } + act(() => + form.onSubmit({ + roomName: '#surviving-room', + reportDescription: '', + policyID: memberPolicy.id, + visibility: CONST.REPORT.VISIBILITY.RESTRICTED, + writeCapability: CONST.REPORT.WRITE_CAPABILITIES.ALL, + }), + ); + expect(addPolicyReport).toHaveBeenCalledTimes(1); + expect(jest.mocked(addPolicyReport).mock.calls.at(0)?.[0].policyID).toBe(memberPolicy.id); + }); + + it('keeps the locked workspace as the submitted policy', async () => { + // Given a locked workspace different from the active policy, selection must stay bound to the page's policy. + const page = renderNewRoomPage(memberPolicy.id); + await waitForBatchedUpdatesWithAct(); + + // When the mounted form submits, no workspace picker can override the locked ID. + expect(page.UNSAFE_getAllByType(ValuePicker).some((node) => node.props.label === 'Workspace')).toBe(false); + const form = mockRoomForm; + expect(form).toBeDefined(); + if (!form) { + throw new Error('Expected the actual room form'); + } + act(() => + form.onSubmit({ + roomName: '#locked-room', + reportDescription: '', + policyID: adminPolicy.id, + visibility: CONST.REPORT.VISIBILITY.RESTRICTED, + writeCapability: CONST.REPORT.WRITE_CAPABILITIES.ALL, + }), + ); + + // Then the real report builder receives the locked policy and default posting capability. + expect(addPolicyReport).toHaveBeenCalledTimes(1); + expect(jest.mocked(addPolicyReport).mock.calls.at(0)?.[0]).toEqual(expect.objectContaining({policyID: memberPolicy.id, writeCapability: CONST.REPORT.WRITE_CAPABILITIES.ALL})); + }); +}); From 73eb4115113c7b49eabdc1c6d28eafc324a0bece Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:52:39 +0000 Subject: [PATCH 2/4] Make room test observations run after commit Seatbelt-Operation: 77793e37-f1a1-48d2-89b3-bae480b8b95a --- tests/ui/WorkspaceNewRoomPageTest.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/ui/WorkspaceNewRoomPageTest.tsx b/tests/ui/WorkspaceNewRoomPageTest.tsx index 28f80caa9515..c6b760b5027f 100644 --- a/tests/ui/WorkspaceNewRoomPageTest.tsx +++ b/tests/ui/WorkspaceNewRoomPageTest.tsx @@ -55,6 +55,7 @@ let mockRoomForm: FormProviderModule.FormProviderProps(); jest.mock('@components/Form/FormProvider', () => { + const ActualReact = jest.requireActual('react'); const formKeys = jest.requireActual('@src/ONYXKEYS').default; const ActualForm = jest.requireActual('@components/Form/FormProvider').default; function isRoomForm( @@ -63,18 +64,24 @@ jest.mock('@components/Form/FormProvider', () => { return props.formID === formKeys.FORMS.NEW_ROOM_FORM; } function ObservedForm(props: FormProviderModule.FormProviderProps) { - if (isRoomForm(props)) { + ActualReact.useEffect(() => { + if (!isRoomForm(props)) { + return; + } mockRoomForm = props; - } + }, [props]); return ; } return {__esModule: true, default: ObservedForm}; }); jest.mock('@components/ValuePicker', () => { + const ActualReact = jest.requireActual('react'); const ActualPicker = jest.requireActual('@components/ValuePicker').default; function ObservedPicker(props: ValuePickerProps) { - mockRoomPickers.set(props.label ?? '', props); + ActualReact.useEffect(() => { + mockRoomPickers.set(props.label ?? '', props); + }, [props]); return ; } return {__esModule: true, default: ObservedPicker}; From 2f216b8799b633a512a1a634c9b844bf614c08a9 Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:47:29 +0000 Subject: [PATCH 3/4] Fix reviewer findings for PR #103920 Seatbelt-Operation: reviewer-fix-103920-148a7b7c-46f3-4e1d-92e3-d55572edf948 --- .../EmojiPicker/EmojiPickerMenu/index.tsx | 2 +- src/components/Icon/BankIconsUtils.ts | 3 +- src/components/SettlementAccountSelector.tsx | 3 +- src/pages/workspace/WorkspaceNewRoomPage.tsx | 10 ++- .../DynamicWorkspaceSettlementAccountPage.tsx | 1 + ...amicWorkspaceSettlementAccountPageTest.tsx | 8 +-- tests/ui/EmojiPickerMenuComponentTest.tsx | 67 +------------------ tests/ui/WorkspaceNewRoomPageTest.tsx | 41 ++++++++++++ 8 files changed, 56 insertions(+), 79 deletions(-) diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx index 7910299fd78b..aaf716e5d6df 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx @@ -83,7 +83,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro } pendingHeaderFocusIndexRef.current = null; - headerRef.current.focus?.(); + headerRef.current.focus(); return true; }, []); diff --git a/src/components/Icon/BankIconsUtils.ts b/src/components/Icon/BankIconsUtils.ts index e8d160b7747f..2259d72484e3 100644 --- a/src/components/Icon/BankIconsUtils.ts +++ b/src/components/Icon/BankIconsUtils.ts @@ -1,13 +1,12 @@ import type {ThemeStyles} from '@styles/index'; import CONST from '@src/CONST'; -import type AccountData from '@src/types/onyx/AccountData'; import type {BankName, BankNameKey} from '@src/types/onyx/Bank'; import type IconAsset from '@src/types/utils/IconAsset'; type BankIconParams = { styles: ThemeStyles; - bankName?: AccountData['addressName']; + bankName?: string; isCard?: boolean; maxIconSize?: number; }; diff --git a/src/components/SettlementAccountSelector.tsx b/src/components/SettlementAccountSelector.tsx index 589cf4c6d300..6d902553026b 100644 --- a/src/components/SettlementAccountSelector.tsx +++ b/src/components/SettlementAccountSelector.tsx @@ -5,7 +5,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import React from 'react'; import {View} from 'react-native'; -import type {BankIconParams} from './Icon/BankIconsUtils'; import type {ListItem} from './SelectionList/types'; import Icon from './Icon'; @@ -31,7 +30,7 @@ type SettlementAccountSelectorProps = { }; type BankAccountListItemLeftElementProps = { - bankName: NonNullable; + bankName: string; }; function BankAccountListItemLeftElement({bankName}: BankAccountListItemLeftElementProps) { diff --git a/src/pages/workspace/WorkspaceNewRoomPage.tsx b/src/pages/workspace/WorkspaceNewRoomPage.tsx index 4a1c668560ff..7db4b2c06deb 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.tsx +++ b/src/pages/workspace/WorkspaceNewRoomPage.tsx @@ -2,7 +2,7 @@ import BlockingView from '@components/BlockingViews/BlockingView'; import Button from '@components/Button'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; -import type {FormOnyxValues} from '@components/Form/types'; +import type {FormOnyxValues, FormValue} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; @@ -44,6 +44,10 @@ import {useIsFocused} from '@react-navigation/core'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; +function findRoomOption(options: Array<{value: TValue}>, value: FormValue | undefined) { + return options.find((option) => option.value === value); +} + function EmptyWorkspaceView() { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -360,7 +364,7 @@ function WorkspaceNewRoomPage({ref, policyID: lockedPolicyID}: WorkspaceNewRoomP items={writeCapabilityOptions} value={writeCapability} onValueChange={(value) => { - const selectedOption = writeCapabilityOptions.find((option) => option.value === value); + const selectedOption = findRoomOption(writeCapabilityOptions, value); if (selectedOption) { setWriteCapability(selectedOption.value); } @@ -375,7 +379,7 @@ function WorkspaceNewRoomPage({ref, policyID: lockedPolicyID}: WorkspaceNewRoomP label={translate('newRoomPage.visibility')} items={visibilityOptions} onValueChange={(value) => { - const selectedOption = visibilityOptions.find((option) => option.value === value); + const selectedOption = findRoomOption(visibilityOptions, value); if (selectedOption) { setVisibility(selectedOption.value); } diff --git a/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx b/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx index 52868fac6d25..f79ffc565ed3 100644 --- a/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx @@ -189,3 +189,4 @@ function DynamicWorkspaceSettlementAccountPage({route}: WorkspaceSettlementAccou } export default DynamicWorkspaceSettlementAccountPage; +export type {WorkspaceSettlementAccountPageProps}; diff --git a/tests/ui/DynamicWorkspaceSettlementAccountPageTest.tsx b/tests/ui/DynamicWorkspaceSettlementAccountPageTest.tsx index 8cd6377def4e..67a4473b099f 100644 --- a/tests/ui/DynamicWorkspaceSettlementAccountPageTest.tsx +++ b/tests/ui/DynamicWorkspaceSettlementAccountPageTest.tsx @@ -19,12 +19,11 @@ import type {BankAccountListItem} from '@components/SettlementAccountSelector'; import {getRouteParamForConnection} from '@libs/AccountingUtils'; import type * as PolicyConnectionsModule from '@libs/actions/PolicyConnections'; import {openPolicyAccountingPage} from '@libs/actions/PolicyConnections'; -import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import Navigation from '@navigation/Navigation'; -import type {SettingsNavigatorParamList} from '@navigation/types'; import DynamicWorkspaceSettlementAccountPage from '@pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage'; +import type {WorkspaceSettlementAccountPageProps} from '@pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage'; import type {ThemeStyles} from '@styles/index'; import variables from '@styles/variables'; @@ -129,15 +128,14 @@ const ineligible = createMock({ bankCountry: CONST.COUNTRY.US, accountData: {type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, allowDebit: true, state: CONST.BANK_ACCOUNT.STATE.SETUP, addressName: 'Chase Bank'}, }); -type SettlementPageProps = PlatformStackScreenProps; -const route: SettlementPageProps['route'] = {key: 'settlement', name: SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_SETTINGS_ACCOUNT, params: {policyID}}; +const route: WorkspaceSettlementAccountPageProps['route'] = {key: 'settlement', name: SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_SETTINGS_ACCOUNT, params: {policyID}}; function renderSettlementPage() { return render( ({})} + navigation={createMock({})} /> , ); diff --git a/tests/ui/EmojiPickerMenuComponentTest.tsx b/tests/ui/EmojiPickerMenuComponentTest.tsx index 9b958470646e..c0ab15a16a04 100644 --- a/tests/ui/EmojiPickerMenuComponentTest.tsx +++ b/tests/ui/EmojiPickerMenuComponentTest.tsx @@ -13,7 +13,6 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {buildEmojisTrie} from '@libs/EmojiTrie'; import {getHeaderEmojis, mergeEmojisWithFrequentlyUsedEmojis, processFrequentlyUsedEmojis} from '@libs/EmojiUtils'; import type {EmojiPickerListItem} from '@libs/EmojiUtils'; -import {isRecord} from '@libs/ObjectUtils'; import CONST from '@src/CONST'; import en from '@src/languages/en'; @@ -486,7 +485,7 @@ describe.each(platformCases)('actual $platform EmojiPickerMenu', ({platform, Men } }); -describe('actual web keyboard and optional host focus', () => { +describe('actual web keyboard and header focus', () => { beforeEach(async () => { jest.useFakeTimers(); await act(async () => { @@ -667,68 +666,4 @@ describe('actual web keyboard and optional host focus', () => { expect(preventDefault).toHaveBeenCalled(); expect(stopPropagation).toHaveBeenCalled(); }); - - it('clears a queued retry when layout succeeds on an actual host without focus', async () => { - // Given an absent selected header, the first failed attempt queues the known delay-T retry. - mockHiddenHeaders.add('animalsAndNature'); - renderEmojiMenu(WebEmojiPickerMenu, jest.fn>()); - await waitForBatchedUpdatesWithAct(); - const natureIndex = mockEmojiListProps.data?.findIndex((item) => 'header' in item && item.code === 'animalsAndNature'); - expect(natureIndex).toBeGreaterThan(0); - if (natureIndex === undefined || natureIndex < 0) { - throw new Error('Expected the actual nature header index'); - } - const {host: unrelatedHost} = getEmojiHeaderHost(0, 'web'); - const unrelatedFocus = jest.fn, Parameters>(); - mockFocusDescriptors.push({host: unrelatedHost, descriptor: Object.getOwnPropertyDescriptor(unrelatedHost, 'focus')}); - unrelatedHost.focus = unrelatedFocus; - expect(screen.queryByTestId(`emoji-row-${natureIndex}`)).toBeNull(); - fireEvent.press(screen.getByRole('button', {name: en.emojiPicker.headers.animalsAndNature})); - expect(mockScrollToOffset).toHaveBeenCalledWith({offset: Math.floor(natureIndex / CONST.EMOJI_NUM_PER_ROW) * CONST.EMOJI_PICKER_HEADER_HEIGHT, animated: true}); - const schedule = jest.spyOn(globalThis, 'setTimeout'); - act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION)); - expect(schedule.mock.calls.some(([, delay]) => delay === CONST.ANIMATED_TRANSITION)).toBe(true); - expect(screen.queryByTestId(`emoji-row-${natureIndex}`)).toBeNull(); - expect(unrelatedFocus).not.toHaveBeenCalled(); - - // When the actual selected host mounts at T, layout succeeds even for the exact external missing-focus compatibility case. - mockHiddenHeaders.delete('animalsAndNature'); - expect(refreshEmojiWindow).toBeDefined(); - act(() => refreshEmojiWindow?.()); - const {heading, host} = getEmojiHeaderHost(natureIndex, 'web'); - mockFocusDescriptors.push({host, descriptor: Object.getOwnPropertyDescriptor(host, 'focus')}); - // @ts-expect-error The external web host can lack focus. All remaining native host fields and ref identity stay real. - host.focus = undefined; - const selectedRef: unknown = heading.props.ref; - expect(isRecord(selectedRef)).toBe(true); - if (!isRecord(selectedRef)) { - throw new Error('Expected the actual source-produced object ref'); - } - expect(selectedRef.current).toBe(host); - expect(Object.getOwnPropertyDescriptor(host, 'focus')?.value).toBeUndefined(); - expect(() => fireEvent(heading, 'layout', headerLayout)).not.toThrow(); - mockSelectedHeaderGeneration += 1; - act(() => refreshEmojiWindow?.()); - const {heading: replacementHeading, host: replacementHost} = getEmojiHeaderHost(natureIndex, 'web'); - expect(replacementHost).not.toBe(host); - expect(replacementHeading.props.ref).toBe(selectedRef); - expect(selectedRef.current).toBe(replacementHost); - expect(getEmojiHeaderHost(0, 'web').host).toBe(unrelatedHost); - const focus = jest.fn, Parameters>(); - const focusAccess = jest.fn(() => focus); - mockFocusDescriptors.push({host: replacementHost, descriptor: Object.getOwnPropertyDescriptor(replacementHost, 'focus')}); - Object.defineProperty(replacementHost, 'focus', {configurable: true, get: focusAccess}); - expect(focusAccess).not.toHaveBeenCalled(); - expect(focus).not.toHaveBeenCalled(); - - // Then the known queued attempt at 2T sees cleared pending and never accesses the distinct replacement's focus. - act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION)); - expect(focusAccess).not.toHaveBeenCalled(); - expect(focus).not.toHaveBeenCalled(); - expect(unrelatedFocus).not.toHaveBeenCalled(); - act(() => jest.advanceTimersByTime(CONST.ANIMATED_TRANSITION * 3)); - expect(focusAccess).not.toHaveBeenCalled(); - expect(focus).not.toHaveBeenCalled(); - expect(unrelatedFocus).not.toHaveBeenCalled(); - }); }); diff --git a/tests/ui/WorkspaceNewRoomPageTest.tsx b/tests/ui/WorkspaceNewRoomPageTest.tsx index c6b760b5027f..0ae45c8b2634 100644 --- a/tests/ui/WorkspaceNewRoomPageTest.tsx +++ b/tests/ui/WorkspaceNewRoomPageTest.tsx @@ -201,6 +201,47 @@ describe('WorkspaceNewRoomPage option state and submit', () => { } }); + it.each(['unmatched-room-option', undefined])('preserves room settings when picker value is %s', async (value) => { + // Given deliberate room settings, unmatched picker values must not replace those choices. + const page = renderNewRoomPage(); + await waitForBatchedUpdatesWithAct(); + selectRoomOption(page, 'Who can post', CONST.REPORT.WRITE_CAPABILITIES.ADMINS); + selectRoomOption(page, 'Visibility', CONST.REPORT.VISIBILITY.PUBLIC); + const capabilityPicker = mockRoomPickers.get('Who can post'); + const visibilityPicker = mockRoomPickers.get('Visibility'); + expect(capabilityPicker?.onInputChange).toBeDefined(); + expect(visibilityPicker?.onInputChange).toBeDefined(); + if (!capabilityPicker?.onInputChange || !visibilityPicker?.onInputChange) { + throw new Error('Expected the mounted room picker callbacks'); + } + + // When a picker reports a missing or unmatched value, the actual selection handlers ignore it. + act(() => { + capabilityPicker.onInputChange?.(value); + visibilityPicker.onInputChange?.(value); + }); + const form = mockRoomForm; + expect(form).toBeDefined(); + if (!form) { + throw new Error('Expected the actual room form'); + } + act(() => + form.onSubmit({ + roomName: '#test-room', + reportDescription: '', + policyID: adminPolicy.id, + writeCapability: CONST.REPORT.WRITE_CAPABILITIES.ADMINS, + visibility: CONST.REPORT.VISIBILITY.PUBLIC, + }), + ); + + // Then the submitted report retains both valid choices rather than resetting or accepting the unknown value. + expect(addPolicyReport).toHaveBeenCalledTimes(1); + expect(jest.mocked(addPolicyReport).mock.calls.at(0)?.[0]).toEqual( + expect.objectContaining({writeCapability: CONST.REPORT.WRITE_CAPABILITIES.ADMINS, visibility: CONST.REPORT.VISIBILITY.PUBLIC}), + ); + }); + it('switches workspace and resets admin-only capability before submit', async () => { // Given an admin-only posting choice, switching to a member workspace must remove that capability. const page = renderNewRoomPage(); From 3e01cdb3f3e75d8384fd3c362b768a4f89f8091c Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:33:12 +0000 Subject: [PATCH 4/4] Fix upstream CI compatibility for PR #103920 Seatbelt-Operation: upstream-ci-repair-103920-7e4b3ccd-cb71-4f37-8701-8f35e50c8125 --- .../DynamicWorkspaceSettlementAccountPageTest.tsx | 15 ++++++++++++--- tests/ui/WorkspaceNewRoomPageTest.tsx | 6 ++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/ui/DynamicWorkspaceSettlementAccountPageTest.tsx b/tests/ui/DynamicWorkspaceSettlementAccountPageTest.tsx index 67a4473b099f..771586f40b0d 100644 --- a/tests/ui/DynamicWorkspaceSettlementAccountPageTest.tsx +++ b/tests/ui/DynamicWorkspaceSettlementAccountPageTest.tsx @@ -183,10 +183,15 @@ describe('DynamicWorkspaceSettlementAccountPage', () => { // Then accountData.bankAccountID and methodID fallback both retain their exact action arguments. expect(updateSettlementAccount).toHaveBeenCalledTimes(2); - expect(jest.mocked(updateSettlementAccount).mock.calls).toEqual([ + const settlementCalls = jest.mocked(updateSettlementAccount).mock.calls; + expect(settlementCalls.map((call) => call.slice(0, 6))).toEqual([ ['example.com', fundID, policyID, programKey, 22, 11], ['example.com', fundID, policyID, programKey, 33, 11], - ]); + ] satisfies Array>); + // Upstream adds an optional pending-state argument. These settings have no pending update. + for (const call of settlementCalls) { + expect(call.slice(6)).toStrictEqual(call.length === 6 ? [] : [false]); + } expect(Navigation.goBack).toHaveBeenCalledTimes(2); }); @@ -211,7 +216,11 @@ describe('DynamicWorkspaceSettlementAccountPage', () => { // Then the real selector delivers the selected ID rather than a display name or list index. expect(updateSettlementAccount).toHaveBeenCalledTimes(1); - expect(updateSettlementAccount).toHaveBeenCalledWith('example.com', fundID, policyID, programKey, 11, 11); + const settlementCalls = jest.mocked(updateSettlementAccount).mock.calls; + expect(settlementCalls.map((call) => call.slice(0, 6))).toEqual([['example.com', fundID, policyID, programKey, 11, 11]] satisfies Array>); + for (const call of settlementCalls) { + expect(call.slice(6)).toStrictEqual(call.length === 6 ? [] : [false]); + } }); it.each([ diff --git a/tests/ui/WorkspaceNewRoomPageTest.tsx b/tests/ui/WorkspaceNewRoomPageTest.tsx index 0ae45c8b2634..ac76dc1159f5 100644 --- a/tests/ui/WorkspaceNewRoomPageTest.tsx +++ b/tests/ui/WorkspaceNewRoomPageTest.tsx @@ -1,10 +1,9 @@ -import {act, cleanup, fireEvent, render, screen} from '@testing-library/react-native'; +import {act, cleanup, fireEvent, render, screen, within} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; import type * as FormProviderModule from '@components/Form/FormProvider'; import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import type * as MenuItemModule from '@components/MenuItemWithTopDescription'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import type {SelectionListProps} from '@components/SelectionList/types'; import type * as ValuePickerModule from '@components/ValuePicker'; @@ -141,8 +140,7 @@ function selectRoomOption(page: ReturnType, label: str } expect(props.items?.some((item) => item.value === value)).toBe(true); // The actual ValuePicker owns its modal visibility through the menu item's press event. - const MenuItem = jest.requireActual('@components/MenuItemWithTopDescription').default; - fireEvent.press(picker.find((node) => node.type === MenuItem)); + fireEvent.press(within(picker).getByRole('button'), createMock({nativeEvent: {}})); fireEvent.press(screen.getByTestId(`room-option-${value}`)); }