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}`));
}