diff --git a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx index 2ce9fd9b772..f33b693db5f 100644 --- a/apps/mobile/src/components/security-agent/notification-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/notification-settings-screen.tsx @@ -208,7 +208,15 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string } // iOS IME inset and focused-field auto-reveal (AGENTS.md: input screens). automaticallyAdjustKeyboardInsets className="flex-1" - contentContainerClassName="px-6 gap-6 pt-4" + // The personal scope has no section heading between its two switches, so + // the break between the two groups carries their separation: it has to + // read as a group break, not as the 10dp step down to the severity label + // or the 7dp step inside its options, and not as the void a removed + // heading would leave. The organization scope keeps the standard gap, + // next to its headings. The permission note above the groups only + // renders for a scope that cannot manage, which the personal scope + // never is. + contentContainerClassName={cn('px-6 pt-4', personal ? 'gap-7' : 'gap-6')} > {!canManage && ( @@ -216,9 +224,11 @@ export function NotificationSettingsScreen({ scope }: Readonly<{ scope: string } )} - - {t('securityAgent.notifications.newFindingSection')} - + {!personal && ( + + {t('securityAgent.notifications.newFindingSection')} + + )} - - {t('securityAgent.notifications.slaSection')} - + {!personal && ( + + {t('securityAgent.notifications.slaSection')} + + )} ({ randomUUID: () => '00000000-0000-4000-8000-000000000000' })); +vi.mock('expo-haptics', () => ({ selectionAsync: vi.fn() })); +vi.mock('react-native', () => ({ + View: 'View', + ScrollView: 'ScrollView', + TextInput: 'TextInput', + Pressable: 'Pressable', + Switch: 'Switch', + Platform: { OS: 'android' }, + useColorScheme: () => 'light', + Alert: { alert: vi.fn() }, + I18nManager: { isRTL: false }, +})); +vi.mock('expo-router', () => ({ + useRouter: () => ({ back: vi.fn(), replace: vi.fn(), push: vi.fn() }), + useNavigation: () => ({ dispatch: vi.fn(), goBack: vi.fn() }), + usePreventRemove: () => undefined, + // The screen's disabled-config redirect only runs on focus, and the config + // these cases mount is enabled, so it never redirects here. + useFocusEffect: () => undefined, +})); +vi.mock('sonner-native', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('@/components/ui/icons', () => ({ Check: 'Check' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ + mutedForeground: '#000', + primaryForeground: '#fff', + primary: '#00f', + }), +})); +vi.mock('@/lib/hooks/use-security-agent', () => ({ + useSecurityAgentCapability: () => ({ + canManage: true, + status: 'allowed', + isLoading: false, + isFetching: false, + isError: false, + refetch: vi.fn(), + }), + useSecurityAgentConfig: () => ({ + data: { + isEnabled: true, + newFindingNotificationsEnabled: true, + newFindingNotificationMinSeverity: 'high', + slaNotificationsEnabled: true, + slaNotificationMinSeverity: 'medium', + slaNotificationWarningDays: 7, + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useSaveSecurityAgentConfig: () => ({ mutateAsync: vi.fn(), isPending: false }), + useTrackSecurityAgentInteraction: () => ({ mutate: vi.fn() }), +})); +vi.mock('@rn-primitives/dialog', () => ({ + Action: 'AlertDialog.Action', + Cancel: 'AlertDialog.Cancel', + Content: 'AlertDialog.Content', + Description: 'AlertDialog.Description', + Overlay: 'AlertDialog.Overlay', + Portal: 'AlertDialog.Portal', + Root: 'AlertDialog.Root', + Title: 'AlertDialog.Title', +})); +vi.mock('@/components/destructive-confirm-dialog', () => ({ + DestructiveConfirmDialog: () => null, +})); +vi.mock('@/components/ui/input', () => ({ Input: 'Input' })); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: () => null })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/radio-group', () => ({ + RadioGroup: (props: { children?: unknown }) => props.children ?? null, + radioItemA11y: ({ + label, + checked, + disabled, + }: { + label: string; + checked: boolean; + disabled: boolean; + }) => ({ + accessibilityRole: 'radio', + accessibilityLabel: label, + accessibilityState: { checked, disabled, busy: false }, + }), +})); +vi.mock('@/components/screen-header', () => ({ ScreenHeader: () => null })); +vi.mock('@/components/platform-error-screen', () => ({ + PlatformErrorScreen: 'PlatformErrorScreen', +})); +vi.mock('@/components/security-agent/settings-save-button', () => ({ + SettingsSaveButton: () => null, +})); +vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'TabScreenScrollView' })); + +const PERSONAL_SCOPE = 'personal'; +const ORGANIZATION_SCOPE = '00000000-0000-4000-8000-000000000001'; + +let renderer: TestRenderer.ReactTestRenderer | undefined = undefined; + +afterEach(() => { + act(() => { + renderer?.unmount(); + }); + renderer = undefined; +}); + +function mount(scope: string): TestRenderer.ReactTestRenderer { + act(() => { + renderer = TestRenderer.create(createElement(NotificationSettingsScreen, { scope })); + }); + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +/** + * The section headings the tree paints, in order. A switch's accessible label + * is composed from the same strings on separate lines, so it cannot stand in + * for the heading itself. + */ +function sectionHeadings(screen: TestRenderer.ReactTestRenderer): string[] { + const sectionKeys = new Set([ + i18n.t('securityAgent.notifications.newFindingSection'), + i18n.t('securityAgent.notifications.slaSection'), + ]); + return screen.root + .findAllByType('Text') + .map(node => node.props.children) + .filter((child): child is string => typeof child === 'string' && sectionKeys.has(child)); +} + +describe('personal scope', () => { + it('titles each switch after its notification and shows no section heading', () => { + const screen = mount(PERSONAL_SCOPE); + const copy = screen.root.findAllByType('Text').map(node => node.props.children); + + expect(copy).toContain(i18n.t('securityAgent.notifications.newFindingPersonalTitle')); + expect(copy).toContain(i18n.t('securityAgent.notifications.slaPersonalTitle')); + expect(sectionHeadings(screen)).toEqual([]); + }); + + it('separates the two switch groups by a break wider than the steps inside one', () => { + const screen = mount(PERSONAL_SCOPE); + + expect(screen.root.findByType('TabScreenScrollView').props.contentContainerClassName).toBe( + 'px-6 pt-4 gap-7' + ); + }); +}); + +describe('organization scope', () => { + it('heads each switch with its notification section, because both read the same recipient', () => { + const screen = mount(ORGANIZATION_SCOPE); + const names = screen.root.findAllByType('Switch').map(node => node.props.accessibilityLabel); + + expect(names).toHaveLength(2); + expect(names[0]).toContain(i18n.t('securityAgent.notifications.newFindingSection')); + expect(names[1]).toContain(i18n.t('securityAgent.notifications.slaSection')); + expect(names[1]).not.toContain(i18n.t('securityAgent.notifications.newFindingSection')); + expect(sectionHeadings(screen)).toEqual([ + i18n.t('securityAgent.notifications.newFindingSection'), + i18n.t('securityAgent.notifications.slaSection'), + ]); + }); + + it('keeps the standard group gap, next to its headings', () => { + const screen = mount(ORGANIZATION_SCOPE); + + expect(screen.root.findByType('TabScreenScrollView').props.contentContainerClassName).toBe( + 'px-6 pt-4 gap-6' + ); + }); +});