Skip to content

Commit 9dbf62e

Browse files
NicolasBonetclaude
andcommitted
Add AIRulesSection component tests
Cover title/prompt fallback, whitespace normalization, sort order, pending-delete visibility online vs offline, and rule item / add button navigation paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 08019cd commit 9dbf62e

1 file changed

Lines changed: 292 additions & 0 deletions

File tree

tests/ui/AIRulesSectionTest.tsx

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import {render} from '@testing-library/react-native';
2+
import React from 'react';
3+
import MenuItem from '@components/MenuItem';
4+
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
5+
import useNetwork from '@hooks/useNetwork';
6+
import usePolicy from '@hooks/usePolicy';
7+
import Navigation from '@libs/Navigation/Navigation';
8+
import AIRulesSection from '@pages/workspace/rules/AIRulesSection';
9+
import CONST from '@src/CONST';
10+
import ROUTES from '@src/ROUTES';
11+
12+
jest.mock('@components/Badge', () => jest.fn(() => null));
13+
jest.mock('@components/MenuItem', () => jest.fn(() => null));
14+
jest.mock('@components/MenuItemWithTopDescription', () => jest.fn(() => null));
15+
jest.mock(
16+
'@components/OfflineWithFeedback',
17+
() =>
18+
({children}: {children: React.ReactNode}) =>
19+
children,
20+
);
21+
jest.mock(
22+
'@components/Section',
23+
() =>
24+
({children}: {children: React.ReactNode}) =>
25+
children,
26+
);
27+
jest.mock('@components/Text', () => jest.fn(() => null));
28+
29+
jest.mock('@hooks/useLazyAsset', () => ({
30+
useMemoizedLazyExpensifyIcons: jest.fn(() => ({Plus: null})),
31+
}));
32+
jest.mock('@hooks/useLocalize', () =>
33+
jest.fn(() => ({
34+
translate: (key: string) => key,
35+
})),
36+
);
37+
jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false})));
38+
jest.mock('@hooks/usePolicy', () => jest.fn());
39+
jest.mock('@hooks/useTheme', () => jest.fn(() => ({text: '#000'})));
40+
jest.mock('@hooks/useThemeStyles', () =>
41+
jest.fn(
42+
() =>
43+
new Proxy(
44+
{},
45+
{
46+
get: () => ({}),
47+
},
48+
),
49+
),
50+
);
51+
52+
jest.mock('@libs/Navigation/Navigation', () => ({
53+
navigate: jest.fn(),
54+
}));
55+
56+
jest.mock('@userActions/Policy/Rules', () => ({
57+
clearPolicyAIRuleErrors: jest.fn(),
58+
}));
59+
60+
const mockedUsePolicy = jest.mocked(usePolicy);
61+
const mockedUseNetwork = jest.mocked(useNetwork);
62+
const mockedMenuItemWithTopDescription = jest.mocked(MenuItemWithTopDescription);
63+
const mockedMenuItem = jest.mocked(MenuItem);
64+
const mockedNavigate = jest.mocked(Navigation.navigate);
65+
66+
const POLICY_ID = 'POLICY_ID_1';
67+
68+
function setPolicyAIRules(aiRules: Record<string, unknown> | undefined) {
69+
mockedUsePolicy.mockReturnValue({rules: {aiRules}} as ReturnType<typeof usePolicy>);
70+
}
71+
72+
function getRuleTitles(): string[] {
73+
return mockedMenuItemWithTopDescription.mock.calls.map((call) => call.at(0)?.title as string);
74+
}
75+
76+
describe('AIRulesSection', () => {
77+
beforeEach(() => {
78+
jest.clearAllMocks();
79+
mockedUseNetwork.mockReturnValue({isOffline: false} as ReturnType<typeof useNetwork>);
80+
});
81+
82+
describe('title rendering', () => {
83+
it('uses rule.title when set', () => {
84+
setPolicyAIRules({
85+
r1: {ruleID: 'r1', prompt: 'Long prompt text', title: 'Short title', created: '2026-01-01 00:00:00'},
86+
});
87+
88+
render(
89+
<AIRulesSection
90+
policyID={POLICY_ID}
91+
canWriteRules
92+
showReadOnlyModal={jest.fn()}
93+
/>,
94+
);
95+
96+
expect(getRuleTitles()).toEqual(['Short title']);
97+
});
98+
99+
it('falls back to rule.prompt when title is missing', () => {
100+
setPolicyAIRules({
101+
r1: {ruleID: 'r1', prompt: 'Prompt only', created: '2026-01-01 00:00:00'},
102+
});
103+
104+
render(
105+
<AIRulesSection
106+
policyID={POLICY_ID}
107+
canWriteRules
108+
showReadOnlyModal={jest.fn()}
109+
/>,
110+
);
111+
112+
expect(getRuleTitles()).toEqual(['Prompt only']);
113+
});
114+
115+
it('collapses whitespace and trims', () => {
116+
setPolicyAIRules({
117+
r1: {ruleID: 'r1', prompt: ' hello\n\n world \t! ', created: '2026-01-01 00:00:00'},
118+
});
119+
120+
render(
121+
<AIRulesSection
122+
policyID={POLICY_ID}
123+
canWriteRules
124+
showReadOnlyModal={jest.fn()}
125+
/>,
126+
);
127+
128+
expect(getRuleTitles()).toEqual(['hello world !']);
129+
});
130+
});
131+
132+
describe('rule list filtering and sorting', () => {
133+
it('sorts rules by created desc', () => {
134+
setPolicyAIRules({
135+
r1: {ruleID: 'r1', prompt: 'first', title: 'A', created: '2026-01-01 00:00:00'},
136+
r2: {ruleID: 'r2', prompt: 'second', title: 'B', created: '2026-02-01 00:00:00'},
137+
r3: {ruleID: 'r3', prompt: 'third', title: 'C', created: '2026-03-01 00:00:00'},
138+
});
139+
140+
render(
141+
<AIRulesSection
142+
policyID={POLICY_ID}
143+
canWriteRules
144+
showReadOnlyModal={jest.fn()}
145+
/>,
146+
);
147+
148+
expect(getRuleTitles()).toEqual(['C', 'B', 'A']);
149+
});
150+
151+
it('hides pending-delete rules when online', () => {
152+
setPolicyAIRules({
153+
r1: {ruleID: 'r1', prompt: 'keep', title: 'Keep', created: '2026-01-01 00:00:00'},
154+
r2: {
155+
ruleID: 'r2',
156+
prompt: 'goner',
157+
title: 'Goner',
158+
created: '2026-02-01 00:00:00',
159+
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
160+
},
161+
});
162+
163+
render(
164+
<AIRulesSection
165+
policyID={POLICY_ID}
166+
canWriteRules
167+
showReadOnlyModal={jest.fn()}
168+
/>,
169+
);
170+
171+
expect(getRuleTitles()).toEqual(['Keep']);
172+
});
173+
174+
it('keeps pending-delete rules when offline so OfflineWithFeedback can style them', () => {
175+
mockedUseNetwork.mockReturnValue({isOffline: true} as ReturnType<typeof useNetwork>);
176+
setPolicyAIRules({
177+
r1: {ruleID: 'r1', prompt: 'keep', title: 'Keep', created: '2026-01-01 00:00:00'},
178+
r2: {
179+
ruleID: 'r2',
180+
prompt: 'goner',
181+
title: 'Goner',
182+
created: '2026-02-01 00:00:00',
183+
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
184+
},
185+
});
186+
187+
render(
188+
<AIRulesSection
189+
policyID={POLICY_ID}
190+
canWriteRules
191+
showReadOnlyModal={jest.fn()}
192+
/>,
193+
);
194+
195+
expect(getRuleTitles()).toEqual(['Goner', 'Keep']);
196+
});
197+
198+
it('renders no rule items when policy has none', () => {
199+
setPolicyAIRules(undefined);
200+
201+
render(
202+
<AIRulesSection
203+
policyID={POLICY_ID}
204+
canWriteRules
205+
showReadOnlyModal={jest.fn()}
206+
/>,
207+
);
208+
209+
expect(mockedMenuItemWithTopDescription).not.toHaveBeenCalled();
210+
});
211+
});
212+
213+
describe('add rule button', () => {
214+
it('navigates to RULES_AI_NEW when user has write access', () => {
215+
setPolicyAIRules(undefined);
216+
217+
render(
218+
<AIRulesSection
219+
policyID={POLICY_ID}
220+
canWriteRules
221+
showReadOnlyModal={jest.fn()}
222+
/>,
223+
);
224+
225+
const onPress = mockedMenuItem.mock.calls.at(0)?.at(0)?.onPress;
226+
onPress?.({} as never);
227+
228+
expect(mockedNavigate).toHaveBeenCalledWith(ROUTES.RULES_AI_NEW.getRoute(POLICY_ID));
229+
});
230+
231+
it('calls showReadOnlyModal when user lacks write access', () => {
232+
const showReadOnlyModal = jest.fn();
233+
setPolicyAIRules(undefined);
234+
235+
render(
236+
<AIRulesSection
237+
policyID={POLICY_ID}
238+
canWriteRules={false}
239+
showReadOnlyModal={showReadOnlyModal}
240+
/>,
241+
);
242+
243+
const onPress = mockedMenuItem.mock.calls.at(0)?.at(0)?.onPress;
244+
onPress?.({} as never);
245+
246+
expect(showReadOnlyModal).toHaveBeenCalledTimes(1);
247+
expect(mockedNavigate).not.toHaveBeenCalled();
248+
});
249+
});
250+
251+
describe('rule item press', () => {
252+
it('navigates to RULES_AI_EDIT when user has write access', () => {
253+
setPolicyAIRules({
254+
r1: {ruleID: 'r1', prompt: 'p', title: 'T', created: '2026-01-01 00:00:00'},
255+
});
256+
257+
render(
258+
<AIRulesSection
259+
policyID={POLICY_ID}
260+
canWriteRules
261+
showReadOnlyModal={jest.fn()}
262+
/>,
263+
);
264+
265+
const onPress = mockedMenuItemWithTopDescription.mock.calls.at(0)?.at(0)?.onPress;
266+
onPress?.({} as never);
267+
268+
expect(mockedNavigate).toHaveBeenCalledWith(ROUTES.RULES_AI_EDIT.getRoute(POLICY_ID, 'r1'));
269+
});
270+
271+
it('calls showReadOnlyModal when user lacks write access', () => {
272+
const showReadOnlyModal = jest.fn();
273+
setPolicyAIRules({
274+
r1: {ruleID: 'r1', prompt: 'p', title: 'T', created: '2026-01-01 00:00:00'},
275+
});
276+
277+
render(
278+
<AIRulesSection
279+
policyID={POLICY_ID}
280+
canWriteRules={false}
281+
showReadOnlyModal={showReadOnlyModal}
282+
/>,
283+
);
284+
285+
const onPress = mockedMenuItemWithTopDescription.mock.calls.at(0)?.at(0)?.onPress;
286+
onPress?.({} as never);
287+
288+
expect(showReadOnlyModal).toHaveBeenCalledTimes(1);
289+
expect(mockedNavigate).not.toHaveBeenCalled();
290+
});
291+
});
292+
});

0 commit comments

Comments
 (0)