From 639b0724700442ce7d0015a5b63a2a17b8b40259 Mon Sep 17 00:00:00 2001 From: MP2EZ <182439403+MP2EZ@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:20:28 -0700 Subject: [PATCH] feat: FEAT-212 migrate Profile to nested React Navigation stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ProfileScreen's hand-rolled currentScreen state machine โ€” and the depth-2 showCloudBackup / selectedDocument sub-machines inside PrivacyDataScreen / LegalDocumentsListScreen โ€” with a nested React Navigation stack (ProfileStackNavigator). Each former subscreen is now a real route with native back-chevron headers (resolves audit finding M3) and iOS swipe-back. Crisis overlay re-host (FEAT-203 ยง5.1 CB-1..CB-7, AS-6 โ€” crisis-agent sign-off: GO): CollapsibleCrisisButton moves from inside ProfileScreen to the navigator wrapper as a sibling above the stack, so it covers every Profile route including depth-2. Frozen props preserved (mode="standard", testID="crisis-profile", position="right"); onNavigate uses the root navigation since CrisisResources is a root-stack modal. - New ProfileStackNavigator + AboutBeingScreen (gated placeholder route, preserves FEAT-209 H2 "About Being." gating) - Subscreens drop SubMenuHeader/onReturn -> native header + goBack - Privacy->CloudBackup and Legal->LegalDocument become pushed routes; LegalDocument takes a serializable documentType param - Profile tab repoints to ProfileStackNavigator (headerShown:false) - Extend crisis-button-reachability.yaml: assert crisis-profile -> crisis-resources-screen from menu + 5 depth-1 routes + depth-2 LegalDocument (CloudBackup depth-2 is cloud_sync-dark, pinned by jest) - New jest test pins frozen crisis props + root-nav onNavigate wiring - Update affected tests for the route model ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/.maestro/crisis-button-reachability.yaml | 112 +++++++++++++++ ...-mindfulness-screen.accessibility.test.tsx | 17 +-- .../about-stoic-mindfulness-screen.test.tsx | 27 ++-- .../privacy-data-cloud-backup-row.test.tsx | 28 ++-- .../unit/profile-stack-navigator.test.tsx | 96 +++++++++++++ app/src/core/navigation/CleanTabNavigator.tsx | 8 +- .../profile/ProfileStackNavigator.tsx | 122 ++++++++++++++++ app/src/features/profile/index.ts | 1 + .../profile/screens/AboutBeingScreen.tsx | 66 +++++++++ .../screens/AboutStoicMindfulnessScreen.tsx | 10 +- .../profile/screens/AccountSettingsScreen.tsx | 10 +- .../profile/screens/AppSettingsScreen.tsx | 10 +- .../profile/screens/CloudBackupScreen.tsx | 10 +- .../profile/screens/LegalDocumentScreen.tsx | 70 +++------- .../screens/LegalDocumentsListScreen.tsx | 39 ++---- .../profile/screens/PrivacyDataScreen.tsx | 27 ++-- .../profile/screens/ProfileScreen.tsx | 132 ++++-------------- .../ProfileScreen.accessibility.test.tsx | 17 +-- 18 files changed, 520 insertions(+), 282 deletions(-) create mode 100644 app/__tests__/unit/profile-stack-navigator.test.tsx create mode 100644 app/src/features/profile/ProfileStackNavigator.tsx create mode 100644 app/src/features/profile/screens/AboutBeingScreen.tsx diff --git a/app/.maestro/crisis-button-reachability.yaml b/app/.maestro/crisis-button-reachability.yaml index 439fe10d..60a2af57 100644 --- a/app/.maestro/crisis-button-reachability.yaml +++ b/app/.maestro/crisis-button-reachability.yaml @@ -69,9 +69,121 @@ name: "Crisis button reaches CrisisResources from every tab" id: "nav-back-button" # โ”€โ”€ Profile tab โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# FEAT-212: Profile migrated from a local currentScreen state machine to a nested +# React Navigation stack (ProfileStackNavigator), and CollapsibleCrisisButton was +# re-hosted from inside ProfileScreen to the navigator wrapper (sibling above the +# stack). The overlay must therefore stay reachable not just on the Profile tab +# landing (menu route) but on EVERY pushed subscreen โ€” the precise regression this +# migration risks. We traverse each depth-1 route + one depth-2 route and assert +# crisis-profile โ†’ crisis-resources-screen from each. +# +# Pops use two distinct testIDs: +# - nav-back-button โ†’ pops the root CrisisResources modal (INFRA-185) +# - profile-back-button โ†’ pops a Profile-stack route (FEAT-212 header back) +# +# NOTE: the Privacy โ†’ CloudBackup depth-2 route is gated by the cloud_sync feature +# flag (dark by default), so its entry row is not present in this default-build +# run; that route's overlay hosting is pinned by ProfileStackNavigator.test.tsx. +# Legal โ†’ LegalDocument (ungated) provides the on-device depth-2 assertion here. + +# menu route (Profile tab landing) - tapOn: id: "tab-profile" - tapOn: id: "crisis-profile" - assertVisible: id: "crisis-resources-screen" +- tapOn: + id: "nav-back-button" + +# depth-1: Notifications & Display +- scrollUntilVisible: + element: + id: "profile-card-appsettings" + direction: DOWN +- tapOn: + id: "profile-card-appsettings" +- tapOn: + id: "crisis-profile" +- assertVisible: + id: "crisis-resources-screen" +- tapOn: + id: "nav-back-button" +- tapOn: + id: "profile-back-button" + +# depth-1: Privacy & Data +- scrollUntilVisible: + element: + id: "profile-card-privacy" + direction: DOWN +- tapOn: + id: "profile-card-privacy" +- tapOn: + id: "crisis-profile" +- assertVisible: + id: "crisis-resources-screen" +- tapOn: + id: "nav-back-button" +- tapOn: + id: "profile-back-button" + +# depth-1: Account +- scrollUntilVisible: + element: + id: "profile-card-account" + direction: DOWN +- tapOn: + id: "profile-card-account" +- tapOn: + id: "crisis-profile" +- assertVisible: + id: "crisis-resources-screen" +- tapOn: + id: "nav-back-button" +- tapOn: + id: "profile-back-button" + +# depth-1: About Stoic Mindfulness +- scrollUntilVisible: + element: + id: "profile-card-stoic" + direction: DOWN +- tapOn: + id: "profile-card-stoic" +- tapOn: + id: "crisis-profile" +- assertVisible: + id: "crisis-resources-screen" +- tapOn: + id: "nav-back-button" +- tapOn: + id: "profile-back-button" + +# depth-1: Legal Documents +- scrollUntilVisible: + element: + id: "profile-card-legal" + direction: DOWN +- tapOn: + id: "profile-card-legal" +- tapOn: + id: "crisis-profile" +- assertVisible: + id: "crisis-resources-screen" +- tapOn: + id: "nav-back-button" + +# depth-2: Legal Documents โ†’ an individual document (real push, depth 2) +- tapOn: + id: "profile-legal-doc-privacy-policy" +- tapOn: + id: "crisis-profile" +- assertVisible: + id: "crisis-resources-screen" +- tapOn: + id: "nav-back-button" +- tapOn: + id: "profile-back-button" +- tapOn: + id: "profile-back-button" diff --git a/app/__tests__/unit/about-stoic-mindfulness-screen.accessibility.test.tsx b/app/__tests__/unit/about-stoic-mindfulness-screen.accessibility.test.tsx index 69d7a852..1da6f630 100644 --- a/app/__tests__/unit/about-stoic-mindfulness-screen.accessibility.test.tsx +++ b/app/__tests__/unit/about-stoic-mindfulness-screen.accessibility.test.tsx @@ -5,7 +5,10 @@ * (previously plain Text with no role) gain `accessibilityRole="header"` / * `accessibilityLevel={2}` so screen readers expose the article's structure under * the screen's level-1 heading. Verifies all four section headers carry the role - * and level, and that the close affordance is properly labelled. + * and level. + * + * FEAT-212: the back affordance is now the native stack header (ProfileStackNavigator), + * so the in-content close-button label test was removed. */ import React from 'react'; import { render } from '@testing-library/react-native'; @@ -14,7 +17,7 @@ import AboutStoicMindfulnessScreen from '@/features/profile/screens/AboutStoicMi describe('AboutStoicMindfulnessScreen โ€” accessibility (FEAT-211)', () => { it('exposes all four section titles as level-2 headers', () => { - const { getAllByRole } = render(); + const { getAllByRole } = render(); const headers = getAllByRole('header'); expect(headers).toHaveLength(4); @@ -24,7 +27,7 @@ describe('AboutStoicMindfulnessScreen โ€” accessibility (FEAT-211)', () => { }); it('labels each section header for screen readers', () => { - const { getByText } = render(); + const { getByText } = render(); ['What is Stoic Mindfulness?', 'The Five Principles', 'Developmental Stages', 'Philosophical Foundations'].forEach((title) => { const node = getByText(title); @@ -32,12 +35,4 @@ describe('AboutStoicMindfulnessScreen โ€” accessibility (FEAT-211)', () => { expect(node.props.accessibilityLevel).toBe(2); }); }); - - it('gives the close button an accessible label and hint', () => { - const { getByLabelText } = render(); - - const closeButton = getByLabelText('Close About Stoic Mindfulness'); - expect(closeButton.props.accessibilityRole).toBe('button'); - expect(closeButton.props.accessibilityHint).toBe('Returns to profile menu'); - }); }); diff --git a/app/__tests__/unit/about-stoic-mindfulness-screen.test.tsx b/app/__tests__/unit/about-stoic-mindfulness-screen.test.tsx index a9e38ba7..88fdc4be 100644 --- a/app/__tests__/unit/about-stoic-mindfulness-screen.test.tsx +++ b/app/__tests__/unit/about-stoic-mindfulness-screen.test.tsx @@ -6,18 +6,21 @@ * order, the four developmental stages with their timeframes, and the three dated * philosopher attributions. Content is a protected therapeutic path โ€” these * assertions guard against accidental edits during the structural move (and the - * later FEAT-76 content enhancement). Also verifies the close affordance calls back. + * later FEAT-76 content enhancement). + * + * FEAT-212: the screen is now a route; its title ("About Stoic Mindfulness") and + * back affordance are supplied by the native stack header (ProfileStackNavigator), + * not an in-content SubMenuHeader โ€” so the close-button test moved to the navigator. */ import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; +import { render } from '@testing-library/react-native'; import AboutStoicMindfulnessScreen from '@/features/profile/screens/AboutStoicMindfulnessScreen'; describe('AboutStoicMindfulnessScreen โ€” FEAT-211 verbatim extraction', () => { - it('renders the title and all four section headers', () => { - const { getByText } = render(); + it('renders all four section headers', () => { + const { getByText } = render(); - expect(getByText('About Stoic Mindfulness')).toBeTruthy(); expect(getByText('What is Stoic Mindfulness?')).toBeTruthy(); expect(getByText('The Five Principles')).toBeTruthy(); expect(getByText('Developmental Stages')).toBeTruthy(); @@ -25,7 +28,7 @@ describe('AboutStoicMindfulnessScreen โ€” FEAT-211 verbatim extraction', () => { }); it('renders the five principles in canonical order', () => { - const { getByText } = render(); + const { getByText } = render(); expect(getByText('1. Aware Presence')).toBeTruthy(); expect(getByText('2. Radical Acceptance')).toBeTruthy(); @@ -35,7 +38,7 @@ describe('AboutStoicMindfulnessScreen โ€” FEAT-211 verbatim extraction', () => { }); it('renders the four developmental stages with exact timeframes', () => { - const { getByText } = render(); + const { getByText } = render(); expect(getByText('Fragmented (1-6 months)')).toBeTruthy(); expect(getByText('Effortful (6-18 months)')).toBeTruthy(); @@ -44,18 +47,10 @@ describe('AboutStoicMindfulnessScreen โ€” FEAT-211 verbatim extraction', () => { }); it('renders the three philosopher attributions', () => { - const { getByText } = render(); + const { getByText } = render(); expect(getByText('Marcus Aurelius')).toBeTruthy(); expect(getByText('Epictetus')).toBeTruthy(); expect(getByText('Seneca')).toBeTruthy(); }); - - it('calls onReturn when the close button is pressed', () => { - const onReturn = jest.fn(); - const { getByLabelText } = render(); - - fireEvent.press(getByLabelText('Close About Stoic Mindfulness')); - expect(onReturn).toHaveBeenCalledTimes(1); - }); }); diff --git a/app/__tests__/unit/privacy-data-cloud-backup-row.test.tsx b/app/__tests__/unit/privacy-data-cloud-backup-row.test.tsx index 7e01e62d..69f2926b 100644 --- a/app/__tests__/unit/privacy-data-cloud-backup-row.test.tsx +++ b/app/__tests__/unit/privacy-data-cloud-backup-row.test.tsx @@ -8,28 +8,26 @@ * INFRA-199: the row now resolves the flag via the runtime `useFeatureFlag` * hook (from @/core/analytics) rather than the sync `isFeatureEnabled`, so the * control point here is the mocked hook. The dark-ship assertions are unchanged. + * + * FEAT-212: Cloud Backup is now a pushed route on ProfileStackNavigator rather + * than an in-component sub-screen, so pressing the row calls + * navigation.navigate('CloudBackup') instead of swapping rendered content. */ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react-native'; -// useFocusEffect needs a NavigationContainer in the real impl; no-op it. +const mockNavigate = jest.fn(); + +// PrivacyDataScreen uses useFocusEffect (no-op it) and useNavigation (spy the +// navigate so we can assert the row pushes the CloudBackup route). jest.mock('@react-navigation/native', () => ({ useFocusEffect: jest.fn(), + useNavigation: () => ({ navigate: mockNavigate }), })); // Control the flag per test (the runtime hook is the gating point post-INFRA-199). const mockUseFeatureFlag = jest.fn(); -// Stub the heavy child screen so navigation is observable without rendering -// the full cloud-backup UI (and its useCloudSync hook chain). -jest.mock('@/features/profile/screens/CloudBackupScreen', () => { - const { Text } = require('react-native'); - return { - __esModule: true, - default: () => Cloud Backup Screen, - }; -}); - // Consent store: provide the slice PrivacyDataScreen destructures. jest.mock('@/core/stores/consentStore', () => ({ useConsentStore: () => ({ @@ -66,21 +64,21 @@ describe('PrivacyDataScreen โ€” Manage Cloud Backup row (MAINT-173)', () => { it('hides the row when cloud_sync is OFF (dark ship)', async () => { mockUseFeatureFlag.mockReturnValue(false); - const { queryByText } = render(); + const { queryByText } = render(); // Wait for the post-load form to render. await waitFor(() => expect(queryByText('Settings Backup')).toBeTruthy()); expect(queryByText('Manage Cloud Backup')).toBeNull(); }); - it('shows the row, with a11y role/label, and navigates when cloud_sync is ON', async () => { + it('shows the row, with a11y role/label, and pushes the CloudBackup route when cloud_sync is ON', async () => { mockUseFeatureFlag.mockImplementation((name) => name === 'cloud_sync'); - const { findByRole, getByTestId } = render(); + const { findByRole } = render(); const row = await findByRole('button', { name: 'Manage Cloud Backup' }); expect(row).toBeTruthy(); fireEvent.press(row); - expect(getByTestId('cloud-backup-screen')).toBeTruthy(); + expect(mockNavigate).toHaveBeenCalledWith('CloudBackup'); }); }); diff --git a/app/__tests__/unit/profile-stack-navigator.test.tsx b/app/__tests__/unit/profile-stack-navigator.test.tsx new file mode 100644 index 00000000..258bb665 --- /dev/null +++ b/app/__tests__/unit/profile-stack-navigator.test.tsx @@ -0,0 +1,96 @@ +/** + * profile-stack-navigator.test.tsx โ€” FEAT-212 crisis-overlay re-host (audit ยง5.1). + * + * FEAT-212 moved CollapsibleCrisisButton from inside ProfileScreen up to the + * ProfileStackNavigator wrapper (sibling above ). This spec pins + * the frozen crisis contract at the jest layer by capturing the props the navigator + * passes to the overlay: + * - CB-7: testID="crisis-profile" (the safety e2e + reachability target) + * - CB-6: mode="standard" (full opacity โ€” never downgraded to immersive) + * - CB-4: position="right" + * - CB-1: onNavigate routes to the ROOT CrisisResources modal, not the local + * Profile stack โ€” the failure mode where the dial path silently no-ops. + * + * The navigator machinery and screen modules are stubbed: this test is about WHAT + * the navigator wires into the overlay. On-device reachability across every route + * (depth-1 + depth-2) is the Maestro crisis-button-reachability flow. + */ +import React from 'react'; +import { render } from '@testing-library/react-native'; + +// Root navigation spy โ€” the navigator must resolve THIS (root) nav for onNavigate. +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +// Stub the stack so just renders its children and screens are +// inert โ€” keeps the overlay (the sibling we care about) rendering without a +// NavigationContainer (unsupported in this jest env). +jest.mock('@react-navigation/stack', () => { + const ReactLib = require('react'); + return { + createStackNavigator: () => ({ + Navigator: ({ children }: { children: React.ReactNode }) => + ReactLib.createElement(ReactLib.Fragment, null, children), + Screen: () => null, + }), + }; +}); +jest.mock('@react-navigation/elements', () => ({ HeaderBackButton: () => null })); + +// Capture the props the navigator passes to the crisis overlay. +const mockCrisisProps = jest.fn(); +jest.mock('@/features/crisis/components/CollapsibleCrisisButton', () => { + const ReactLib = require('react'); + const { Text } = require('react-native'); + return { + __esModule: true, + CollapsibleCrisisButton: (props: Record) => { + mockCrisisProps(props); + return ReactLib.createElement(Text, { testID: props.testID as string }, 'crisis'); + }, + }; +}); + +// Screen modules are imported at the navigator's module top โ€” stub them so their +// heavy transitive deps (e.g. react-native-markdown-display ESM) never load. +jest.mock('@/features/profile/screens/ProfileScreen', () => ({ __esModule: true, default: () => null })); +jest.mock('@/features/profile/screens/AccountSettingsScreen', () => ({ __esModule: true, default: () => null })); +jest.mock('@/features/profile/screens/PrivacyDataScreen', () => ({ __esModule: true, default: () => null })); +jest.mock('@/features/profile/screens/AppSettingsScreen', () => ({ __esModule: true, default: () => null })); +jest.mock('@/features/profile/screens/AboutStoicMindfulnessScreen', () => ({ __esModule: true, default: () => null })); +jest.mock('@/features/profile/screens/AboutBeingScreen', () => ({ __esModule: true, default: () => null })); +jest.mock('@/features/profile/screens/LegalDocumentsListScreen', () => ({ __esModule: true, default: () => null })); +jest.mock('@/features/profile/screens/LegalDocumentScreen', () => ({ __esModule: true, default: () => null })); +jest.mock('@/features/profile/screens/CloudBackupScreen', () => ({ __esModule: true, default: () => null })); + +import ProfileStackNavigator from '@/features/profile/ProfileStackNavigator'; + +beforeEach(() => { + mockNavigate.mockClear(); + mockCrisisProps.mockClear(); +}); + +describe('ProfileStackNavigator โ€” crisis overlay re-host (FEAT-212)', () => { + it('hosts the crisis overlay as a sibling of the stack (testID="crisis-profile")', () => { + const { getByTestId } = render(); + expect(getByTestId('crisis-profile')).toBeTruthy(); + }); + + it('passes the frozen crisis props: standard mode, right position, pinned testID', () => { + render(); + expect(mockCrisisProps).toHaveBeenCalledTimes(1); + const props = mockCrisisProps.mock.calls[0][0]; + expect(props.mode).toBe('standard'); // CB-6: never immersive + expect(props.position).toBe('right'); // CB-4 + expect(props.testID).toBe('crisis-profile'); // CB-7 + }); + + it('CB-1: onNavigate routes to the ROOT CrisisResources modal', () => { + render(); + const props = mockCrisisProps.mock.calls[0][0]; + (props.onNavigate as () => void)(); + expect(mockNavigate).toHaveBeenCalledWith('CrisisResources'); + }); +}); diff --git a/app/src/core/navigation/CleanTabNavigator.tsx b/app/src/core/navigation/CleanTabNavigator.tsx index ca9766c4..d00fd882 100644 --- a/app/src/core/navigation/CleanTabNavigator.tsx +++ b/app/src/core/navigation/CleanTabNavigator.tsx @@ -16,7 +16,7 @@ import { View, Text } from 'react-native'; import Svg, { Path, Circle, Rect, ClipPath, Defs, G } from 'react-native-svg'; import { colorSystem, spacing, typography } from '@/core/theme'; import CleanHomeScreen from '@/features/home/screens/CleanHomeScreen'; -import ProfileScreen from '@/features/profile/screens/ProfileScreen'; +import ProfileStackNavigator from '@/features/profile/ProfileStackNavigator'; import InsightsScreen from '@/features/insights/screens/InsightsScreen'; import LearnScreen from '@/features/learn/screens/LearnScreen'; import BrainIcon from '@/core/components/shared/BrainIcon'; @@ -190,10 +190,12 @@ const CleanTabNavigator: React.FC = () => {