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
190 changes: 190 additions & 0 deletions app/__tests__/unit/practices/practiceNavigation.contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* FEAT-293 CONTRACT — standalone practice discoverability.
*
* AC3 of FEAT-293 is "no regression to existing Learn-launched entry points",
* and before this work item that AC had NO mechanical pin whatsoever: zero tests
* in the repo referenced PracticeTab or the launch switch it owned. Promoting
* the practices to a second entry point without pinning the first is exactly how
* the regression would have arrived unnoticed.
*
* The launch switch is now the pure `resolvePracticeRoute`, shared by Learn and
* the new Practice Library. These cases lock its output to what the original
* inline switch produced, param for param — including the default durations,
* which were previously magic numbers inside PracticeTab.
*
* Also pins two philosopher constraints that would otherwise degrade silently:
* the drill's citation must resolve to Epictetus (not fall back to the breathing
* quote), and every catalog entry must be filed under its OWN principle.
*/

import { resolvePracticeRoute } from '@/features/practices/catalog/practiceNavigation';
import {
STANDALONE_PRACTICES,
FEATURED_PRACTICE,
} from '@/features/practices/catalog/standalonePractices';
import { PRACTICE_QUOTES } from '@/features/learn/practices/PracticeCompletionScreen';
import type { ModuleId, Practice } from '@/features/learn/types/education';

const MODULE: ModuleId = 'sphere-sovereignty';

const practice = (over: Partial<Practice>): Practice =>
({
id: 'p1',
title: 'A Practice',
description: 'A description',
type: 'guided-timer',
...over,
}) as Practice;

describe('FEAT-293 — Learn launch contract (AC3 regression pin)', () => {
it('guided-timer → PracticeTimer, preserving the 180s default', () => {
const { screen, params } = resolvePracticeRoute(
practice({ id: 'breathing-space', type: 'guided-timer', title: 'Breathing' }),
MODULE
);
expect(screen).toBe('PracticeTimer');
expect(params).toEqual({
practiceId: 'breathing-space',
moduleId: MODULE,
duration: 180,
title: 'Breathing',
});
});

it('body-scan → BodyScan, preserving the 300s default', () => {
const { screen, params } = resolvePracticeRoute(
practice({ id: 'body-scan', type: 'body-scan' }),
MODULE
);
expect(screen).toBe('BodyScan');
expect(params).toMatchObject({ duration: 300 });
});

it('reflection → ReflectionTimer, mapping description→prompt and passing instructions', () => {
const { screen, params } = resolvePracticeRoute(
practice({
id: 'reserve-clause',
type: 'reflection',
description: 'Reflect on this',
instructions: ['one', 'two'],
duration: 240,
}),
MODULE
);
expect(screen).toBe('ReflectionTimer');
expect(params).toMatchObject({
prompt: 'Reflect on this',
instructions: ['one', 'two'],
duration: 240,
});
});

it('omits `instructions` entirely when the practice has none', () => {
// exactOptionalPropertyTypes is on; passing `instructions: undefined` is a
// different thing from omitting it, and the original switch omitted it.
const { params } = resolvePracticeRoute(
practice({ type: 'reflection', description: 'x' }),
MODULE
);
expect(params).not.toHaveProperty('instructions');
});

it('guided-body-scan → GuidedBodyScan', () => {
const { screen } = resolvePracticeRoute(
practice({ type: 'guided-body-scan' }),
MODULE
);
expect(screen).toBe('GuidedBodyScan');
});

it('an unknown type still falls back to the timer, as the original switch did', () => {
const { screen, params } = resolvePracticeRoute(
practice({ type: 'not-a-real-type' as Practice['type'] }),
MODULE
);
expect(screen).toBe('PracticeTimer');
expect(params).toMatchObject({ duration: 180 });
});

it('honours an authored duration over the default', () => {
const { params } = resolvePracticeRoute(
practice({ type: 'guided-timer', duration: 480 }),
MODULE
);
expect(params).toMatchObject({ duration: 480 });
});
});

describe('FEAT-293 — sorting launches from BOTH entry points', () => {
it('passes Learn’s already-loaded scenarios straight through', () => {
const scenarios = [{ id: 's1' }, { id: 's2' }] as never;
const { screen, params } = resolvePracticeRoute(
practice({ id: 'control-sorting', type: 'sorting', scenarios }),
MODULE
);
expect(screen).toBe('SortingPractice');
expect(params).toMatchObject({ scenarios });
});

it('OMITS scenarios when the caller has none, so the screen self-loads', () => {
// The library and the /sorting deep link cannot supply an array. Before
// FEAT-293 the switch bailed out with a console.warn and navigated nowhere,
// which is why that deep link never worked.
const { screen, params } = resolvePracticeRoute(
practice({ id: 'control-sorting', type: 'sorting' }),
MODULE
);
expect(screen).toBe('SortingPractice');
expect(params).not.toHaveProperty('scenarios');
expect(params).toMatchObject({ practiceId: 'control-sorting', moduleId: MODULE });
});

it('treats an empty scenarios array as "not supplied"', () => {
const { params } = resolvePracticeRoute(
practice({ type: 'sorting', scenarios: [] as never }),
MODULE
);
expect(params).not.toHaveProperty('scenarios');
});
});

describe('FEAT-293 — philosopher constraints on the promoted surface', () => {
it('the drill’s citation resolves to Epictetus, NOT the breathing fallback', () => {
// usePracticeCompletion falls back to PRACTICE_QUOTES['breathing-space'] for
// an unknown practiceId. On a Stoic drill that silent fallback would be a
// citation error, so it must fail here rather than degrade quietly.
const quote = PRACTICE_QUOTES[FEATURED_PRACTICE.practiceId];
expect(quote).toBeDefined();
expect(quote?.author).toBe('Epictetus');
expect(quote?.source).toBe('Enchiridion 1');
expect(quote).not.toEqual(PRACTICE_QUOTES['breathing-space']);
});

it('files every catalog entry under its OWN principle', () => {
// Breathing and body scan are the Aware Presence limb and must never be
// presented as classical Stoic technique; the reserve clause is
// hypexhairesis and belongs to Sphere Sovereignty. Filing a practice under a
// principle it does not belong to would assert a false principle→practice
// mapping on a surface users read as canonical — and would propagate into
// the Insights principle-engagement chart.
const PRINCIPLE_FOR_MODULE: Record<string, string> = {
'aware-presence': 'aware_presence',
'radical-acceptance': 'radical_acceptance',
'sphere-sovereignty': 'sphere_sovereignty',
'virtuous-response': 'virtuous_response',
'interconnected-living': 'interconnected_living',
};

for (const ref of STANDALONE_PRACTICES) {
expect(ref.principleKey).toBe(PRINCIPLE_FOR_MODULE[ref.moduleId]);
}
});

it('features the sorting drill, and features it exactly once', () => {
expect(FEATURED_PRACTICE.practiceId).toBe('control-sorting');
const occurrences = STANDALONE_PRACTICES.filter(
(p) => p.practiceId === FEATURED_PRACTICE.practiceId
);
expect(occurrences).toHaveLength(1);
});
});
133 changes: 133 additions & 0 deletions app/__tests__/unit/practices/sortingPracticeRoute.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* FEAT-293 — SortingPracticeRoute (scenario-resolving wrapper).
*
* This wrapper is now the rendered component for the EXISTING `SortingPractice`
* route, so a bug here breaks the Learn-launched sorting practice that shipped
* long before FEAT-293. That risk is invisible to the Maestro safety suite:
* crisis-button-reachability.yaml deliberately does not walk practice flows
* (they need the long check-in preamble), so no on-device gate exercises this
* path. It has to be pinned here.
*
* The central guarantee is the Learn path: when the caller already holds the
* scenarios, the wrapper must pass them straight through with NO module load and
* NO loading state — same render, same frame, as before FEAT-293.
*/

import React from 'react';
import { render, waitFor } from '@testing-library/react-native';
import { Text } from 'react-native';

const mockLoadModuleContent = jest.fn();
jest.mock('@/core/services/moduleContent', () => ({
loadModuleContent: (...args: unknown[]) => mockLoadModuleContent(...args),
}));

// Stub the practice screen: this suite is about the wrapper's resolution logic,
// not the drill's rendering (which has its own philosopher-signed copy).
jest.mock('@/features/learn/practices/SortingPracticeScreen', () => {
const { Text: RNText } = require('react-native');
return {
__esModule: true,
default: ({ scenarios }: { scenarios: Array<{ id: string }> }) => (
<RNText testID="sorting-screen">{scenarios.map((s) => s.id).join(',')}</RNText>
),
};
});

import SortingPracticeRoute from '@/features/practices/catalog/SortingPracticeRoute';

const SCENARIOS = [{ id: 'a' }, { id: 'b' }] as never;

const moduleWith = (scenarios: unknown) => ({
practices: [{ id: 'control-sorting', type: 'sorting', scenarios }],
});

beforeEach(() => {
mockLoadModuleContent.mockReset();
});

describe('FEAT-293 — Learn path (scenarios supplied) must not regress', () => {
it('renders the drill immediately and never loads module content', () => {
const { getByTestId, queryByTestId } = render(
<SortingPracticeRoute
practiceId="control-sorting"
moduleId="sphere-sovereignty"
scenarios={SCENARIOS}
/>
);

expect(getByTestId('sorting-screen').props.children).toBe('a,b');
// No spinner frame: Learn's launch must look exactly as it did before.
expect(queryByTestId('sorting-practice-loading')).toBeNull();
expect(mockLoadModuleContent).not.toHaveBeenCalled();
});
});

describe('FEAT-293 — standalone path (scenarios omitted) self-loads', () => {
it('shows a loading state, then renders the loaded scenarios', async () => {
mockLoadModuleContent.mockResolvedValue(moduleWith([{ id: 'x' }, { id: 'y' }]));

const { getByTestId } = render(
<SortingPracticeRoute practiceId="control-sorting" moduleId="sphere-sovereignty" />
);

expect(getByTestId('sorting-practice-loading')).toBeTruthy();
await waitFor(() =>
expect(getByTestId('sorting-screen').props.children).toBe('x,y')
);
expect(mockLoadModuleContent).toHaveBeenCalledWith('sphere-sovereignty');
});

it('treats an empty scenarios array as unavailable rather than rendering an empty drill', async () => {
// SortingPracticeScreen throws on a missing scenario at the current index,
// so an empty array must never reach it.
mockLoadModuleContent.mockResolvedValue(moduleWith([]));

const { getByTestId } = render(
<SortingPracticeRoute practiceId="control-sorting" moduleId="sphere-sovereignty" />
);

await waitFor(() => expect(getByTestId('sorting-practice-unavailable')).toBeTruthy());
});

it('degrades to an unavailable message when the practice id is not in the module', async () => {
mockLoadModuleContent.mockResolvedValue({ practices: [] });

const { getByTestId } = render(
<SortingPracticeRoute practiceId="nope" moduleId="sphere-sovereignty" />
);

await waitFor(() => expect(getByTestId('sorting-practice-unavailable')).toBeTruthy());
});

it('degrades to an unavailable message when the load rejects — never an unhandled rejection', async () => {
mockLoadModuleContent.mockRejectedValue(new Error('disk on fire'));

const { getByTestId } = render(
<SortingPracticeRoute practiceId="control-sorting" moduleId="sphere-sovereignty" />
);

await waitFor(() => expect(getByTestId('sorting-practice-unavailable')).toBeTruthy());
});

it('does not set state after unmount when the load resolves late', async () => {
// Dismissing the modal mid-load must not warn or throw.
let resolve!: (v: unknown) => void;
mockLoadModuleContent.mockReturnValue(
new Promise((r) => {
resolve = r;
})
);
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});

const { unmount } = render(
<SortingPracticeRoute practiceId="control-sorting" moduleId="sphere-sovereignty" />
);
unmount();
resolve(moduleWith([{ id: 'late' }]));
await waitFor(() => expect(mockLoadModuleContent).toHaveBeenCalled());

expect(errorSpy).not.toHaveBeenCalled();
errorSpy.mockRestore();
});
});
Loading
Loading