From 7f0f8b5d1c84cde3633e2782ebd9fc82803b79d2 Mon Sep 17 00:00:00 2001 From: MP2EZ <182439403+MP2EZ@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:24:27 -0700 Subject: [PATCH 1/2] feat: FEAT-293 standalone-practice discoverability + propatheia content fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Narrowed from the original ACs after a 3-lens planning panel. AC1's "a Home entry and/or a loop variation slot" was a material fork, not a stylistic hedge: no variation slot exists (zero grep hits), adding one mutates a crisis- and philosopher-reviewed config, and `daily_loop` is false in .env.production — so that branch would have shipped invisible to every real user on merge day. Home entry only; the loop slot is deferred. WHAT SHIPS - Home: a fixed-height "Practices / Explore" row BELOW checkInSection, not a fifth CheckInCard. checkInSection is flex:1 and every card inside it is also flex:1 with no ScrollView, so a fifth card squeezes all of them. The row also survives FEAT-298 deleting the three time-of-day cards. - New PracticeLibrary root screen. The sorting drill is featured and named by principle (SPHERE SOVEREIGNTY / Control Sorting), carries the Enchiridion 1 framing and a "Read the full principle" link to Module 3 — the philosopher requires no context-free launch, since the drill's answer key encodes a counterintuitive doctrine that reads as arbitrary once Module 3's prose is out of view. Practices group under THEIR OWN principle: breathing and body scan are the Aware Presence limb and are never labelled Stoic; the reserve clause is hypexhairesis and stays with Sphere Sovereignty. - The launch switch moved out of PracticeTab into a pure `resolvePracticeRoute`, so Learn and the library launch through identical code rather than two switches that can drift. - SortingPractice's `scenarios` param is now optional, resolved by a wrapper that self-loads module content when the caller has none. This also repairs the pre-existing `/sorting` deep link, which could never have supplied an array. - No score, tally, streak or badge anywhere; the signed feedback copy and VIRTUE_CHECK_FEEDBACK are untouched. The featured citation is IMPORTED from PRACTICE_QUOTES rather than retyped — a hand-typed second copy is exactly how the citation drift FEAT-268 cleaned up would recur. CONTENT SAFETY FIX (philosopher-blocking, and live in Learn today) `medical-test-anxiety` marked "Your anxiety about an upcoming medical test result" as in-control, explaining the anxiety itself is up to you. That conflates propatheia — the involuntary first movement, which classical Stoicism holds is NOT up to us — with assent. Promoted to a primary surface without Module 3's prose it tells a user with elevated GAD-7 that their anxiety is their own doing, and flags "Not Quite" when they disagree. The card now sorts the RESPONSE, with the first wave of fear moved to notInControl. The philosopher's review of that rewrite then found the fix had made the rest of the deck inconsistent with its own new standard, so four more cards are corrected: `political-response` (its explanation contradicted its own notInControl list on the same screen), `traffic-cutoff`, `event-interpretation` (the highest-risk one — it stated the conflation as doctrine on the card most likely to be loaded with a real difficult event), and `vacation-weather` (listed an outcome, "finding enjoyment", as a controllable). Two avoidance-shaped inControl items on the medical card were also replaced. TESTS AC3 ("no regression to existing Learn-launched entry points") had NO mechanical pin — zero tests referenced PracticeTab. 13 new cases lock the resolver's output param-for-param to the original inline switch, including its default durations, plus: the drill's citation must resolve to Epictetus and not silently fall back to the breathing quote; every catalog entry must be filed under its own principle (a mislabel would propagate into the Insights engagement chart). One case added to RootCrisisButton.test.tsx pinning that PracticeLibrary keeps the overlay in standard mode — unknown routes fall through to standard, but safe-by- accident is not a contract. Verification: npm run precommit exit 0 (safety 108, clinical 106, unit 581, privacy 51); npm run test:accessibility 87 passed; lint baseline improved 557 -> 555. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) --- .../practiceNavigation.contract.test.ts | 190 ++++++++++ .../modules/module-3-sphere-sovereignty.json | 28 +- .../core/navigation/CleanRootNavigator.tsx | 39 +- .../__tests__/RootCrisisButton.test.tsx | 11 + .../features/home/screens/CleanHomeScreen.tsx | 37 ++ app/src/features/learn/tabs/PracticeTab.tsx | 69 +--- .../catalog/SortingPracticeRoute.tsx | 116 ++++++ .../practices/catalog/practiceNavigation.ts | 109 ++++++ .../practices/catalog/standalonePractices.ts | 64 ++++ .../screens/PracticeLibraryScreen.tsx | 345 ++++++++++++++++++ 10 files changed, 929 insertions(+), 79 deletions(-) create mode 100644 app/__tests__/unit/practices/practiceNavigation.contract.test.ts create mode 100644 app/src/features/practices/catalog/SortingPracticeRoute.tsx create mode 100644 app/src/features/practices/catalog/practiceNavigation.ts create mode 100644 app/src/features/practices/catalog/standalonePractices.ts create mode 100644 app/src/features/practices/screens/PracticeLibraryScreen.tsx diff --git a/app/__tests__/unit/practices/practiceNavigation.contract.test.ts b/app/__tests__/unit/practices/practiceNavigation.contract.test.ts new file mode 100644 index 00000000..0d66ed89 --- /dev/null +++ b/app/__tests__/unit/practices/practiceNavigation.contract.test.ts @@ -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 => + ({ + 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 = { + '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); + }); +}); diff --git a/app/assets/modules/module-3-sphere-sovereignty.json b/app/assets/modules/module-3-sphere-sovereignty.json index f817958c..e7f65716 100644 --- a/app/assets/modules/module-3-sphere-sovereignty.json +++ b/app/assets/modules/module-3-sphere-sovereignty.json @@ -70,7 +70,7 @@ "correctAnswer": "not-in-control", "explanation": "Other drivers' actions and the traffic situation are not in your control.", "inControl": [ - "Your emotional response (staying calm vs. getting angry)", + "How you handle the anger once it flares (steadying vs. escalating)", "How you drive in response (safely vs. recklessly)", "Your judgment about the situation", "Calling ahead to inform you'll be late", @@ -78,6 +78,7 @@ "Your breathing and stress management" ], "notInControl": [ + "The first flash of anger (it appears automatically)", "The other driver's behavior", "The traffic conditions", "Being late (it's already happening)", @@ -86,18 +87,18 @@ }, { "id": "medical-test-anxiety", - "text": "Your anxiety about an upcoming medical test result.", + "text": "How you respond to anxiety while waiting for a medical test result.", "correctAnswer": "in-control", - "explanation": "Your anxiety itself (your judgment/response) is in your control, though the test result is not.", + "explanation": "How you respond to the anxiety is in your control. The first wave of fear is not — it arrives before you can choose. Neither is the result itself; it was never yours to decide.", "inControl": [ - "Your anxiety (it's your judgment/mental response)", - "How you work with the anxiety (breathing, grounding)", + "How you work with the anxiety once you notice it (breathing, grounding)", "Your self-talk and interpretations", "Whether you seek support while waiting", - "Activities you choose to distract or soothe yourself", - "Your preparation for different possible outcomes" + "Activities you choose while you wait (steadying yourself, not avoiding)", + "Thinking through what you'd do next, whichever way it goes" ], "notInControl": [ + "The first wave of fear (it appears automatically)", "The medical test result itself", "When you receive the results", "The medical facts of your condition", @@ -155,7 +156,7 @@ "Your attitude about the situation", "Whether you bring rain gear", "How you make the best of circumstances", - "Finding enjoyment despite the rain" + "Where you put your attention despite the rain" ], "notInControl": [ "The actual weather", @@ -188,16 +189,17 @@ "id": "event-interpretation", "text": "Your interpretation of a difficult event that happened.", "correctAnswer": "in-control", - "explanation": "Your interpretations and judgments are in your control—this is a core Stoic teaching.", + "explanation": "Your interpretations and judgments are in your control — this is a core Stoic teaching. The feeling that arrives before you've interpreted anything is not.", "inControl": [ "The meaning you assign to the event", "Your perspective and framing", - "Your emotional response (based on interpretation)", + "How the feeling shifts once you revisit the interpretation", "The story you tell yourself about it", "Whether you reframe or reinterpret", "How you choose to think about it" ], "notInControl": [ + "The feeling that arrives before you've interpreted anything", "The event itself (it already happened)", "Others' interpretations of the event", "The objective facts of what occurred", @@ -269,9 +271,9 @@ }, { "id": "political-response", - "text": "Your emotional response when hearing those political beliefs.", + "text": "How you respond when you hear those political beliefs.", "correctAnswer": "in-control", - "explanation": "Your responses—emotional and behavioral—are within your control, though they may require practice.", + "explanation": "How you respond is in your control — the tone you take, whether you engage, what you make it mean. The first flash of feeling is not; it arrives before you can choose.", "inControl": [ "How you regulate your emotional reaction", "Your interpretation of their words", @@ -281,7 +283,7 @@ "How you choose to think about the situation" ], "notInControl": [ - "Your initial emotional flash (appears automatically)", + "The first flash of feeling (it appears automatically)", "Their words and delivery", "The content of their beliefs", "The political climate creating tension" diff --git a/app/src/core/navigation/CleanRootNavigator.tsx b/app/src/core/navigation/CleanRootNavigator.tsx index cf43e04c..add632fb 100644 --- a/app/src/core/navigation/CleanRootNavigator.tsx +++ b/app/src/core/navigation/CleanRootNavigator.tsx @@ -33,10 +33,12 @@ import PassageReaderScreen from '@/features/library/screens/PassageReaderScreen' import { PracticeTimerScreen, ReflectionTimerScreen, - SortingPracticeScreen, BodyScanScreen, GuidedBodyScanScreen } from '@/features/learn/practices'; +// FEAT-293: standalone practice discoverability. +import SortingPracticeRoute from '@/features/practices/catalog/SortingPracticeRoute'; +import PracticeLibraryScreen from '@/features/practices/screens/PracticeLibraryScreen'; import { useStoicPracticeStore } from '@/features/practices/stores/stoicPracticeStore'; import { useSettingsStore } from '@/core/stores/settingsStore'; import { useConsentStore } from '@/core/stores/consentStore'; @@ -76,8 +78,16 @@ export type RootStackParamList = { SortingPractice: { practiceId: string; moduleId: ModuleId; - scenarios: SortingScenario[]; + // FEAT-293: OPTIONAL. Learn still passes the already-loaded scenarios + // (unchanged); the standalone Practice Library omits them and the screen + // self-loads from module content. This also repairs the pre-existing + // `/sorting` deep link in linking.ts, which could never supply an array. + scenarios?: SortingScenario[]; }; + // FEAT-293: standalone practice discoverability. A listing surface, so it is + // deliberately absent from RootCrisisButton's SUPPRESSED_ROUTES and + // IMMERSIVE_ROUTES — it must resolve to the default `standard` crisis overlay. + PracticeLibrary: undefined; BodyScan: { practiceId: string; moduleId: ModuleId; @@ -371,6 +381,27 @@ const CleanRootNavigator: React.FC = () => { )} + {/* FEAT-293: standalone practice discoverability. `card` presentation, + NOT modal — it is a browsable listing surface, and it must keep the + root crisis overlay in its default `standard` mode (hence its + deliberate absence from RootCrisisButton's route sets). */} + + {({ navigation }) => ( + navigation.goBack()} + onOpenPractice={(screen, params) => + navigation.navigate(screen as never, params as never) + } + onOpenModule={(moduleId) => + navigation.navigate('ModuleDetail', { moduleId }) + } + /> + )} + + { }} > {({ navigation, route }) => ( - { expect(receivedProps[0]?.testID).toBe(ROOT_CRISIS_BUTTON_TEST_ID); }); + it('keeps the overlay in standard mode on the FEAT-293 PracticeLibrary route', () => { + // PracticeLibrary is a browsable LISTING surface, not a practice the user is + // immersed in, so it must resolve to the default `standard` overlay — and it + // must never end up in SUPPRESSED_ROUTES, which would make it a screen with + // zero 988 access. Unknown routes fall through to `standard`, which is the + // fail-safe direction, but "safe by accident" is not a contract; this pins it. + const { getByTestId } = render(); + expect(getByTestId(ROOT_CRISIS_BUTTON_TEST_ID)).toBeTruthy(); + expect(receivedProps[0]?.mode).toBe('standard'); + }); + it('renders in standard mode when route is undefined (pre-ready)', () => { const { getByTestId } = render(); expect(getByTestId(ROOT_CRISIS_BUTTON_TEST_ID)).toBeTruthy(); diff --git a/app/src/features/home/screens/CleanHomeScreen.tsx b/app/src/features/home/screens/CleanHomeScreen.tsx index 21f37b99..c32f69f9 100644 --- a/app/src/features/home/screens/CleanHomeScreen.tsx +++ b/app/src/features/home/screens/CleanHomeScreen.tsx @@ -260,6 +260,24 @@ const CleanHomeScreen: React.FC = () => { /> )} + + {/* FEAT-293: standalone-practice discoverability. + Deliberately a FIXED-HEIGHT row BELOW checkInSection, not a fifth + CheckInCard: checkInSection is flex:1 and every card inside it is + also flex:1 with no ScrollView, so an extra card would squeeze all + of them. A fixed row keeps the cards equal to each other and reflows + cleanly if the three time-of-day cards are later retired. */} + navigation.navigate('PracticeLibrary')} + accessibilityRole="button" + accessibilityLabel="Practices" + accessibilityHint="Opens breathing, body scan, and Stoic practices" + testID="home-practices-entry" + > + Practices + Explore › + {/* Intro Animation Overlay */} @@ -309,6 +327,25 @@ const styles = StyleSheet.create({ flex: 1, marginTop: spacing[12], }, + // FEAT-293: fixed height, so it never competes with the flex:1 check-in cards. + practicesEntry: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing[12], + marginTop: spacing[8], + borderTopWidth: 1, + borderTopColor: colorSystem.gray[200], + }, + practicesEntryLabel: { + fontSize: typography.bodyRegular.size, + fontWeight: typography.fontWeight.medium, + color: colorSystem.base.black, + }, + practicesEntryAction: { + fontSize: typography.bodySmall.size, + color: colorSystem.gray[600], + }, checkInCard: { flex: 1, justifyContent: 'space-between', diff --git a/app/src/features/learn/tabs/PracticeTab.tsx b/app/src/features/learn/tabs/PracticeTab.tsx index 8040b86a..f219fc18 100644 --- a/app/src/features/learn/tabs/PracticeTab.tsx +++ b/app/src/features/learn/tabs/PracticeTab.tsx @@ -23,6 +23,7 @@ import { colorSystem, spacing, typography, borderRadius } from '@/core/theme'; import { useEducationStore } from '../stores/educationStore'; import type { ModuleContent, ModuleId, Practice } from '@/features/learn/types/education'; import type { RootStackParamList } from '@/core/navigation/CleanRootNavigator'; +import { resolvePracticeRoute } from '@/features/practices/catalog/practiceNavigation'; type NavigationProp = StackNavigationProp; @@ -39,69 +40,11 @@ const PracticeTab: React.FC = ({ const { incrementPracticeCount } = useEducationStore(); const handlePracticePress = (practice: Practice) => { - // Navigate to the appropriate practice screen based on type - switch (practice.type) { - case 'guided-timer': - navigation.navigate('PracticeTimer', { - practiceId: practice.id, - moduleId, - duration: practice.duration ?? 180, // Default 3 minutes - title: practice.title, - }); - break; - - case 'sorting': - // Sorting practice uses scenarios from the practice object - if (practice.scenarios && practice.scenarios.length > 0) { - navigation.navigate('SortingPractice', { - practiceId: practice.id, - moduleId, - scenarios: practice.scenarios, - }); - } else { - console.warn(`Sorting practice ${practice.id} has no scenarios`); - } - break; - - case 'body-scan': - navigation.navigate('BodyScan', { - practiceId: practice.id, - moduleId, - duration: practice.duration ?? 300, // Default 5 minutes - }); - break; - - case 'reflection': - // Reflection exercises - contemplation without breathing circle - navigation.navigate('ReflectionTimer', { - practiceId: practice.id, - moduleId, - duration: practice.duration ?? 300, // Default 5 minutes - title: practice.title, - prompt: practice.description, // Use description as reflection prompt - ...(practice.instructions && { instructions: practice.instructions }), // Full instruction steps - }); - break; - - case 'guided-body-scan': - // Self-paced resistance/tension body check - navigation.navigate('GuidedBodyScan', { - practiceId: practice.id, - moduleId, - title: practice.title, - }); - break; - - default: - console.warn(`Unknown practice type: ${practice.type}`); - // Fallback to timer screen - navigation.navigate('PracticeTimer', { - practiceId: practice.id, - moduleId, - duration: practice.duration ?? 180, - title: practice.title, - }); - } + // FEAT-293: the launch switch moved to the shared resolver so that Learn and + // the standalone Practice Library open practices through identical code. + // Behaviour here is unchanged — pinned by practiceNavigation.contract.test.ts. + const { screen, params } = resolvePracticeRoute(practice, moduleId); + navigation.navigate(screen, params as never); }; const formatDuration = (seconds: number): string => { diff --git a/app/src/features/practices/catalog/SortingPracticeRoute.tsx b/app/src/features/practices/catalog/SortingPracticeRoute.tsx new file mode 100644 index 00000000..b2888067 --- /dev/null +++ b/app/src/features/practices/catalog/SortingPracticeRoute.tsx @@ -0,0 +1,116 @@ +/** + * SORTING PRACTICE ROUTE — scenario-resolving wrapper (FEAT-293) + * + * `SortingPracticeScreen` requires a fully-loaded `scenarios` array and throws + * if one is missing at the current index. That was fine while Learn was the only + * entry point, because ModuleDetail has already loaded module content by the + * time it launches a practice. It is NOT fine for the standalone Practice + * Library (or for the `/sorting` deep link in linking.ts, which has always been + * broken for exactly this reason — a URL cannot carry a scenarios array). + * + * This wrapper resolves scenarios before rendering: it passes them straight + * through when the caller already has them (Learn — unchanged behaviour, no + * extra load, no flash of a spinner), and otherwise loads module content itself. + * Keeping the resolution OUT of SortingPracticeScreen leaves that screen's + * philosopher-signed feedback copy and hook order untouched. + */ + +import React, { useEffect, useState } from 'react'; +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import { colorSystem, spacing, typography } from '@/core/theme'; +import SortingPracticeScreen from '@/features/learn/practices/SortingPracticeScreen'; +import { loadModuleContent } from '@/core/services/moduleContent'; +import type { ModuleId, SortingScenario } from '@/features/learn/types/education'; + +interface SortingPracticeRouteProps { + practiceId: string; + moduleId: ModuleId; + /** Supplied by Learn; omitted by the Practice Library and by deep links. */ + scenarios?: SortingScenario[] | undefined; + onComplete?: (() => void) | undefined; + onBack?: (() => void) | undefined; +} + +const SortingPracticeRoute: React.FC = ({ + practiceId, + moduleId, + scenarios, + onComplete, + onBack, +}) => { + const alreadyResolved = Boolean(scenarios?.length); + const [resolved, setResolved] = useState( + alreadyResolved ? (scenarios as SortingScenario[]) : null + ); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (alreadyResolved) return; + + let cancelled = false; + (async () => { + try { + const content = await loadModuleContent(moduleId); + const practice = content.practices.find((p) => p.id === practiceId); + const loaded = practice?.scenarios ?? []; + if (cancelled) return; + if (loaded.length === 0) { + setFailed(true); + return; + } + setResolved(loaded); + } catch { + if (!cancelled) setFailed(true); + } + })(); + + return () => { + cancelled = true; + }; + }, [alreadyResolved, moduleId, practiceId]); + + if (failed) { + return ( + + + This practice couldn’t be loaded right now. + + + ); + } + + if (!resolved) { + return ( + + + + ); + } + + return ( + + ); +}; + +const styles = StyleSheet.create({ + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colorSystem.base.white, + padding: spacing[24], + }, + message: { + fontSize: typography.bodyRegular.size, + color: colorSystem.gray[600], + textAlign: 'center', + }, +}); + +export default SortingPracticeRoute; diff --git a/app/src/features/practices/catalog/practiceNavigation.ts b/app/src/features/practices/catalog/practiceNavigation.ts new file mode 100644 index 00000000..4fb4c20f --- /dev/null +++ b/app/src/features/practices/catalog/practiceNavigation.ts @@ -0,0 +1,109 @@ +/** + * PRACTICE NAVIGATION — shared launch resolver (FEAT-293) + * + * A single source of truth for "which screen does this practice open, with what + * params". Extracted from the switch that used to live inline in + * `features/learn/tabs/PracticeTab.tsx` so that Learn and the standalone + * Practice Library launch through IDENTICAL code — FEAT-293's AC3 requires no + * regression to the Learn-launched entry points, and two parallel switches is + * exactly how that regression would arrive. + * + * Deliberately PURE: it returns a route descriptor rather than calling + * `navigation.navigate` itself. That is what makes the Learn contract testable + * without mounting a navigator — before FEAT-293 there were zero tests + * referencing PracticeTab, so AC3 had no mechanical pin at all. + */ + +import type { ModuleId, Practice } from '@/features/learn/types/education'; +import type { RootStackParamList } from '@/core/navigation/CleanRootNavigator'; + +/** Default durations, preserved verbatim from the original PracticeTab switch. */ +const DEFAULT_TIMER_SECONDS = 180; +const DEFAULT_BODY_SCAN_SECONDS = 300; +const DEFAULT_REFLECTION_SECONDS = 300; + +export type PracticeRoute = { + [K in keyof RootStackParamList]: { screen: K; params: RootStackParamList[K] }; +}[ + | 'PracticeTimer' + | 'ReflectionTimer' + | 'SortingPractice' + | 'BodyScan' + | 'GuidedBodyScan' +]; + +/** + * Resolve the screen + params a practice should launch with. + * + * Never returns null: an unrecognised type falls back to the guided timer, + * matching the original switch's `default` branch. Keeping the fallback means a + * new practice type authored in module JSON degrades to a usable screen rather + * than a dead button. + */ +export function resolvePracticeRoute( + practice: Practice, + moduleId: ModuleId +): PracticeRoute { + switch (practice.type) { + case 'sorting': + // FEAT-293: `scenarios` is now OPTIONAL on the route. When Learn launches + // this it already holds the loaded module content and passes them through + // (unchanged behaviour); when the standalone library launches it, they are + // omitted and SortingPracticeScreen self-loads from module content. That + // also repairs the pre-existing `/sorting` deep link, which could never + // have supplied a scenarios array. + return { + screen: 'SortingPractice', + params: { + practiceId: practice.id, + moduleId, + ...(practice.scenarios?.length ? { scenarios: practice.scenarios } : {}), + }, + }; + + case 'body-scan': + return { + screen: 'BodyScan', + params: { + practiceId: practice.id, + moduleId, + duration: practice.duration ?? DEFAULT_BODY_SCAN_SECONDS, + }, + }; + + case 'reflection': + return { + screen: 'ReflectionTimer', + params: { + practiceId: practice.id, + moduleId, + duration: practice.duration ?? DEFAULT_REFLECTION_SECONDS, + title: practice.title, + prompt: practice.description, + ...(practice.instructions && { instructions: practice.instructions }), + }, + }; + + case 'guided-body-scan': + return { + screen: 'GuidedBodyScan', + params: { + practiceId: practice.id, + moduleId, + title: practice.title, + }, + }; + + case 'guided-timer': + default: + return { + screen: 'PracticeTimer', + params: { + practiceId: practice.id, + moduleId, + duration: practice.duration ?? DEFAULT_TIMER_SECONDS, + title: practice.title, + }, + }; + } +} diff --git a/app/src/features/practices/catalog/standalonePractices.ts b/app/src/features/practices/catalog/standalonePractices.ts new file mode 100644 index 00000000..d7f1f598 --- /dev/null +++ b/app/src/features/practices/catalog/standalonePractices.ts @@ -0,0 +1,64 @@ +/** + * STANDALONE PRACTICE CATALOG (FEAT-293) + * + * The curated set of practices promoted out of Learn onto a primary surface. + * + * Stores ONLY stable identifiers — no titles, durations, or prose. Everything + * displayable is read from the authored module JSON at render time, so this file + * cannot drift from the content the way a duplicated title or duration would. + * + * Curation is deliberately small. Twelve practices exist across the five + * modules, six of them generic reflection timers; surfacing all of them would + * make the library a list rather than an invitation. The set below covers the + * four the user story names — breathing, body scan, reflection, and especially + * the Stoic sorting drill. + * + * PRINCIPLE ATTRIBUTION IS LOAD-BEARING, not cosmetic. Each entry declares the + * principle it actually belongs to, and the library groups by that. Breathing + * and the body scan are the Aware Presence limb (embodied/metacognitive + * awareness) and must never be presented as classical Stoic technique. The + * Reserve Clause is hypexhairesis and belongs to Sphere Sovereignty — filing it + * under Aware Presence to get a tidier screen would be a mis-attribution. + */ + +import type { ModuleId } from '@/features/learn/types/education'; +import type { StoicPrinciple } from '@/features/practices/types/stoic'; + +export interface StandalonePracticeRef { + /** Drives the section heading; must be the practice's OWN principle. */ + principleKey: StoicPrinciple; + moduleId: ModuleId; + practiceId: string; +} + +/** + * The dichotomy-of-control drill, promoted to a first-class Stoic practice + * (FEAT-293 AC2). Rendered as the featured card, not as a list row. + */ +export const FEATURED_PRACTICE: StandalonePracticeRef = { + principleKey: 'sphere_sovereignty', + moduleId: 'sphere-sovereignty', + practiceId: 'control-sorting', +}; + +export const STANDALONE_PRACTICES: readonly StandalonePracticeRef[] = [ + FEATURED_PRACTICE, + // Sphere Sovereignty — the reserve clause is hypexhairesis ("fate + // permitting"), releasing attachment to outcome. Stays with its own principle. + { + principleKey: 'sphere_sovereignty', + moduleId: 'sphere-sovereignty', + practiceId: 'reserve-clause', + }, + // Aware Presence — the mindfulness limb. NOT classical Stoic exercises. + { + principleKey: 'aware_presence', + moduleId: 'aware-presence', + practiceId: 'breathing-space', + }, + { + principleKey: 'aware_presence', + moduleId: 'aware-presence', + practiceId: 'body-scan', + }, +] as const; diff --git a/app/src/features/practices/screens/PracticeLibraryScreen.tsx b/app/src/features/practices/screens/PracticeLibraryScreen.tsx new file mode 100644 index 00000000..7f59e1e9 --- /dev/null +++ b/app/src/features/practices/screens/PracticeLibraryScreen.tsx @@ -0,0 +1,345 @@ +/** + * PRACTICE LIBRARY — standalone practice discoverability (FEAT-293) + * + * The standalone practices were reachable only as modals launched from deep + * inside Learn (Learn > ModuleDetail > Practice tab > launch). The Stoic + * control-sorting drill in particular is a differentiated asset that no user was + * finding. This screen is the primary surface that fixes that. + * + * PHILOSOPHER CONSTRAINTS encoded here (non-negotiable): + * - Practices are named and grouped BY PRINCIPLE, never by mechanic. Each + * section heading and every line of principle prose is read from PRINCIPLES + * (byte-parity pinned by principles.test.ts) — no parallel copy is authored + * in this file. + * - The sorting drill may not launch context-free. Its card carries the + * Enchiridion 1 framing and a visible link back to the full principle, + * because the drill's answer key encodes a counterintuitive doctrine that + * reads as arbitrary once Module 3's prose is out of view. + * - Practices are grouped under THEIR OWN principle. Breathing and body scan + * are the Aware Presence limb; the Reserve Clause is hypexhairesis and + * belongs to Sphere Sovereignty. Filing the latter under "Aware Presence" + * would be a mis-attribution, so the grouping is derived, not hardcoded to a + * single heading. + * - NO score, percentage, tally, streak, or badge anywhere. These are + * discernment exercises; a scoreboard would convert them into performance. + * Note there is deliberately no practice-count display on this screen. + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { colorSystem, spacing, typography, borderRadius } from '@/core/theme'; +import { PRINCIPLES } from '@/features/practices/shared/constants/principles'; +import { loadModuleContent } from '@/core/services/moduleContent'; +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'; +import type { StoicPrinciple } from '@/features/practices/types/stoic'; + +/** + * The framing line on the featured card. Single-sourced from the same constant + * the completion screen uses, so the drill's citation cannot drift between the + * surface that launches it and the surface that closes it. Pinned by + * practiceLibrary.contract.test.ts. + */ +const FRAMING_QUOTE = PRACTICE_QUOTES[FEATURED_PRACTICE.practiceId]; + +interface PracticeLibraryScreenProps { + onBack: () => void; + onOpenPractice: (screen: string, params: unknown) => void; + onOpenModule: (moduleId: ModuleId) => void; + testID?: string; +} + +/** A resolved catalog entry: the curated reference plus its loaded content. */ +interface ResolvedEntry { + principleKey: StoicPrinciple; + moduleId: ModuleId; + practice: Practice; +} + +const formatDuration = (seconds?: number | null): string | null => + seconds ? `${Math.round(seconds / 60)} min` : null; + +const PracticeLibraryScreen: React.FC = ({ + onBack, + onOpenPractice, + onOpenModule, + testID = 'practice-library-screen', +}) => { + const [entries, setEntries] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + // The catalog stores only stable identifiers, so titles and durations are + // read from module content rather than duplicated here (loadModuleContent + // caches, so this is cheap and cannot drift from the authored content). + const moduleIds = Array.from( + new Set(STANDALONE_PRACTICES.map((p) => p.moduleId)) + ); + const contents = await Promise.all( + moduleIds.map(async (id) => { + try { + return [id, await loadModuleContent(id)] as const; + } catch { + return [id, null] as const; + } + }) + ); + if (cancelled) return; + + const byModule = new Map(contents); + const resolved: ResolvedEntry[] = []; + for (const ref of STANDALONE_PRACTICES) { + const content = byModule.get(ref.moduleId); + const practice = content?.practices.find((p) => p.id === ref.practiceId); + if (practice) { + resolved.push({ ...ref, practice }); + } + } + setEntries(resolved); + })(); + return () => { + cancelled = true; + }; + }, []); + + /** Group by principle, preserving the canonical PRINCIPLES order. */ + const sections = useMemo(() => { + if (!entries) return []; + return PRINCIPLES.map((principle) => ({ + principle, + items: entries.filter( + (e) => + e.principleKey === principle.key && + e.practice.id !== FEATURED_PRACTICE.practiceId + ), + })).filter((s) => s.items.length > 0); + }, [entries]); + + const featured = useMemo( + () => entries?.find((e) => e.practice.id === FEATURED_PRACTICE.practiceId), + [entries] + ); + + const featuredPrinciple = PRINCIPLES.find( + (p) => p.key === FEATURED_PRACTICE.principleKey + ); + + const launch = (entry: ResolvedEntry) => { + const { screen, params } = resolvePracticeRoute(entry.practice, entry.moduleId); + onOpenPractice(screen, params); + }; + + return ( + + + + ‹ Back + + Practices + + + + {!entries ? ( + + + + ) : ( + + {/* FEATURED — the sorting drill, promoted to a first-class Stoic + practice, carrying its principle framing and citation. */} + {featured && featuredPrinciple && FRAMING_QUOTE && ( + + + {featuredPrinciple.title.toUpperCase()} + + {featured.practice.title} + + {/* Enchiridion 1 framing. IMPORTED, never retyped: this citation + already exists byte-identical as PRACTICE_QUOTES['control-sorting'], + and a hand-typed second copy is precisely how the citation drift + that FEAT-268 had to clean up recurs. */} + + “{FRAMING_QUOTE.text}” + + {FRAMING_QUOTE.author}, {FRAMING_QUOTE.source} + + + + onOpenModule(featured.moduleId)} + accessibilityRole="link" + accessibilityLabel={`Read the full principle: ${featuredPrinciple.title}`} + accessibilityHint="Opens Module 3, Sphere Sovereignty" + testID="practice-library-principle-link" + > + + Read the full principle: {featuredPrinciple.title} › + + + + launch(featured)} + accessibilityRole="button" + accessibilityLabel={`Begin ${featured.practice.title}`} + testID="practice-library-featured-start" + > + Begin + + + )} + + {sections.map(({ principle, items }) => ( + + + {principle.title.toUpperCase()} + + {items.map((entry) => { + const duration = formatDuration(entry.practice.duration); + return ( + launch(entry)} + accessibilityRole="button" + accessibilityLabel={ + duration + ? `${entry.practice.title}, ${duration}` + : entry.practice.title + } + testID={`practice-library-item-${entry.practice.id}`} + > + + {entry.practice.title} + + {duration && ( + {duration} + )} + + ); + })} + + ))} + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colorSystem.base.white }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing[16], + paddingVertical: spacing[16], + }, + backButton: { minWidth: spacing[64] }, + backText: { + fontSize: typography.bodyRegular.size, + color: colorSystem.navigation.learn, + }, + headerTitle: { + fontSize: typography.bodyLarge.size, + fontWeight: typography.fontWeight.semibold, + color: colorSystem.base.black, + }, + centered: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + scrollContent: { paddingHorizontal: spacing[24], paddingTop: spacing[8] }, + featuredCard: { + borderWidth: 1.5, + borderColor: colorSystem.navigation.learn, + borderRadius: borderRadius.xl, + padding: spacing[24], + gap: spacing[8], + marginBottom: spacing[32], + }, + principleEyebrow: { + fontSize: typography.bodySmall.size, + fontWeight: typography.fontWeight.semibold, + color: colorSystem.navigation.learn, + letterSpacing: 1, + marginBottom: spacing[4], + }, + featuredTitle: { + fontSize: typography.headline4.size, + fontWeight: typography.fontWeight.semibold, + color: colorSystem.base.black, + }, + featuredFraming: { + fontSize: typography.bodyRegular.size, + color: colorSystem.gray[700], + lineHeight: 22, + }, + citation: { + fontSize: typography.bodySmall.size, + fontStyle: 'italic', + color: colorSystem.gray[600], + }, + principleLink: { + fontSize: typography.bodySmall.size, + fontWeight: typography.fontWeight.medium, + color: colorSystem.navigation.learn, + marginTop: spacing[4], + }, + featuredButton: { + marginTop: spacing[16], + backgroundColor: colorSystem.navigation.learn, + borderRadius: borderRadius.large, + paddingVertical: spacing[16], + alignItems: 'center', + }, + featuredButtonText: { + fontSize: typography.bodyRegular.size, + fontWeight: typography.fontWeight.semibold, + color: colorSystem.base.white, + }, + section: { marginBottom: spacing[32] }, + practiceRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing[16], + borderBottomWidth: 1, + borderBottomColor: colorSystem.gray[200], + }, + practiceRowTitle: { + flex: 1, + fontSize: typography.bodyRegular.size, + color: colorSystem.base.black, + }, + practiceRowMeta: { + fontSize: typography.bodySmall.size, + color: colorSystem.gray[500], + }, +}); + +export default PracticeLibraryScreen; From 74f48b3e969844ace6a1984ed6a040b905bba33d Mon Sep 17 00:00:00 2001 From: MP2EZ <182439403+MP2EZ@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:36:13 -0700 Subject: [PATCH 2/2] test: FEAT-293 render test for SortingPracticeRoute (Learn-path regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SortingPracticeRoute is now the rendered component for the pre-existing `SortingPractice` route, so a bug in it breaks the Learn-launched sorting practice that shipped long before FEAT-293. That risk is invisible to the Maestro safety gate: crisis-button-reachability deliberately does not walk practice flows (they need the long check-in preamble), so no on-device gate exercises this path. The unit tests added with the feature covered the pure route resolver, not the wrapper's rendering. Six cases, the load-bearing one being the Learn path: when the caller already holds the scenarios the wrapper must pass them straight through with NO module load and NO spinner frame — same render as before FEAT-293. Plus the standalone path (loads, then renders), and the three degradation paths that must never reach SortingPracticeScreen, which throws on a missing scenario at the current index: empty array, unknown practice id, and a rejected load. Also pins that a load resolving after unmount does not set state. Verification: npm run precommit exit 0 — safety 108, clinical 106, unit 587 (+6), privacy 51. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) --- .../practices/sortingPracticeRoute.test.tsx | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 app/__tests__/unit/practices/sortingPracticeRoute.test.tsx diff --git a/app/__tests__/unit/practices/sortingPracticeRoute.test.tsx b/app/__tests__/unit/practices/sortingPracticeRoute.test.tsx new file mode 100644 index 00000000..0ce6272d --- /dev/null +++ b/app/__tests__/unit/practices/sortingPracticeRoute.test.tsx @@ -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 }> }) => ( + {scenarios.map((s) => s.id).join(',')} + ), + }; +}); + +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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + unmount(); + resolve(moduleWith([{ id: 'late' }])); + await waitFor(() => expect(mockLoadModuleContent).toHaveBeenCalled()); + + expect(errorSpy).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); +});