Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions app/.maestro/crisis-button-reachability.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(<AboutStoicMindfulnessScreen onReturn={jest.fn()} />);
const { getAllByRole } = render(<AboutStoicMindfulnessScreen />);

const headers = getAllByRole('header');
expect(headers).toHaveLength(4);
Expand All @@ -24,20 +27,12 @@ describe('AboutStoicMindfulnessScreen — accessibility (FEAT-211)', () => {
});

it('labels each section header for screen readers', () => {
const { getByText } = render(<AboutStoicMindfulnessScreen onReturn={jest.fn()} />);
const { getByText } = render(<AboutStoicMindfulnessScreen />);

['What is Stoic Mindfulness?', 'The Five Principles', 'Developmental Stages', 'Philosophical Foundations'].forEach((title) => {
const node = getByText(title);
expect(node.props.accessibilityRole).toBe('header');
expect(node.props.accessibilityLevel).toBe(2);
});
});

it('gives the close button an accessible label and hint', () => {
const { getByLabelText } = render(<AboutStoicMindfulnessScreen onReturn={jest.fn()} />);

const closeButton = getByLabelText('Close About Stoic Mindfulness');
expect(closeButton.props.accessibilityRole).toBe('button');
expect(closeButton.props.accessibilityHint).toBe('Returns to profile menu');
});
});
27 changes: 11 additions & 16 deletions app/__tests__/unit/about-stoic-mindfulness-screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,29 @@
* 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(<AboutStoicMindfulnessScreen onReturn={jest.fn()} />);
it('renders all four section headers', () => {
const { getByText } = render(<AboutStoicMindfulnessScreen />);

expect(getByText('About Stoic Mindfulness')).toBeTruthy();
expect(getByText('What is Stoic Mindfulness?')).toBeTruthy();
expect(getByText('The Five Principles')).toBeTruthy();
expect(getByText('Developmental Stages')).toBeTruthy();
expect(getByText('Philosophical Foundations')).toBeTruthy();
});

it('renders the five principles in canonical order', () => {
const { getByText } = render(<AboutStoicMindfulnessScreen onReturn={jest.fn()} />);
const { getByText } = render(<AboutStoicMindfulnessScreen />);

expect(getByText('1. Aware Presence')).toBeTruthy();
expect(getByText('2. Radical Acceptance')).toBeTruthy();
Expand All @@ -35,7 +38,7 @@ describe('AboutStoicMindfulnessScreen — FEAT-211 verbatim extraction', () => {
});

it('renders the four developmental stages with exact timeframes', () => {
const { getByText } = render(<AboutStoicMindfulnessScreen onReturn={jest.fn()} />);
const { getByText } = render(<AboutStoicMindfulnessScreen />);

expect(getByText('Fragmented (1-6 months)')).toBeTruthy();
expect(getByText('Effortful (6-18 months)')).toBeTruthy();
Expand All @@ -44,18 +47,10 @@ describe('AboutStoicMindfulnessScreen — FEAT-211 verbatim extraction', () => {
});

it('renders the three philosopher attributions', () => {
const { getByText } = render(<AboutStoicMindfulnessScreen onReturn={jest.fn()} />);
const { getByText } = render(<AboutStoicMindfulnessScreen />);

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(<AboutStoicMindfulnessScreen onReturn={onReturn} />);

fireEvent.press(getByLabelText('Close About Stoic Mindfulness'));
expect(onReturn).toHaveBeenCalledTimes(1);
});
});
28 changes: 13 additions & 15 deletions app/__tests__/unit/privacy-data-cloud-backup-row.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean, [string]>();

// 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: () => <Text testID="cloud-backup-screen">Cloud Backup Screen</Text>,
};
});

// Consent store: provide the slice PrivacyDataScreen destructures.
jest.mock('@/core/stores/consentStore', () => ({
useConsentStore: () => ({
Expand Down Expand Up @@ -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(<PrivacyDataScreen onReturn={jest.fn()} />);
const { queryByText } = render(<PrivacyDataScreen />);

// 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(<PrivacyDataScreen onReturn={jest.fn()} />);
const { findByRole } = render(<PrivacyDataScreen />);

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');
});
});
96 changes: 96 additions & 0 deletions app/__tests__/unit/profile-stack-navigator.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <Stack.Navigator>). 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 <Stack.Navigator> 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<string, unknown>) => {
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(<ProfileStackNavigator />);
expect(getByTestId('crisis-profile')).toBeTruthy();
});

it('passes the frozen crisis props: standard mode, right position, pinned testID', () => {
render(<ProfileStackNavigator />);
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(<ProfileStackNavigator />);
const props = mockCrisisProps.mock.calls[0][0];
(props.onNavigate as () => void)();
expect(mockNavigate).toHaveBeenCalledWith('CrisisResources');
});
});
Loading
Loading