From c2e71b95fcd8b18f0073dfa6595fdbaeb328db48 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 3 Jul 2026 11:37:59 +0200 Subject: [PATCH 1/4] feat(formplayer): add locale utils + tests --- formulus-formplayer/src/App.tsx | 23 ++++++++++++++----- .../src/i18n/applyFormUiTranslations.test.ts | 10 ++++++++ .../src/i18n/applyFormUiTranslations.ts | 2 ++ .../src/i18n/formLocaleUtils.test.ts | 22 ++++++++++++++++++ .../src/i18n/formLocaleUtils.ts | 21 +++++++++++++++++ .../src/types/FormulusInterfaceDefinition.ts | 2 +- .../src/utils/formObservationData.test.ts | 3 +++ .../src/utils/formObservationData.ts | 8 ++++--- 8 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 formulus-formplayer/src/i18n/formLocaleUtils.test.ts create mode 100644 formulus-formplayer/src/i18n/formLocaleUtils.ts diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index 14eaa1b66..140894fd1 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -51,6 +51,10 @@ import { } from './utils/stickyFieldHelpers'; import { stickyService } from './services/StickyService'; import { applyFormUiTranslations } from './i18n/applyFormUiTranslations'; +import { + resolveEffectiveFormLocale, + stampFormLocaleOnObservationData, +} from './i18n/formLocaleUtils'; import { LinkedFormSpecsMap } from './utils/controlDisplayText'; import { createOdeI18n } from './i18n/createOdeI18n'; import { odeT } from './i18n/createOdeI18n'; @@ -338,7 +342,7 @@ interface FormContextType { function prepareLinkedFormSpecs( raw: FormInitData['linkedFormSpecs'], - locale: string, + formLocale: string, ): LinkedFormSpecsMap { if (!raw || typeof raw !== 'object') return {}; const out: LinkedFormSpecsMap = {}; @@ -349,7 +353,7 @@ function prepareLinkedFormSpecs( if (!schema || !uiRaw) continue; out[id] = { schema, - uiSchema: applyFormUiTranslations(uiRaw, locale) as UISchemaElement, + uiSchema: applyFormUiTranslations(uiRaw, formLocale) as UISchemaElement, }; } return out; @@ -512,9 +516,10 @@ function App() { const resolvedLocale = resolveFormplayerLocale( (params as Record | null)?.locale, ); + const resolvedFormLocale = resolveEffectiveFormLocale(params); setUiLocale(resolvedLocale); setLinkedFormSpecs( - prepareLinkedFormSpecs(initData.linkedFormSpecs, resolvedLocale), + prepareLinkedFormSpecs(initData.linkedFormSpecs, resolvedFormLocale), ); // Debug: log schema details, especially x-dynamicEnum usage @@ -669,7 +674,7 @@ function App() { const swipeLayoutUISchema = ensureSwipeLayoutRoot(null); const withLocale = applyFormUiTranslations( processUISchemaWithFinalize(swipeLayoutUISchema, skipFinalize), - resolvedLocale, + resolvedFormLocale, ); setUISchema(withLocale); } else { @@ -679,7 +684,7 @@ function App() { ); const withLocale = applyFormUiTranslations( processUISchemaWithFinalize(swipeLayoutUISchema, skipFinalize), - resolvedLocale, + resolvedFormLocale, ); setUISchema(withLocale); } @@ -1238,11 +1243,17 @@ function App() { } const rootPayload = prepareRootObservationData(rawPayload ?? {}, schema); + const effectiveFormLocale = resolveEffectiveFormLocale( + payloadFormInit.params, + ); const { errors: finalizeValidatorErrors, data: payloadData } = runCustomValidatorsAndRefreshData( uischema ?? undefined, schema ?? undefined, - rootPayload as Record, + stampFormLocaleOnObservationData( + rootPayload as Record, + effectiveFormLocale, + ), ajv, ); diff --git a/formulus-formplayer/src/i18n/applyFormUiTranslations.test.ts b/formulus-formplayer/src/i18n/applyFormUiTranslations.test.ts index c9683154c..472a3c918 100644 --- a/formulus-formplayer/src/i18n/applyFormUiTranslations.test.ts +++ b/formulus-formplayer/src/i18n/applyFormUiTranslations.test.ts @@ -10,6 +10,16 @@ describe('applyFormUiTranslations', () => { expect(applyFormUiTranslations(ui, 'fr')).toBe(ui); }); + it('skips merge for default locale even when translations exist', () => { + const ui = { + type: 'Control', + scope: '#/properties/name', + label: 'Name', + translations: { fr: { label: 'Nom' } }, + }; + expect(applyFormUiTranslations(ui, 'default')).toBe(ui); + }); + it('merges partial control label', () => { const ui = { type: 'Control', diff --git a/formulus-formplayer/src/i18n/applyFormUiTranslations.ts b/formulus-formplayer/src/i18n/applyFormUiTranslations.ts index d221697c0..57b7b5c9c 100644 --- a/formulus-formplayer/src/i18n/applyFormUiTranslations.ts +++ b/formulus-formplayer/src/i18n/applyFormUiTranslations.ts @@ -266,6 +266,8 @@ function processNode(node: unknown, locale: string): unknown { */ export function applyFormUiTranslations(uischema: T, locale: string): T { if (!uischema || typeof uischema !== 'object') return uischema; + const tag = locale.trim().toLowerCase(); + if (!tag || tag === 'default') return uischema; if (!hasTranslationsSubtree(uischema)) return uischema; return processNode(uischema, locale) as T; } diff --git a/formulus-formplayer/src/i18n/formLocaleUtils.test.ts b/formulus-formplayer/src/i18n/formLocaleUtils.test.ts new file mode 100644 index 000000000..27a9e7e2e --- /dev/null +++ b/formulus-formplayer/src/i18n/formLocaleUtils.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { + resolveEffectiveFormLocale, + stampFormLocaleOnObservationData, +} from './formLocaleUtils'; + +describe('formLocaleUtils', () => { + it('resolves default when formLocale missing', () => { + expect(resolveEffectiveFormLocale({ locale: 'en' })).toBe('default'); + }); + + it('passes through BCP-47 tags', () => { + expect(resolveEffectiveFormLocale({ formLocale: 'fj' })).toBe('fj'); + }); + + it('stamps formLocale on observation data', () => { + expect(stampFormLocaleOnObservationData({ name: 'Ada' }, 'fj')).toEqual({ + name: 'Ada', + formLocale: 'fj', + }); + }); +}); diff --git a/formulus-formplayer/src/i18n/formLocaleUtils.ts b/formulus-formplayer/src/i18n/formLocaleUtils.ts new file mode 100644 index 000000000..ceefc5669 --- /dev/null +++ b/formulus-formplayer/src/i18n/formLocaleUtils.ts @@ -0,0 +1,21 @@ +/** Resolve effective form translation locale from bridge params. */ + +export function resolveEffectiveFormLocale(params: unknown): string { + if (!params || typeof params !== 'object' || Array.isArray(params)) { + return 'default'; + } + const formLocale = (params as Record).formLocale; + if (typeof formLocale !== 'string' || !formLocale.trim()) { + return 'default'; + } + const trimmed = formLocale.trim(); + return trimmed.toLowerCase() === 'default' ? 'default' : trimmed; +} + +/** Stamp resolved form locale onto observation payload before submit. */ +export function stampFormLocaleOnObservationData( + data: Record, + formLocale: string, +): Record { + return { ...data, formLocale }; +} diff --git a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts index d149227a6..9c3b8e291 100644 --- a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts +++ b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts @@ -41,7 +41,7 @@ export interface ExtensionMetadata { * Data passed to the Formulus app when a form is initialized * @property {string} formType - The form type (e.g. 'form1') * @property {string | null} observationId - The observation ID (generated by the database on first form submission). NULL if this is a new form. - * @property {Record} params - Host parameters for the formplayer WebView. Put **field prefills** in `params.defaultData` (a plain object). Top-level keys are reserved for bridge/UI: `defaultData`, `theme`, `darkMode`, `themeColors`, `locale` (active UI language), `context` (session context)—do not rely on those being stored on the observation. If `defaultData` is omitted, legacy behavior copies every other top-level key as prefill data (still excluding the reserved keys above). On load and submit, when the JSON Schema has non-empty root `properties`, the formplayer keeps only those property keys plus optional observation metadata keys such as schema-declared `locale`. + * @property {Record} params - Host parameters for the formplayer WebView. Put **field prefills** in `params.defaultData` (a plain object). Top-level keys are reserved for bridge/UI: `defaultData`, `theme`, `darkMode`, `themeColors`, `locale` (Formplayer chrome / ODE UI catalogs), `formLocale` (`'default'` or a BCP-47 tag for embedded `ui.json` translations), `context` (session context)—do not rely on `locale` or `formLocale` being copied from params into prefill data. If `defaultData` is omitted, legacy behavior copies every other top-level key as prefill data (still excluding the reserved keys above). On load and submit, when the JSON Schema has non-empty root `properties`, the formplayer keeps only those property keys plus optional observation metadata keys such as schema-declared `locale` and host-stamped `formLocale` (the form translation locale used for that session). * @property {Record} savedData - Previously saved form data (for editing) * @property {any} [formSchema] - JSON Schema for the form structure and validation (optional) * @property {any} [uiSchema] - UI Schema for form rendering layout (optional) diff --git a/formulus-formplayer/src/utils/formObservationData.test.ts b/formulus-formplayer/src/utils/formObservationData.test.ts index 0e97b3e1e..42b18d369 100644 --- a/formulus-formplayer/src/utils/formObservationData.test.ts +++ b/formulus-formplayer/src/utils/formObservationData.test.ts @@ -26,6 +26,7 @@ describe('initialFormDataFromParams', () => { darkMode: true, themeColors: { primary: '#000' }, locale: 'pt', + formLocale: 'fj', species: 'oak', }; expect(initialFormDataFromParams(params)).toEqual({ species: 'oak' }); @@ -48,11 +49,13 @@ describe('dataMatchingSchemaRoot', () => { themeColors: { z: 1 }, junk: true, locale: 'sv', + formLocale: 'fj', }; const schema = { properties: { name: { type: 'string' } } }; expect(dataMatchingSchemaRoot(data, schema)).toEqual({ name: 'x', locale: 'sv', + formLocale: 'fj', }); }); diff --git a/formulus-formplayer/src/utils/formObservationData.ts b/formulus-formplayer/src/utils/formObservationData.ts index c7ee38543..b357c8b42 100644 --- a/formulus-formplayer/src/utils/formObservationData.ts +++ b/formulus-formplayer/src/utils/formObservationData.ts @@ -11,6 +11,8 @@ export const FORMPARAMS_NON_DATA_KEYS = new Set([ 'themeColors', // UI locale from host — not observation data (distinct from optional schema `locale` field). 'locale', + // Form translation locale from host — stamped on submit as observation metadata. + 'formLocale', // Reserved read-only session context channel (see App init): a custom app may // pass `params.context` with session info (device role, selected cluster, ...) // that must never be persisted as observation data. @@ -137,13 +139,13 @@ export function applySchemaDefaultTokens( } /** - * Observation JSON should match root `schema.properties` (plus optional extras like `locale`). + * Observation JSON should match root `schema.properties` (plus optional extras like `locale`, `formLocale`). * Strips leaked host/UI keys and survives older rows that embedded `theme`, `themeColors`, etc. */ export function dataMatchingSchemaRoot( data: FormObservationData, formSchema: unknown, - extraRootKeys: string[] = ['locale'], + extraRootKeys: string[] = ['locale', 'formLocale'], ): FormObservationData { if (!data || typeof data !== 'object') { return {}; @@ -212,7 +214,7 @@ export function coerceSchemaRootIntegers( export function prepareRootObservationData( data: FormObservationData, formSchema: unknown, - extraRootKeys: string[] = ['locale'], + extraRootKeys: string[] = ['locale', 'formLocale'], ): FormObservationData { return coerceSchemaRootIntegers( dataMatchingSchemaRoot(data, formSchema, extraRootKeys), From 17908ed7a5b215422cf95033e65d320f1891ed02 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 3 Jul 2026 11:38:51 +0200 Subject: [PATCH 2/4] feat(formulus): add forms and UI locale selectors --- formulus/src/components/FormplayerModal.tsx | 17 ++ .../components/common/FormLocalePicker.tsx | 190 ++++++++++++++++++ .../src/components/common/LocalePicker.tsx | 4 +- formulus/src/components/common/index.ts | 1 + .../src/lib/collectTranslationLocales.test.ts | 51 +++++ formulus/src/lib/collectTranslationLocales.ts | 52 +++++ formulus/src/lib/formLocale.test.ts | 52 +++++ formulus/src/lib/formLocale.ts | 72 +++++++ formulus/src/locales/en.json | 3 + formulus/src/locales/fr.json | 3 + formulus/src/locales/pt.json | 3 + formulus/src/screens/SettingsScreen.tsx | 59 +++++- .../src/services/FormLocaleIndexService.ts | 149 ++++++++++++++ .../src/services/FormLocaleSettingsService.ts | 79 ++++++++ formulus/src/services/SyncService.ts | 4 + .../webview/FormulusInterfaceDefinition.ts | 2 +- 16 files changed, 736 insertions(+), 5 deletions(-) create mode 100644 formulus/src/components/common/FormLocalePicker.tsx create mode 100644 formulus/src/lib/collectTranslationLocales.test.ts create mode 100644 formulus/src/lib/collectTranslationLocales.ts create mode 100644 formulus/src/lib/formLocale.test.ts create mode 100644 formulus/src/lib/formLocale.ts create mode 100644 formulus/src/services/FormLocaleIndexService.ts create mode 100644 formulus/src/services/FormLocaleSettingsService.ts diff --git a/formulus/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx index 55583e7a3..417711c0d 100644 --- a/formulus/src/components/FormplayerModal.tsx +++ b/formulus/src/components/FormplayerModal.tsx @@ -48,6 +48,7 @@ import { useConfirmModal } from '../contexts/ConfirmModalContext'; import { geolocationService } from '../services/GeolocationService'; import { persistObservationWithAttachments } from '../services/attachmentStorage'; import { localeSettingsService } from '../services/LocaleSettingsService'; +import { formLocaleSettingsService } from '../services/FormLocaleSettingsService'; import { useTranslation } from 'react-i18next'; async function buildLinkedFormSpecs( @@ -293,12 +294,28 @@ const FormplayerModal = forwardRef( const resolvedLocale = await localeSettingsService.resolveActiveLocale(sessionLocale); + const sessionFormLocale = + params && typeof params.formLocale === 'string' + ? params.formLocale + : null; + const savedFormLocale = + existingObservationData && + typeof existingObservationData.formLocale === 'string' + ? existingObservationData.formLocale + : null; + const resolvedFormLocale = + await formLocaleSettingsService.resolveActiveFormLocale( + sessionFormLocale, + savedFormLocale, + ); + const formParams = { theme: 'default', darkMode: isDark, themeColors, // ← custom app palette forwarded to Formplayer ...params, locale: resolvedLocale, + formLocale: resolvedFormLocale, }; // Load extensions for this form diff --git a/formulus/src/components/common/FormLocalePicker.tsx b/formulus/src/components/common/FormLocalePicker.tsx new file mode 100644 index 000000000..6a2b78b88 --- /dev/null +++ b/formulus/src/components/common/FormLocalePicker.tsx @@ -0,0 +1,190 @@ +import React, { useState } from 'react'; +import { + Modal, + Text, + TouchableOpacity, + StyleSheet, + Pressable, +} from 'react-native'; +import Icon from '@react-native-vector-icons/material-design-icons'; +import { useTranslation } from 'react-i18next'; +import { FORM_LOCALE_DEFAULT } from '../../lib/formLocale'; +import { useAppTheme } from '../../contexts/AppThemeContext'; +import colors from '../../theme/colors'; +import { odeSpacing, odeTypography, odeRadius } from '../../theme/odeDesign'; + +interface FormLocalePickerProps { + value: string; + locales: string[]; + disabled?: boolean; + onChange: (value: string) => void; +} + +const FormLocalePicker: React.FC = ({ + value, + locales, + disabled = false, + onChange, +}) => { + const { t } = useTranslation(); + const { themeColors } = useAppTheme(); + const [open, setOpen] = useState(false); + + const options = [ + { value: FORM_LOCALE_DEFAULT, label: t('settings.formsLanguage.default') }, + ...locales.map(code => ({ value: code, label: code })), + ]; + + const currentLabel = + options.find(opt => opt.value === value)?.label ?? + (value === FORM_LOCALE_DEFAULT + ? t('settings.formsLanguage.default') + : value); + + return ( + <> + { + if (!disabled) setOpen(true); + }} + disabled={disabled} + accessibilityRole="button" + accessibilityLabel={t('settings.formsLanguage.label')} + accessibilityState={{ disabled }}> + + {currentLabel} + + + + + setOpen(false)}> + setOpen(false)}> + e.stopPropagation()}> + + {t('settings.formsLanguage.label')} + + {options.map(opt => { + const selected = value === opt.value; + return ( + { + onChange(opt.value); + setOpen(false); + }}> + + {opt.label} + + {selected ? ( + + ) : null} + + ); + })} + + + + + ); +}; + +const styles = StyleSheet.create({ + trigger: { + flex: 1, + minWidth: 140, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderWidth: 1, + borderRadius: odeRadius.inner, + paddingHorizontal: odeSpacing.sm, + paddingVertical: odeSpacing.xs, + gap: odeSpacing.xs, + }, + triggerText: { + flex: 1, + fontSize: odeTypography.bodySm, + }, + backdrop: { + flex: 1, + backgroundColor: colors.ui.background, + justifyContent: 'center', + padding: odeSpacing.lg, + }, + sheet: { + borderRadius: odeRadius.card, + borderWidth: 1, + paddingVertical: odeSpacing.sm, + overflow: 'hidden', + }, + sheetTitle: { + fontSize: odeTypography.body, + fontWeight: '600', + paddingHorizontal: odeSpacing.md, + paddingVertical: odeSpacing.sm, + }, + option: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: odeSpacing.md, + paddingVertical: odeSpacing.sm, + }, + optionText: { + fontSize: odeTypography.body, + flex: 1, + }, +}); + +export default FormLocalePicker; diff --git a/formulus/src/components/common/LocalePicker.tsx b/formulus/src/components/common/LocalePicker.tsx index 20dd6b1c9..96639f717 100644 --- a/formulus/src/components/common/LocalePicker.tsx +++ b/formulus/src/components/common/LocalePicker.tsx @@ -44,7 +44,7 @@ const LocalePicker: React.FC = ({ value, onChange }) => { ]} onPress={() => setOpen(true)} accessibilityRole="button" - accessibilityLabel={t('settings.language.label')}> + accessibilityLabel={t('settings.appInterface.label')}> = ({ value, onChange }) => { styles.sheetTitle, { color: themeColors.onSurface as string }, ]}> - {t('settings.language.label')} + {t('settings.appInterface.label')} {UI_LOCALE_PREFERENCE_OPTIONS.map(opt => { const selected = value === opt.value; diff --git a/formulus/src/components/common/index.ts b/formulus/src/components/common/index.ts index 89e5612ed..d0b81b0e5 100644 --- a/formulus/src/components/common/index.ts +++ b/formulus/src/components/common/index.ts @@ -13,5 +13,6 @@ export type { SortOption, FilterOption } from './FilterBar.types'; export type { StatusTab } from './StatusTabs'; export type { SyncStatus } from './SyncStatusButtons'; export { default as LocalePicker } from './LocalePicker'; +export { default as FormLocalePicker } from './FormLocalePicker'; export { default as PasswordInput } from './PasswordInput'; export type { PasswordInputProps } from './PasswordInput'; diff --git a/formulus/src/lib/collectTranslationLocales.test.ts b/formulus/src/lib/collectTranslationLocales.test.ts new file mode 100644 index 000000000..80fb713ff --- /dev/null +++ b/formulus/src/lib/collectTranslationLocales.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { collectTranslationLocalesFromUiSchema } from './collectTranslationLocales'; + +describe('collectTranslationLocalesFromUiSchema', () => { + it('returns empty array when no translations', () => { + expect( + collectTranslationLocalesFromUiSchema({ + type: 'VerticalLayout', + elements: [{ type: 'Control', scope: '#/properties/a', label: 'A' }], + }), + ).toEqual([]); + }); + + it('collects locale keys from nested controls', () => { + const locales = collectTranslationLocalesFromUiSchema({ + type: 'VerticalLayout', + elements: [ + { + type: 'Control', + scope: '#/properties/a', + label: 'A', + translations: { fj: { label: 'A' }, pt: { label: 'A' } }, + }, + { + type: 'Control', + scope: '#/properties/b', + label: 'B', + translations: { fr: { label: 'B' } }, + }, + ], + }); + expect(locales).toEqual(['fj', 'fr', 'pt']); + }); + + it('walks SwipeLayout option columns', () => { + const locales = collectTranslationLocalesFromUiSchema({ + type: 'SwipeLayout', + options: { + columns: [ + { + type: 'Control', + scope: '#/properties/q', + translations: { 'pt-BR': { label: 'Pergunta' } }, + }, + ], + }, + elements: [], + }); + expect(locales).toEqual(['pt-BR']); + }); +}); diff --git a/formulus/src/lib/collectTranslationLocales.ts b/formulus/src/lib/collectTranslationLocales.ts new file mode 100644 index 000000000..01649990c --- /dev/null +++ b/formulus/src/lib/collectTranslationLocales.ts @@ -0,0 +1,52 @@ +/** + * Collect BCP-47 locale keys from embedded `translations` blocks in ui.json trees. + * Mirrors the walk used by formplayer `applyFormUiTranslations`. + */ + +function collectFromNode(node: unknown, out: Set): void { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const child of node) { + collectFromNode(child, out); + } + return; + } + + const obj = node as Record; + const translations = obj.translations; + if ( + translations && + typeof translations === 'object' && + !Array.isArray(translations) + ) { + for (const key of Object.keys(translations)) { + const trimmed = key.trim(); + if (trimmed) out.add(trimmed); + } + } + + if (Array.isArray(obj.elements)) { + for (const el of obj.elements) { + collectFromNode(el, out); + } + } + + const options = obj.options; + if (options && typeof options === 'object' && !Array.isArray(options)) { + const opts = options as Record; + if (Array.isArray(opts.columns)) { + for (const col of opts.columns) { + collectFromNode(col, out); + } + } + } +} + +/** Union of all `translations.*` locale keys in a parsed ui.json root. */ +export function collectTranslationLocalesFromUiSchema( + uiSchema: unknown, +): string[] { + const out = new Set(); + collectFromNode(uiSchema, out); + return Array.from(out).sort((a, b) => a.localeCompare(b)); +} diff --git a/formulus/src/lib/formLocale.test.ts b/formulus/src/lib/formLocale.test.ts new file mode 100644 index 000000000..598ab6488 --- /dev/null +++ b/formulus/src/lib/formLocale.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { + FORM_LOCALE_DEFAULT, + isStaleFormLocalePreference, + resolveActiveFormLocale, +} from './formLocale'; + +describe('formLocale', () => { + it('prefers session override over saved and settings', () => { + expect( + resolveActiveFormLocale({ + preference: 'pt', + availableLocales: ['pt'], + sessionOverride: 'fj', + savedFormLocale: 'fr', + }), + ).toBe('fj'); + }); + + it('uses saved form locale on edit when no session override', () => { + expect( + resolveActiveFormLocale({ + preference: FORM_LOCALE_DEFAULT, + availableLocales: [], + savedFormLocale: 'fj', + }), + ).toBe('fj'); + }); + + it('falls back to default when settings preference is stale', () => { + expect( + resolveActiveFormLocale({ + preference: 'fj', + availableLocales: ['pt'], + }), + ).toBe(FORM_LOCALE_DEFAULT); + }); + + it('keeps settings preference when still in union', () => { + expect( + resolveActiveFormLocale({ + preference: 'pt-BR', + availableLocales: ['pt-br'], + }), + ).toBe('pt-BR'); + }); + + it('detects stale settings preference', () => { + expect(isStaleFormLocalePreference('fj', ['pt'])).toBe(true); + expect(isStaleFormLocalePreference(FORM_LOCALE_DEFAULT, [])).toBe(false); + }); +}); diff --git a/formulus/src/lib/formLocale.ts b/formulus/src/lib/formLocale.ts new file mode 100644 index 000000000..583f30e6c --- /dev/null +++ b/formulus/src/lib/formLocale.ts @@ -0,0 +1,72 @@ +import { localeLookupCandidates, normalizeLocaleTag } from './locale'; + +export const FORM_LOCALE_DEFAULT = 'default' as const; +export type FormLocalePreference = typeof FORM_LOCALE_DEFAULT | string; + +export function isDefaultFormLocale(tag: string): boolean { + return tag.trim().toLowerCase() === FORM_LOCALE_DEFAULT; +} + +export function sortFormLocaleCodes(locales: string[]): string[] { + return [...locales].sort((a, b) => a.localeCompare(b)); +} + +function localeInUnion(tag: string, available: string[]): boolean { + if (isDefaultFormLocale(tag)) return true; + const normalized = normalizeLocaleTag(tag); + const availableNorm = new Set(available.map(normalizeLocaleTag)); + if (availableNorm.has(normalized)) return true; + for (const candidate of localeLookupCandidates(tag)) { + if (availableNorm.has(candidate)) return true; + } + return false; +} + +export interface ResolveActiveFormLocaleInput { + preference: FormLocalePreference; + availableLocales: string[]; + /** `openFormplayer` params.formLocale when provided. */ + sessionOverride?: string | null; + /** Prior observation metadata on edit. */ + savedFormLocale?: string | null; +} + +/** + * Resolve the form translation locale for a Formplayer session. + * Precedence: session override → saved observation → Settings → default. + * Settings-only stale tags fall back to default (session/saved accept any BCP-47). + */ +export function resolveActiveFormLocale( + input: ResolveActiveFormLocaleInput, +): FormLocalePreference { + const { preference, availableLocales, sessionOverride, savedFormLocale } = + input; + + if (typeof sessionOverride === 'string' && sessionOverride.trim()) { + const trimmed = sessionOverride.trim(); + return isDefaultFormLocale(trimmed) ? FORM_LOCALE_DEFAULT : trimmed; + } + + if (typeof savedFormLocale === 'string' && savedFormLocale.trim()) { + const trimmed = savedFormLocale.trim(); + return isDefaultFormLocale(trimmed) ? FORM_LOCALE_DEFAULT : trimmed; + } + + if (isDefaultFormLocale(preference)) { + return FORM_LOCALE_DEFAULT; + } + + if (localeInUnion(preference, availableLocales)) { + return preference; + } + + return FORM_LOCALE_DEFAULT; +} + +export function isStaleFormLocalePreference( + preference: FormLocalePreference, + availableLocales: string[], +): boolean { + if (isDefaultFormLocale(preference)) return false; + return !localeInUnion(preference, availableLocales); +} diff --git a/formulus/src/locales/en.json b/formulus/src/locales/en.json index 7dfc2b7c8..c66fb7489 100644 --- a/formulus/src/locales/en.json +++ b/formulus/src/locales/en.json @@ -4,6 +4,9 @@ "settings.language.pt": "Português", "settings.language.fr": "Français", "settings.language.label": "Language:", + "settings.appInterface.label": "App interface:", + "settings.formsLanguage.label": "Forms:", + "settings.formsLanguage.default": "Default", "settings.theme.label": "Theme:", "settings.title": "Settings", "settings.appSettings": "App Settings", diff --git a/formulus/src/locales/fr.json b/formulus/src/locales/fr.json index 5fc840bc3..c3f96127f 100644 --- a/formulus/src/locales/fr.json +++ b/formulus/src/locales/fr.json @@ -4,6 +4,9 @@ "settings.language.pt": "Português", "settings.language.fr": "Français", "settings.language.label": "Langue :", + "settings.appInterface.label": "Interface de l'application :", + "settings.formsLanguage.label": "Formulaires :", + "settings.formsLanguage.default": "Par défaut", "settings.theme.label": "Thème :", "settings.title": "Paramètres", "settings.appSettings": "Paramètres de l'application", diff --git a/formulus/src/locales/pt.json b/formulus/src/locales/pt.json index 4cd422dc7..4dc6a991f 100644 --- a/formulus/src/locales/pt.json +++ b/formulus/src/locales/pt.json @@ -4,6 +4,9 @@ "settings.language.pt": "Português", "settings.language.fr": "Français", "settings.language.label": "Idioma:", + "settings.appInterface.label": "Interface da aplicação:", + "settings.formsLanguage.label": "Formulários:", + "settings.formsLanguage.default": "Predefinido", "settings.theme.label": "Tema:", "settings.title": "Definições", "settings.appSettings": "Definições da aplicação", diff --git a/formulus/src/screens/SettingsScreen.tsx b/formulus/src/screens/SettingsScreen.tsx index 36f456f83..fa18ad98e 100644 --- a/formulus/src/screens/SettingsScreen.tsx +++ b/formulus/src/screens/SettingsScreen.tsx @@ -55,14 +55,18 @@ import { getSettingsHydrationSnapshot, loadSettingsHydrationFromStorage, } from '../services/SettingsHydrationCache'; -import { Button, LocalePicker } from '../components/common'; +import { Button, LocalePicker, FormLocalePicker } from '../components/common'; import { useScreenShellStyle } from '../hooks/useScreenShellStyle'; import Logo from '../../assets/images/logo.png'; import { Moon, Monitor, Sun, Languages } from 'lucide-react-native'; import { useTranslation } from 'react-i18next'; import { localeSettingsService } from '../services/LocaleSettingsService'; +import { formLocaleSettingsService } from '../services/FormLocaleSettingsService'; +import { formLocaleIndexService } from '../services/FormLocaleIndexService'; import { type UiLocalePreference } from '../lib/locale'; +import { FORM_LOCALE_DEFAULT } from '../lib/formLocale'; import { syncFormulusI18nLanguage } from '../i18n'; +import { appEvents } from '../webview/FormulusMessageHandlers'; type SettingsScreenNavigationProp = BottomTabNavigationProp< MainTabParamList, @@ -95,6 +99,9 @@ const SettingsScreen = () => { const [version, setVersion] = useState(''); const [uiLocalePreference, setUiLocalePreference] = useState('auto'); + const [formLocalePreference, setFormLocalePreference] = + useState(FORM_LOCALE_DEFAULT); + const [scannedFormLocales, setScannedFormLocales] = useState([]); const mountedRef = useRef(true); useEffect(() => { @@ -121,6 +128,33 @@ const SettingsScreen = () => { setUiLocalePreference(localeSettingsService.getPreference()); } }); + void formLocaleSettingsService.load().then(() => { + if (mountedRef.current) { + setFormLocalePreference(formLocaleSettingsService.getPreference()); + } + }); + void formLocaleIndexService.getLocales().then(locales => { + if (mountedRef.current) { + setScannedFormLocales(locales); + } + }); + }, []); + + useEffect(() => { + const onBundleUpdated = () => { + void formLocaleIndexService.getLocales().then(locales => { + if (mountedRef.current) { + setScannedFormLocales(locales); + } + }); + }; + appEvents.addListener('bundleUpdated', onBundleUpdated); + return () => appEvents.removeListener('bundleUpdated', onBundleUpdated); + }, []); + + const handleFormLocalePreference = useCallback(async (preference: string) => { + setFormLocalePreference(preference); + await formLocaleSettingsService.setPreference(preference); }, []); const handleLocalePreference = useCallback( @@ -621,7 +655,7 @@ const SettingsScreen = () => { styles.appSettingsLabel, { color: themeColors.onSurface }, ]}> - {t('settings.language.label')} + {t('settings.appInterface.label')} @@ -632,6 +666,27 @@ const SettingsScreen = () => { + + + + + {t('settings.formsLanguage.label')} + + + + void handleFormLocalePreference(pref)} + /> + + + {!!version && ( diff --git a/formulus/src/services/FormLocaleIndexService.ts b/formulus/src/services/FormLocaleIndexService.ts new file mode 100644 index 000000000..edbd5ad53 --- /dev/null +++ b/formulus/src/services/FormLocaleIndexService.ts @@ -0,0 +1,149 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import RNFS from 'react-native-fs'; +import { collectTranslationLocalesFromUiSchema } from '../lib/collectTranslationLocales'; +import { sortFormLocaleCodes } from '../lib/formLocale'; +import { normalizeAppBundleVersion } from '../utils/appBundleVersion'; +import { appEvents } from '../webview/FormulusMessageHandlers'; + +const CACHE_DIR = `${RNFS.DocumentDirectoryPath}/app/.ode`; +const CACHE_FILE = `${CACHE_DIR}/form-locale-index.json`; + +const FORMS_DIRS = [ + `${RNFS.DocumentDirectoryPath}/forms`, + `${RNFS.DocumentDirectoryPath}/app/forms`, +]; + +const RESERVED_FORM_DIR_NAMES = new Set(['extensions', 'question_types']); + +interface FormLocaleIndexCache { + bundleVersion: string; + scannedAt: string; + locales: string[]; +} + +export class FormLocaleIndexService { + private static instance: FormLocaleIndexService | null = null; + + private refreshPromise: Promise | null = null; + + private constructor() { + appEvents.addListener('bundleUpdated', () => { + void this.refreshIndex().catch(err => { + console.warn( + '[FormLocaleIndexService] refresh after bundle failed:', + err, + ); + }); + }); + } + + static getInstance(): FormLocaleIndexService { + if (!FormLocaleIndexService.instance) { + FormLocaleIndexService.instance = new FormLocaleIndexService(); + } + return FormLocaleIndexService.instance; + } + + private async readBundleVersion(): Promise { + const raw = await AsyncStorage.getItem('@appVersion'); + return normalizeAppBundleVersion(raw ?? ''); + } + + private async readCache(): Promise { + try { + const exists = await RNFS.exists(CACHE_FILE); + if (!exists) return null; + const raw = await RNFS.readFile(CACHE_FILE, 'utf8'); + const parsed = JSON.parse(raw) as FormLocaleIndexCache; + if (!Array.isArray(parsed.locales)) return null; + return parsed; + } catch (err) { + console.warn('[FormLocaleIndexService] Failed to read cache:', err); + return null; + } + } + + private async writeCache(locales: string[]): Promise { + const bundleVersion = await this.readBundleVersion(); + const payload: FormLocaleIndexCache = { + bundleVersion, + scannedAt: new Date().toISOString(), + locales, + }; + try { + const dirExists = await RNFS.exists(CACHE_DIR); + if (!dirExists) { + await RNFS.mkdir(CACHE_DIR); + } + await RNFS.writeFile(CACHE_FILE, JSON.stringify(payload), 'utf8'); + } catch (err) { + console.warn('[FormLocaleIndexService] Failed to write cache:', err); + } + } + + async scanFromStorage(): Promise { + const localeSet = new Set(); + const seenFormIds = new Set(); + + for (const formsDir of FORMS_DIRS) { + const dirExists = await RNFS.exists(formsDir); + if (!dirExists) continue; + + const entries = await RNFS.readDir(formsDir); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if ( + entry.name.startsWith('.') || + entry.name.startsWith('temp_') || + RESERVED_FORM_DIR_NAMES.has(entry.name) + ) { + continue; + } + if (seenFormIds.has(entry.name)) continue; + + const uiPath = `${entry.path}/ui.json`; + try { + const uiExists = await RNFS.exists(uiPath); + if (!uiExists) continue; + const raw = await RNFS.readFile(uiPath, 'utf8'); + const uiSchema = JSON.parse(raw) as unknown; + for (const code of collectTranslationLocalesFromUiSchema(uiSchema)) { + localeSet.add(code); + } + seenFormIds.add(entry.name); + } catch (err) { + console.warn( + `[FormLocaleIndexService] Failed to scan ui.json for ${entry.name}:`, + err, + ); + } + } + } + + return sortFormLocaleCodes(Array.from(localeSet)); + } + + async refreshIndex(): Promise { + if (this.refreshPromise) return this.refreshPromise; + this.refreshPromise = (async () => { + const locales = await this.scanFromStorage(); + await this.writeCache(locales); + return locales; + })().finally(() => { + this.refreshPromise = null; + }); + return this.refreshPromise; + } + + /** Cached union when fresh; otherwise scan and cache. */ + async getLocales(): Promise { + const bundleVersion = await this.readBundleVersion(); + const cached = await this.readCache(); + if (cached && cached.bundleVersion === bundleVersion) { + return cached.locales; + } + return this.refreshIndex(); + } +} + +export const formLocaleIndexService = FormLocaleIndexService.getInstance(); diff --git a/formulus/src/services/FormLocaleSettingsService.ts b/formulus/src/services/FormLocaleSettingsService.ts new file mode 100644 index 000000000..36d9b270b --- /dev/null +++ b/formulus/src/services/FormLocaleSettingsService.ts @@ -0,0 +1,79 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + FORM_LOCALE_DEFAULT, + isStaleFormLocalePreference, + resolveActiveFormLocale, + type FormLocalePreference, +} from '../lib/formLocale'; +import { formLocaleIndexService } from './FormLocaleIndexService'; + +const STORAGE_KEY = '@ode/formLocale'; + +export class FormLocaleSettingsService { + private static instance: FormLocaleSettingsService | null = null; + + private preference: FormLocalePreference = FORM_LOCALE_DEFAULT; + private loaded = false; + + static getInstance(): FormLocaleSettingsService { + if (!FormLocaleSettingsService.instance) { + FormLocaleSettingsService.instance = new FormLocaleSettingsService(); + } + return FormLocaleSettingsService.instance; + } + + async load(): Promise { + if (this.loaded) return; + try { + const stored = await AsyncStorage.getItem(STORAGE_KEY); + if (stored != null && stored.trim()) { + this.preference = stored.trim(); + } + } catch (err) { + console.warn( + '[FormLocaleSettingsService] Failed to load preference:', + err, + ); + } + this.loaded = true; + } + + getPreference(): FormLocalePreference { + return this.preference; + } + + async setPreference(preference: FormLocalePreference): Promise { + this.preference = preference; + this.loaded = true; + await AsyncStorage.setItem(STORAGE_KEY, preference); + } + + /** + * Resolved form translation locale for Formplayer (`params.formLocale`). + */ + async resolveActiveFormLocale( + sessionOverride?: string | null, + savedFormLocale?: string | null, + ): Promise { + await this.load(); + const availableLocales = await formLocaleIndexService.getLocales(); + + if ( + !sessionOverride && + !savedFormLocale && + isStaleFormLocalePreference(this.preference, availableLocales) + ) { + await this.setPreference(FORM_LOCALE_DEFAULT); + } + + return resolveActiveFormLocale({ + preference: this.preference, + availableLocales, + sessionOverride, + savedFormLocale, + }); + } +} + +export const formLocaleSettingsService = + FormLocaleSettingsService.getInstance(); diff --git a/formulus/src/services/SyncService.ts b/formulus/src/services/SyncService.ts index 8c5b4371f..5c19f9c76 100644 --- a/formulus/src/services/SyncService.ts +++ b/formulus/src/services/SyncService.ts @@ -397,6 +397,10 @@ export class SyncService { const formService = await FormService.getInstance(); await formService.invalidateCache(); + const { formLocaleIndexService } = + await import('./FormLocaleIndexService'); + await formLocaleIndexService.refreshIndex(); + const syncTime = new Date().toLocaleTimeString(); await AsyncStorage.setItem('@lastSync', syncTime); this.updateStatus('App bundle sync completed'); diff --git a/formulus/src/webview/FormulusInterfaceDefinition.ts b/formulus/src/webview/FormulusInterfaceDefinition.ts index d149227a6..9c3b8e291 100644 --- a/formulus/src/webview/FormulusInterfaceDefinition.ts +++ b/formulus/src/webview/FormulusInterfaceDefinition.ts @@ -41,7 +41,7 @@ export interface ExtensionMetadata { * Data passed to the Formulus app when a form is initialized * @property {string} formType - The form type (e.g. 'form1') * @property {string | null} observationId - The observation ID (generated by the database on first form submission). NULL if this is a new form. - * @property {Record} params - Host parameters for the formplayer WebView. Put **field prefills** in `params.defaultData` (a plain object). Top-level keys are reserved for bridge/UI: `defaultData`, `theme`, `darkMode`, `themeColors`, `locale` (active UI language), `context` (session context)—do not rely on those being stored on the observation. If `defaultData` is omitted, legacy behavior copies every other top-level key as prefill data (still excluding the reserved keys above). On load and submit, when the JSON Schema has non-empty root `properties`, the formplayer keeps only those property keys plus optional observation metadata keys such as schema-declared `locale`. + * @property {Record} params - Host parameters for the formplayer WebView. Put **field prefills** in `params.defaultData` (a plain object). Top-level keys are reserved for bridge/UI: `defaultData`, `theme`, `darkMode`, `themeColors`, `locale` (Formplayer chrome / ODE UI catalogs), `formLocale` (`'default'` or a BCP-47 tag for embedded `ui.json` translations), `context` (session context)—do not rely on `locale` or `formLocale` being copied from params into prefill data. If `defaultData` is omitted, legacy behavior copies every other top-level key as prefill data (still excluding the reserved keys above). On load and submit, when the JSON Schema has non-empty root `properties`, the formplayer keeps only those property keys plus optional observation metadata keys such as schema-declared `locale` and host-stamped `formLocale` (the form translation locale used for that session). * @property {Record} savedData - Previously saved form data (for editing) * @property {any} [formSchema] - JSON Schema for the form structure and validation (optional) * @property {any} [uiSchema] - UI Schema for form rendering layout (optional) From 389b432992aa40082f9382319bdeeb511b67e7e9 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 3 Jul 2026 12:44:30 +0200 Subject: [PATCH 3/4] fix(desktop): formatting, tauri casing support --- desktop/src/lib/__tests__/formLocale.test.ts | 39 +++++++++++++ desktop/src/lib/buildFormPreviewInit.ts | 11 +++- desktop/src/lib/collectTranslationLocales.ts | 48 ++++++++++++++++ desktop/src/lib/formLocale.ts | 60 ++++++++++++++++++++ desktop/src/pages/FormPreviewPage.tsx | 53 ++++++++++++++++- 5 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 desktop/src/lib/__tests__/formLocale.test.ts create mode 100644 desktop/src/lib/collectTranslationLocales.ts create mode 100644 desktop/src/lib/formLocale.ts diff --git a/desktop/src/lib/__tests__/formLocale.test.ts b/desktop/src/lib/__tests__/formLocale.test.ts new file mode 100644 index 000000000..7fa58a31c --- /dev/null +++ b/desktop/src/lib/__tests__/formLocale.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { scanActiveBundleFormLocales } from '../formLocale'; + +vi.mock('../tauriClient', () => ({ + tauriClient: { + listActiveBundleForms: vi.fn(), + readBundleFormSpec: vi.fn(), + }, +})); + +import { tauriClient } from '../tauriClient'; + +describe('scanActiveBundleFormLocales', () => { + beforeEach(() => { + vi.mocked(tauriClient.listActiveBundleForms).mockReset(); + vi.mocked(tauriClient.readBundleFormSpec).mockReset(); + }); + + it('collects locales from uiSchema (camelCase from Tauri)', async () => { + vi.mocked(tauriClient.listActiveBundleForms).mockResolvedValue([ + { formType: 'register_bean' }, + ]); + vi.mocked(tauriClient.readBundleFormSpec).mockResolvedValue({ + formType: 'register_bean', + formSchema: {}, + uiSchema: { + translations: { it: { label: 'Registra' } }, + elements: [ + { + type: 'Control', + translations: { it: { label: 'Nome' } }, + }, + ], + }, + }); + + await expect(scanActiveBundleFormLocales()).resolves.toEqual(['it']); + }); +}); diff --git a/desktop/src/lib/buildFormPreviewInit.ts b/desktop/src/lib/buildFormPreviewInit.ts index 518f29c97..41b9e8f2f 100644 --- a/desktop/src/lib/buildFormPreviewInit.ts +++ b/desktop/src/lib/buildFormPreviewInit.ts @@ -1,6 +1,7 @@ import type { FormInitData } from './formplayerHost'; import { sanitizePortableAttachmentsInFormData } from './sanitizeFormSavedData'; import { resolveDesktopUiLocale } from './uiLocale'; +import { resolveDesktopFormLocale } from './formLocale'; import { buildLinkedFormSpecs, type LoadLinkedFormSpec, @@ -42,10 +43,18 @@ export function buildFormPreviewInit(args: { typeof args.params.locale === 'string' ? resolveDesktopUiLocale(args.params.locale) : resolveDesktopUiLocale(); + const savedFormLocale = + typeof args.savedData.formLocale === 'string' + ? args.savedData.formLocale + : null; + const formLocale = resolveDesktopFormLocale( + typeof args.params.formLocale === 'string' ? args.params.formLocale : null, + savedFormLocale, + ); const init: FormInitData = { formType: args.formType, observationId: args.observationId ?? null, - params: { ...args.params, locale }, + params: { ...args.params, locale, formLocale }, savedData: sanitizePortableAttachmentsInFormData(args.savedData), formSchema: args.formSchema, uiSchema: args.uiSchema, diff --git a/desktop/src/lib/collectTranslationLocales.ts b/desktop/src/lib/collectTranslationLocales.ts new file mode 100644 index 000000000..97aecedac --- /dev/null +++ b/desktop/src/lib/collectTranslationLocales.ts @@ -0,0 +1,48 @@ +/** Collect BCP-47 locale keys from embedded `translations` blocks in ui.json trees. */ + +function collectFromNode(node: unknown, out: Set): void { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const child of node) { + collectFromNode(child, out); + } + return; + } + + const obj = node as Record; + const translations = obj.translations; + if ( + translations && + typeof translations === 'object' && + !Array.isArray(translations) + ) { + for (const key of Object.keys(translations)) { + const trimmed = key.trim(); + if (trimmed) out.add(trimmed); + } + } + + if (Array.isArray(obj.elements)) { + for (const el of obj.elements) { + collectFromNode(el, out); + } + } + + const options = obj.options; + if (options && typeof options === 'object' && !Array.isArray(options)) { + const opts = options as Record; + if (Array.isArray(opts.columns)) { + for (const col of opts.columns) { + collectFromNode(col, out); + } + } + } +} + +export function collectTranslationLocalesFromUiSchema( + uiSchema: unknown, +): string[] { + const out = new Set(); + collectFromNode(uiSchema, out); + return Array.from(out).sort((a, b) => a.localeCompare(b)); +} diff --git a/desktop/src/lib/formLocale.ts b/desktop/src/lib/formLocale.ts new file mode 100644 index 000000000..b646f9cd4 --- /dev/null +++ b/desktop/src/lib/formLocale.ts @@ -0,0 +1,60 @@ +import { tauriClient } from './tauriClient'; +import { collectTranslationLocalesFromUiSchema } from './collectTranslationLocales'; + +export const FORM_LOCALE_DEFAULT = 'default' as const; +export type FormLocalePreference = typeof FORM_LOCALE_DEFAULT | string; + +const STORAGE_KEY = '@ode/formLocale'; + +export function getDesktopFormLocalePreference(): FormLocalePreference { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored != null && stored.trim()) { + return stored.trim(); + } + } catch { + // ignore + } + return FORM_LOCALE_DEFAULT; +} + +export function setDesktopFormLocalePreference( + preference: FormLocalePreference, +): void { + localStorage.setItem(STORAGE_KEY, preference); +} + +export async function scanActiveBundleFormLocales(): Promise { + const forms = await tauriClient.listActiveBundleForms(); + const localeSet = new Set(); + for (const form of forms) { + try { + const spec = await tauriClient.readBundleFormSpec(form.formType); + for (const code of collectTranslationLocalesFromUiSchema(spec.uiSchema)) { + localeSet.add(code); + } + } catch { + // skip unreadable forms + } + } + return Array.from(localeSet).sort((a, b) => a.localeCompare(b)); +} + +export function resolveDesktopFormLocale( + sessionOverride?: string | null, + savedFormLocale?: string | null, +): FormLocalePreference { + if (typeof sessionOverride === 'string' && sessionOverride.trim()) { + const trimmed = sessionOverride.trim(); + return trimmed.toLowerCase() === FORM_LOCALE_DEFAULT + ? FORM_LOCALE_DEFAULT + : trimmed; + } + if (typeof savedFormLocale === 'string' && savedFormLocale.trim()) { + const trimmed = savedFormLocale.trim(); + return trimmed.toLowerCase() === FORM_LOCALE_DEFAULT + ? FORM_LOCALE_DEFAULT + : trimmed; + } + return getDesktopFormLocalePreference(); +} diff --git a/desktop/src/pages/FormPreviewPage.tsx b/desktop/src/pages/FormPreviewPage.tsx index ffd5144b2..15adafc64 100644 --- a/desktop/src/pages/FormPreviewPage.tsx +++ b/desktop/src/pages/FormPreviewPage.tsx @@ -28,6 +28,13 @@ import { dropPendingSubObservationOpen, registerPendingSubObservationOpen, } from '../lib/formPreviewSubObservationBridge'; +import { + FORM_LOCALE_DEFAULT, + getDesktopFormLocalePreference, + scanActiveBundleFormLocales, + setDesktopFormLocalePreference, + type FormLocalePreference, +} from '../lib/formLocale'; import { getDesktopLocalePreference, setDesktopLocalePreference, @@ -75,6 +82,9 @@ export function FormPreviewPage() { >(null); const [uiLocalePreference, setUiLocalePreference] = useState(() => getDesktopLocalePreference()); + const [formLocalePreference, setFormLocalePreference] = + useState(() => getDesktopFormLocalePreference()); + const [scannedFormLocales, setScannedFormLocales] = useState([]); const [formInitData, setFormInitData] = useState(null); @@ -145,11 +155,16 @@ export function FormPreviewPage() { setListLoading(true); setListError(null); try { - const rows = await tauriClient.listActiveBundleForms(); + const [rows, locales] = await Promise.all([ + tauriClient.listActiveBundleForms(), + scanActiveBundleFormLocales(), + ]); setForms(rows); + setScannedFormLocales(locales); } catch (e) { setListError(e instanceof Error ? e.message : String(e)); setForms([]); + setScannedFormLocales([]); } finally { setListLoading(false); } @@ -658,6 +673,42 @@ export function FormPreviewPage() { + + {listError ? (

{listError}

) : forms.length === 0 && !listLoading ? ( From 9306ab4b561f983cc83cd0582eb0aa24eceaf1d6 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 3 Jul 2026 14:24:03 +0200 Subject: [PATCH 4/4] fix(formulus): Fix unrelated react-native / jest incompatibility by pinning jest until fixed for reallah --- formulus/jest.config.js | 3 +- formulus/package.json | 17 +- formulus/pnpm-lock.yaml | 865 ++++++++++++------ .../src/lib/collectTranslationLocales.test.ts | 2 +- formulus/src/lib/formLocale.test.ts | 2 +- formulus/src/services/SyncService.ts | 3 +- .../__tests__/SyncService.autoLogin.test.ts | 5 + 7 files changed, 599 insertions(+), 298 deletions(-) diff --git a/formulus/jest.config.js b/formulus/jest.config.js index a6d2e3408..544fbcbed 100644 --- a/formulus/jest.config.js +++ b/formulus/jest.config.js @@ -17,6 +17,7 @@ export default { ], // WatermelonDB / Loki can leave handles open in Jest; force exit avoids hung workers. forceExit: true, - // Set a timeout for the entire test suite + // React Native's jest preset still pulls jest-environment-node@29; jest-runtime@30.4+ + // calls clearMocksOnScope which that mocker lacks. Pin jest to 30.3.x (see package.json). testTimeout: 30000, }; diff --git a/formulus/package.json b/formulus/package.json index b2349a1ea..093ced508 100644 --- a/formulus/package.json +++ b/formulus/package.json @@ -40,7 +40,6 @@ "@react-navigation/bottom-tabs": "^7.9.0", "@react-navigation/native": "^7.1.26", "@react-navigation/stack": "^7.6.13", - "@testing-library/react-native": "^13.3.3", "i18next": "^26.3.4", "lucide-react-native": "^0.577.0", "react": "19.2.4", @@ -80,8 +79,9 @@ "@react-native/eslint-config": "0.84.1", "@react-native/metro-config": "0.83.1", "@react-native/typescript-config": "0.86.0", + "@testing-library/react-native": "^13.3.3", "@types/fs-extra": "^11.0.4", - "@types/jest": "^30.0.0", + "@types/jest": "~30.0.0", "@types/node": "^24.10.4", "@types/react": "^19.2.7", "@types/react-test-renderer": "^19.1.0", @@ -93,7 +93,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-native": "^5.0.0", "globals": "^16.5.0", - "jest": "^30.2.0", + "jest": "30.3.0", "prettier": "3.8.1", "react-test-renderer": "19.2.4", "tsx": "^4.21.0", @@ -102,5 +102,16 @@ }, "engines": { "node": ">=20" + }, + "pnpm": { + "overrides": { + "jest": "30.3.0", + "jest-runtime": "30.3.0", + "@jest/core": "30.3.0", + "jest-cli": "30.3.0", + "jest-config": "30.3.0", + "jest-circus": "30.3.0", + "jest-runner": "30.3.0" + } } } diff --git a/formulus/pnpm-lock.yaml b/formulus/pnpm-lock.yaml index 3dfdbf483..e9a3f27ac 100644 --- a/formulus/pnpm-lock.yaml +++ b/formulus/pnpm-lock.yaml @@ -5,14 +5,13 @@ settings: excludeLinksFromLockfile: false overrides: - jest-environment-node: ^30.4.1 - jest-haste-map: ^30.4.1 - jest-message-util: ^30.4.1 - jest-mock: ^30.4.1 - jest-regex-util: ^30.4.0 - jest-util: ^30.4.1 - jest-validate: ^30.4.1 - jest-worker: ^30.4.1 + jest: 30.3.0 + jest-runtime: 30.3.0 + '@jest/core': 30.3.0 + jest-cli: 30.3.0 + jest-config: 30.3.0 + jest-circus: 30.3.0 + jest-runner: 30.3.0 importers: @@ -63,9 +62,6 @@ importers: '@react-navigation/stack': specifier: ^7.6.13 version: 7.9.2(e77e2ee8455471efeec313358832aef4) - '@testing-library/react-native': - specifier: ^13.3.3 - version: 13.3.3(jest@30.4.2(@types/node@24.12.4))(react-native@0.83.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.4))(react-test-renderer@19.2.4(react@19.2.4))(react@19.2.4) i18next: specifier: ^26.3.4 version: 26.3.4(typescript@5.9.3) @@ -171,18 +167,21 @@ importers: version: 0.84.1(@babel/core@7.29.7) '@react-native/eslint-config': specifier: 0.84.1 - version: 0.84.1(eslint@9.39.4)(jest@30.4.2(@types/node@24.12.4))(prettier@3.8.1)(typescript@5.9.3) + version: 0.84.1(eslint@9.39.4)(jest@30.3.0(@types/node@24.12.4))(prettier@3.8.1)(typescript@5.9.3) '@react-native/metro-config': specifier: 0.83.1 version: 0.83.1(@babel/core@7.29.7) '@react-native/typescript-config': specifier: 0.86.0 version: 0.86.0 + '@testing-library/react-native': + specifier: ^13.3.3 + version: 13.3.3(jest@30.3.0(@types/node@24.12.4))(react-native@0.83.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.4))(react-test-renderer@19.2.4(react@19.2.4))(react@19.2.4) '@types/fs-extra': specifier: ^11.0.4 version: 11.0.4 '@types/jest': - specifier: ^30.0.0 + specifier: ~30.0.0 version: 30.0.0 '@types/node': specifier: ^24.10.4 @@ -218,8 +217,8 @@ importers: specifier: ^16.5.0 version: 16.5.0 jest: - specifier: ^30.2.0 - version: 30.4.2(@types/node@24.12.4) + specifier: 30.3.0 + version: 30.3.0(@types/node@24.12.4) prettier: specifier: 3.8.1 version: 3.8.1 @@ -1467,12 +1466,12 @@ packages: resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} engines: {node: '>=8'} - '@jest/console@30.4.1': - resolution: {integrity: sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==} + '@jest/console@30.3.0': + resolution: {integrity: sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/core@30.4.2': - resolution: {integrity: sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==} + '@jest/core@30.3.0': + resolution: {integrity: sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -1484,40 +1483,60 @@ packages: resolution: {integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/diff-sequences@30.3.0': + resolution: {integrity: sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/diff-sequences@30.4.0': resolution: {integrity: sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/environment@30.4.1': - resolution: {integrity: sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==} + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/environment@30.3.0': + resolution: {integrity: sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect-utils@30.3.0': + resolution: {integrity: sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/expect-utils@30.4.1': resolution: {integrity: sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/expect@30.4.1': - resolution: {integrity: sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==} + '@jest/expect@30.3.0': + resolution: {integrity: sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/fake-timers@30.4.1': - resolution: {integrity: sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==} + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@30.3.0': + resolution: {integrity: sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/get-type@30.1.0': resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/globals@30.4.1': - resolution: {integrity: sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==} + '@jest/globals@30.3.0': + resolution: {integrity: sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/pattern@30.0.1': + resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/pattern@30.4.0': resolution: {integrity: sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/reporters@30.4.1': - resolution: {integrity: sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==} + '@jest/reporters@30.3.0': + resolution: {integrity: sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -1529,38 +1548,46 @@ packages: resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/schemas@30.0.5': + resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/schemas@30.4.1': resolution: {integrity: sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/snapshot-utils@30.4.1': - resolution: {integrity: sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==} + '@jest/snapshot-utils@30.3.0': + resolution: {integrity: sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/source-map@30.0.1': resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/test-result@30.4.1': - resolution: {integrity: sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==} + '@jest/test-result@30.3.0': + resolution: {integrity: sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/test-sequencer@30.4.1': - resolution: {integrity: sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==} + '@jest/test-sequencer@30.3.0': + resolution: {integrity: sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/transform@29.7.0': resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jest/transform@30.4.1': - resolution: {integrity: sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==} + '@jest/transform@30.3.0': + resolution: {integrity: sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/types@29.6.3': resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/types@30.3.0': + resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/types@30.4.1': resolution: {integrity: sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1968,6 +1995,9 @@ packages: '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@sinonjs/fake-timers@15.4.0': resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==} @@ -1975,7 +2005,7 @@ packages: resolution: {integrity: sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==} engines: {node: '>=18'} peerDependencies: - jest: '>=29.0.0' + jest: 30.3.0 react: '>=18.2.0' react-native: '>=0.71' react-test-renderer: '>=18.2.0' @@ -2014,6 +2044,9 @@ packages: '@types/fs-extra@11.0.4': resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + '@types/hammerjs@2.0.46': resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} @@ -2380,8 +2413,8 @@ packages: peerDependencies: '@babel/core': ^7.8.0 - babel-jest@30.4.1: - resolution: {integrity: sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==} + babel-jest@30.3.0: + resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: '@babel/core': ^7.11.0 || ^8.0.0-0 @@ -2398,8 +2431,8 @@ packages: resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - babel-plugin-jest-hoist@30.4.0: - resolution: {integrity: sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==} + babel-plugin-jest-hoist@30.3.0: + resolution: {integrity: sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} babel-plugin-polyfill-corejs2@0.4.17: @@ -2439,8 +2472,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - babel-preset-jest@30.4.0: - resolution: {integrity: sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==} + babel-preset-jest@30.3.0: + resolution: {integrity: sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: '@babel/core': ^7.11.0 || ^8.0.0-beta.1 @@ -2555,6 +2588,10 @@ packages: ci-info@2.0.0: resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} @@ -2968,7 +3005,7 @@ packages: peerDependencies: '@typescript-eslint/eslint-plugin': ^8.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - jest: '*' + jest: 30.3.0 typescript: '>=4.8.4 <7.0.0' peerDependenciesMeta: '@typescript-eslint/eslint-plugin': @@ -3091,6 +3128,10 @@ packages: resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} engines: {node: '>= 0.8.0'} + expect@30.3.0: + resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + expect@30.4.1: resolution: {integrity: sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -3645,16 +3686,16 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jest-changed-files@30.4.1: - resolution: {integrity: sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==} + jest-changed-files@30.3.0: + resolution: {integrity: sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-circus@30.4.2: - resolution: {integrity: sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==} + jest-circus@30.3.0: + resolution: {integrity: sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-cli@30.4.2: - resolution: {integrity: sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==} + jest-cli@30.3.0: + resolution: {integrity: sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: @@ -3663,8 +3704,8 @@ packages: node-notifier: optional: true - jest-config@30.4.2: - resolution: {integrity: sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==} + jest-config@30.3.0: + resolution: {integrity: sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: '@types/node': '*' @@ -3678,38 +3719,74 @@ packages: ts-node: optional: true + jest-diff@30.3.0: + resolution: {integrity: sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-diff@30.4.1: resolution: {integrity: sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-docblock@30.4.0: - resolution: {integrity: sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==} + jest-docblock@30.2.0: + resolution: {integrity: sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-each@30.4.1: - resolution: {integrity: sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==} + jest-each@30.3.0: + resolution: {integrity: sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-environment-node@30.4.1: - resolution: {integrity: sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==} + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-environment-node@30.3.0: + resolution: {integrity: sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@30.3.0: + resolution: {integrity: sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-haste-map@30.4.1: - resolution: {integrity: sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==} + jest-leak-detector@30.3.0: + resolution: {integrity: sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-leak-detector@30.4.1: - resolution: {integrity: sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==} + jest-matcher-utils@30.3.0: + resolution: {integrity: sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} jest-matcher-utils@30.4.1: resolution: {integrity: sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@30.3.0: + resolution: {integrity: sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-message-util@30.4.1: resolution: {integrity: sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@30.3.0: + resolution: {integrity: sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-mock@30.4.1: resolution: {integrity: sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -3723,48 +3800,72 @@ packages: jest-resolve: optional: true + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-regex-util@30.0.1: + resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-regex-util@30.4.0: resolution: {integrity: sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-resolve-dependencies@30.4.2: - resolution: {integrity: sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==} + jest-resolve-dependencies@30.3.0: + resolution: {integrity: sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve@30.3.0: + resolution: {integrity: sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-resolve@30.4.1: - resolution: {integrity: sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==} + jest-runner@30.3.0: + resolution: {integrity: sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-runner@30.4.2: - resolution: {integrity: sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==} + jest-runtime@30.3.0: + resolution: {integrity: sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-runtime@30.4.2: - resolution: {integrity: sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==} + jest-snapshot@30.3.0: + resolution: {integrity: sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-snapshot@30.4.1: - resolution: {integrity: sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==} + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@30.3.0: + resolution: {integrity: sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} jest-util@30.4.1: resolution: {integrity: sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-validate@30.4.1: - resolution: {integrity: sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==} + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@30.3.0: + resolution: {integrity: sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-watcher@30.4.1: - resolution: {integrity: sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==} + jest-watcher@30.3.0: + resolution: {integrity: sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-worker@30.4.1: - resolution: {integrity: sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==} + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@30.3.0: + resolution: {integrity: sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest@30.4.2: - resolution: {integrity: sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==} + jest@30.3.0: + resolution: {integrity: sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: @@ -4338,6 +4439,10 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + pretty-format@30.3.0: + resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + pretty-format@30.4.1: resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -6421,7 +6526,7 @@ snapshots: '@babel/template@7.28.6': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/parser': 7.29.3 '@babel/types': 7.29.0 @@ -6695,44 +6800,43 @@ snapshots: '@istanbuljs/schema@0.1.6': {} - '@jest/console@30.4.1': + '@jest/console@30.3.0': dependencies: - '@jest/types': 30.4.1 + '@jest/types': 30.3.0 '@types/node': 24.12.4 chalk: 4.1.2 - jest-message-util: 30.4.1 - jest-util: 30.4.1 + jest-message-util: 30.3.0 + jest-util: 30.3.0 slash: 3.0.0 - '@jest/core@30.4.2': + '@jest/core@30.3.0': dependencies: - '@jest/console': 30.4.1 - '@jest/pattern': 30.4.0 - '@jest/reporters': 30.4.1 - '@jest/test-result': 30.4.1 - '@jest/transform': 30.4.1 - '@jest/types': 30.4.1 + '@jest/console': 30.3.0 + '@jest/pattern': 30.0.1 + '@jest/reporters': 30.3.0 + '@jest/test-result': 30.3.0 + '@jest/transform': 30.3.0 + '@jest/types': 30.3.0 '@types/node': 24.12.4 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 4.4.0 exit-x: 0.2.2 - fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.11 - jest-changed-files: 30.4.1 - jest-config: 30.4.2(@types/node@24.12.4) - jest-haste-map: 30.4.1 - jest-message-util: 30.4.1 - jest-regex-util: 30.4.0 - jest-resolve: 30.4.1 - jest-resolve-dependencies: 30.4.2 - jest-runner: 30.4.2 - jest-runtime: 30.4.2 - jest-snapshot: 30.4.1 - jest-util: 30.4.1 - jest-validate: 30.4.1 - jest-watcher: 30.4.1 - pretty-format: 30.4.1 + jest-changed-files: 30.3.0 + jest-config: 30.3.0(@types/node@24.12.4) + jest-haste-map: 30.3.0 + jest-message-util: 30.3.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.3.0 + jest-resolve-dependencies: 30.3.0 + jest-runner: 30.3.0 + jest-runtime: 30.3.0 + jest-snapshot: 30.3.0 + jest-util: 30.3.0 + jest-validate: 30.3.0 + jest-watcher: 30.3.0 + pretty-format: 30.3.0 slash: 3.0.0 transitivePeerDependencies: - babel-plugin-macros @@ -6744,58 +6848,85 @@ snapshots: dependencies: '@jest/types': 29.6.3 + '@jest/diff-sequences@30.3.0': {} + '@jest/diff-sequences@30.4.0': {} - '@jest/environment@30.4.1': + '@jest/environment@29.7.0': dependencies: - '@jest/fake-timers': 30.4.1 - '@jest/types': 30.4.1 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 '@types/node': 24.12.4 - jest-mock: 30.4.1 + jest-mock: 29.7.0 + + '@jest/environment@30.3.0': + dependencies: + '@jest/fake-timers': 30.3.0 + '@jest/types': 30.3.0 + '@types/node': 24.12.4 + jest-mock: 30.3.0 + + '@jest/expect-utils@30.3.0': + dependencies: + '@jest/get-type': 30.1.0 '@jest/expect-utils@30.4.1': dependencies: '@jest/get-type': 30.1.0 - '@jest/expect@30.4.1': + '@jest/expect@30.3.0': dependencies: - expect: 30.4.1 - jest-snapshot: 30.4.1 + expect: 30.3.0 + jest-snapshot: 30.3.0 transitivePeerDependencies: - supports-color - '@jest/fake-timers@30.4.1': + '@jest/fake-timers@29.7.0': dependencies: - '@jest/types': 30.4.1 + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 24.12.4 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/fake-timers@30.3.0': + dependencies: + '@jest/types': 30.3.0 '@sinonjs/fake-timers': 15.4.0 '@types/node': 24.12.4 - jest-message-util: 30.4.1 - jest-mock: 30.4.1 - jest-util: 30.4.1 + jest-message-util: 30.3.0 + jest-mock: 30.3.0 + jest-util: 30.3.0 '@jest/get-type@30.1.0': {} - '@jest/globals@30.4.1': + '@jest/globals@30.3.0': dependencies: - '@jest/environment': 30.4.1 - '@jest/expect': 30.4.1 - '@jest/types': 30.4.1 - jest-mock: 30.4.1 + '@jest/environment': 30.3.0 + '@jest/expect': 30.3.0 + '@jest/types': 30.3.0 + jest-mock: 30.3.0 transitivePeerDependencies: - supports-color + '@jest/pattern@30.0.1': + dependencies: + '@types/node': 24.12.4 + jest-regex-util: 30.0.1 + '@jest/pattern@30.4.0': dependencies: '@types/node': 24.12.4 jest-regex-util: 30.4.0 - '@jest/reporters@30.4.1': + '@jest/reporters@30.3.0': dependencies: '@bcoe/v8-coverage': 0.2.3 - '@jest/console': 30.4.1 - '@jest/test-result': 30.4.1 - '@jest/transform': 30.4.1 - '@jest/types': 30.4.1 + '@jest/console': 30.3.0 + '@jest/test-result': 30.3.0 + '@jest/transform': 30.3.0 + '@jest/types': 30.3.0 '@jridgewell/trace-mapping': 0.3.31 '@types/node': 24.12.4 chalk: 4.1.2 @@ -6808,9 +6939,9 @@ snapshots: istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 - jest-message-util: 30.4.1 - jest-util: 30.4.1 - jest-worker: 30.4.1 + jest-message-util: 30.3.0 + jest-util: 30.3.0 + jest-worker: 30.3.0 slash: 3.0.0 string-length: 4.0.2 v8-to-istanbul: 9.3.0 @@ -6821,13 +6952,17 @@ snapshots: dependencies: '@sinclair/typebox': 0.27.10 + '@jest/schemas@30.0.5': + dependencies: + '@sinclair/typebox': 0.34.49 + '@jest/schemas@30.4.1': dependencies: '@sinclair/typebox': 0.34.49 - '@jest/snapshot-utils@30.4.1': + '@jest/snapshot-utils@30.3.0': dependencies: - '@jest/types': 30.4.1 + '@jest/types': 30.3.0 chalk: 4.1.2 graceful-fs: 4.2.11 natural-compare: 1.4.0 @@ -6838,18 +6973,18 @@ snapshots: callsites: 3.1.0 graceful-fs: 4.2.11 - '@jest/test-result@30.4.1': + '@jest/test-result@30.3.0': dependencies: - '@jest/console': 30.4.1 - '@jest/types': 30.4.1 + '@jest/console': 30.3.0 + '@jest/types': 30.3.0 '@types/istanbul-lib-coverage': 2.0.6 collect-v8-coverage: 1.0.3 - '@jest/test-sequencer@30.4.1': + '@jest/test-sequencer@30.3.0': dependencies: - '@jest/test-result': 30.4.1 + '@jest/test-result': 30.3.0 graceful-fs: 4.2.11 - jest-haste-map: 30.4.1 + jest-haste-map: 30.3.0 slash: 3.0.0 '@jest/transform@29.7.0': @@ -6862,9 +6997,9 @@ snapshots: convert-source-map: 2.0.0 fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.11 - jest-haste-map: 30.4.1 - jest-regex-util: 30.4.0 - jest-util: 30.4.1 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 micromatch: 4.0.8 pirates: 4.0.7 slash: 3.0.0 @@ -6872,19 +7007,19 @@ snapshots: transitivePeerDependencies: - supports-color - '@jest/transform@30.4.1': + '@jest/transform@30.3.0': dependencies: '@babel/core': 7.29.7 - '@jest/types': 30.4.1 + '@jest/types': 30.3.0 '@jridgewell/trace-mapping': 0.3.31 babel-plugin-istanbul: 7.0.1 chalk: 4.1.2 convert-source-map: 2.0.0 fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.11 - jest-haste-map: 30.4.1 - jest-regex-util: 30.4.0 - jest-util: 30.4.1 + jest-haste-map: 30.3.0 + jest-regex-util: 30.0.1 + jest-util: 30.3.0 pirates: 4.0.7 slash: 3.0.0 write-file-atomic: 5.0.1 @@ -6900,6 +7035,16 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 + '@jest/types@30.3.0': + dependencies: + '@jest/pattern': 30.0.1 + '@jest/schemas': 30.0.5 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 24.12.4 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + '@jest/types@30.4.1': dependencies: '@jest/pattern': 30.4.0 @@ -7424,7 +7569,7 @@ snapshots: - supports-color - utf-8-validate - '@react-native/eslint-config@0.84.1(eslint@9.39.4)(jest@30.4.2(@types/node@24.12.4))(prettier@3.8.1)(typescript@5.9.3)': + '@react-native/eslint-config@0.84.1(eslint@9.39.4)(jest@30.3.0(@types/node@24.12.4))(prettier@3.8.1)(typescript@5.9.3)': dependencies: '@babel/core': 7.29.7 '@babel/eslint-parser': 7.28.6(@babel/core@7.29.7)(eslint@9.39.4) @@ -7435,7 +7580,7 @@ snapshots: eslint-config-prettier: 8.10.2(eslint@9.39.4) eslint-plugin-eslint-comments: 3.2.0(eslint@9.39.4) eslint-plugin-ft-flow: 2.0.3(@babel/eslint-parser@7.28.6(@babel/core@7.29.7)(eslint@9.39.4))(eslint@9.39.4) - eslint-plugin-jest: 29.15.2(@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(jest@30.4.2(@types/node@24.12.4))(typescript@5.9.3) + eslint-plugin-jest: 29.15.2(@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(jest@30.3.0(@types/node@24.12.4))(typescript@5.9.3) eslint-plugin-react: 7.37.5(eslint@9.39.4) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4) eslint-plugin-react-native: 5.0.0(eslint@9.39.4) @@ -7564,11 +7709,15 @@ snapshots: dependencies: type-detect: 4.0.8 + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers@15.4.0': dependencies: '@sinonjs/commons': 3.0.1 - '@testing-library/react-native@13.3.3(jest@30.4.2(@types/node@24.12.4))(react-native@0.83.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.4))(react-test-renderer@19.2.4(react@19.2.4))(react@19.2.4)': + '@testing-library/react-native@13.3.3(jest@30.3.0(@types/node@24.12.4))(react-native@0.83.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.4))(react-test-renderer@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: jest-matcher-utils: 30.4.1 picocolors: 1.1.1 @@ -7578,7 +7727,7 @@ snapshots: react-test-renderer: 19.2.4(react@19.2.4) redent: 3.0.0 optionalDependencies: - jest: 30.4.2(@types/node@24.12.4) + jest: 30.3.0(@types/node@24.12.4) '@tokenizer/inflate@0.4.1': dependencies: @@ -7624,6 +7773,10 @@ snapshots: '@types/jsonfile': 6.1.4 '@types/node': 24.12.4 + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 24.12.4 + '@types/hammerjs@2.0.46': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -8009,13 +8162,13 @@ snapshots: transitivePeerDependencies: - supports-color - babel-jest@30.4.1(@babel/core@7.29.7): + babel-jest@30.3.0(@babel/core@7.29.7): dependencies: '@babel/core': 7.29.7 - '@jest/transform': 30.4.1 + '@jest/transform': 30.3.0 '@types/babel__core': 7.20.5 babel-plugin-istanbul: 7.0.1 - babel-preset-jest: 30.4.0(@babel/core@7.29.7) + babel-preset-jest: 30.3.0(@babel/core@7.29.7) chalk: 4.1.2 graceful-fs: 4.2.11 slash: 3.0.0 @@ -8034,7 +8187,7 @@ snapshots: babel-plugin-istanbul@7.0.1: dependencies: - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.6 istanbul-lib-instrument: 6.0.3 @@ -8049,7 +8202,7 @@ snapshots: '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 - babel-plugin-jest-hoist@30.4.0: + babel-plugin-jest-hoist@30.3.0: dependencies: '@types/babel__core': 7.20.5 @@ -8120,10 +8273,10 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) - babel-preset-jest@30.4.0(@babel/core@7.29.7): + babel-preset-jest@30.3.0(@babel/core@7.29.7): dependencies: '@babel/core': 7.29.7 - babel-plugin-jest-hoist: 30.4.0 + babel-plugin-jest-hoist: 30.3.0 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) balanced-match@1.0.2: {} @@ -8252,6 +8405,8 @@ snapshots: ci-info@2.0.0: {} + ci-info@3.9.0: {} + ci-info@4.4.0: {} cjs-module-lexer@2.2.0: {} @@ -8714,13 +8869,13 @@ snapshots: lodash: 4.18.1 string-natural-compare: 3.0.1 - eslint-plugin-jest@29.15.2(@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(jest@30.4.2(@types/node@24.12.4))(typescript@5.9.3): + eslint-plugin-jest@29.15.2(@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(jest@30.3.0(@types/node@24.12.4))(typescript@5.9.3): dependencies: '@typescript-eslint/utils': 8.59.3(eslint@9.39.4)(typescript@5.9.3) eslint: 9.39.4 optionalDependencies: '@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - jest: 30.4.2(@types/node@24.12.4) + jest: 30.3.0(@types/node@24.12.4) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -8871,6 +9026,15 @@ snapshots: exit-x@0.2.2: {} + expect@30.3.0: + dependencies: + '@jest/expect-utils': 30.3.0 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.3.0 + jest-message-util: 30.3.0 + jest-mock: 30.3.0 + jest-util: 30.3.0 + expect@30.4.1: dependencies: '@jest/expect-utils': 30.4.1 @@ -9412,7 +9576,7 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.3 + '@babel/parser': 7.29.7 '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 semver: 7.8.0 @@ -9455,31 +9619,31 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jest-changed-files@30.4.1: + jest-changed-files@30.3.0: dependencies: execa: 5.1.1 - jest-util: 30.4.1 + jest-util: 30.3.0 p-limit: 3.1.0 - jest-circus@30.4.2: + jest-circus@30.3.0: dependencies: - '@jest/environment': 30.4.1 - '@jest/expect': 30.4.1 - '@jest/test-result': 30.4.1 - '@jest/types': 30.4.1 + '@jest/environment': 30.3.0 + '@jest/expect': 30.3.0 + '@jest/test-result': 30.3.0 + '@jest/types': 30.3.0 '@types/node': 24.12.4 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.2 is-generator-fn: 2.1.0 - jest-each: 30.4.1 - jest-matcher-utils: 30.4.1 - jest-message-util: 30.4.1 - jest-runtime: 30.4.2 - jest-snapshot: 30.4.1 - jest-util: 30.4.1 + jest-each: 30.3.0 + jest-matcher-utils: 30.3.0 + jest-message-util: 30.3.0 + jest-runtime: 30.3.0 + jest-snapshot: 30.3.0 + jest-util: 30.3.0 p-limit: 3.1.0 - pretty-format: 30.4.1 + pretty-format: 30.3.0 pure-rand: 7.0.1 slash: 3.0.0 stack-utils: 2.0.6 @@ -9487,17 +9651,17 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.4.2(@types/node@24.12.4): + jest-cli@30.3.0(@types/node@24.12.4): dependencies: - '@jest/core': 30.4.2 - '@jest/test-result': 30.4.1 - '@jest/types': 30.4.1 + '@jest/core': 30.3.0 + '@jest/test-result': 30.3.0 + '@jest/types': 30.3.0 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.4.2(@types/node@24.12.4) - jest-util: 30.4.1 - jest-validate: 30.4.1 + jest-config: 30.3.0(@types/node@24.12.4) + jest-util: 30.3.0 + jest-validate: 30.3.0 yargs: 17.7.2 transitivePeerDependencies: - '@types/node' @@ -9506,29 +9670,29 @@ snapshots: - supports-color - ts-node - jest-config@30.4.2(@types/node@24.12.4): + jest-config@30.3.0(@types/node@24.12.4): dependencies: '@babel/core': 7.29.7 '@jest/get-type': 30.1.0 - '@jest/pattern': 30.4.0 - '@jest/test-sequencer': 30.4.1 - '@jest/types': 30.4.1 - babel-jest: 30.4.1(@babel/core@7.29.7) + '@jest/pattern': 30.0.1 + '@jest/test-sequencer': 30.3.0 + '@jest/types': 30.3.0 + babel-jest: 30.3.0(@babel/core@7.29.7) chalk: 4.1.2 ci-info: 4.4.0 deepmerge: 4.3.1 glob: 10.5.0 graceful-fs: 4.2.11 - jest-circus: 30.4.2 - jest-docblock: 30.4.0 - jest-environment-node: 30.4.1 - jest-regex-util: 30.4.0 - jest-resolve: 30.4.1 - jest-runner: 30.4.2 - jest-util: 30.4.1 - jest-validate: 30.4.1 + jest-circus: 30.3.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.3.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.3.0 + jest-runner: 30.3.0 + jest-util: 30.3.0 + jest-validate: 30.3.0 parse-json: 5.2.0 - pretty-format: 30.4.1 + pretty-format: 30.3.0 slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: @@ -9537,6 +9701,13 @@ snapshots: - babel-plugin-macros - supports-color + jest-diff@30.3.0: + dependencies: + '@jest/diff-sequences': 30.3.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.3.0 + jest-diff@30.4.1: dependencies: '@jest/diff-sequences': 30.4.0 @@ -9544,47 +9715,81 @@ snapshots: chalk: 4.1.2 pretty-format: 30.4.1 - jest-docblock@30.4.0: + jest-docblock@30.2.0: dependencies: detect-newline: 3.1.0 - jest-each@30.4.1: + jest-each@30.3.0: dependencies: '@jest/get-type': 30.1.0 - '@jest/types': 30.4.1 + '@jest/types': 30.3.0 chalk: 4.1.2 - jest-util: 30.4.1 - pretty-format: 30.4.1 + jest-util: 30.3.0 + pretty-format: 30.3.0 - jest-environment-node@30.4.1: + jest-environment-node@29.7.0: dependencies: - '@jest/environment': 30.4.1 - '@jest/fake-timers': 30.4.1 - '@jest/types': 30.4.1 + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 '@types/node': 24.12.4 - jest-mock: 30.4.1 - jest-util: 30.4.1 - jest-validate: 30.4.1 + jest-mock: 29.7.0 + jest-util: 29.7.0 - jest-haste-map@30.4.1: + jest-environment-node@30.3.0: dependencies: - '@jest/types': 30.4.1 + '@jest/environment': 30.3.0 + '@jest/fake-timers': 30.3.0 + '@jest/types': 30.3.0 + '@types/node': 24.12.4 + jest-mock: 30.3.0 + jest-util: 30.3.0 + jest-validate: 30.3.0 + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 '@types/node': 24.12.4 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 - jest-regex-util: 30.4.0 - jest-util: 30.4.1 - jest-worker: 30.4.1 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-haste-map@30.3.0: + dependencies: + '@jest/types': 30.3.0 + '@types/node': 24.12.4 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 30.0.1 + jest-util: 30.3.0 + jest-worker: 30.3.0 picomatch: 4.0.4 walker: 1.0.8 optionalDependencies: fsevents: 2.3.3 - jest-leak-detector@30.4.1: + jest-leak-detector@30.3.0: dependencies: '@jest/get-type': 30.1.0 - pretty-format: 30.4.1 + pretty-format: 30.3.0 + + jest-matcher-utils@30.3.0: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.3.0 + pretty-format: 30.3.0 jest-matcher-utils@30.4.1: dependencies: @@ -9593,9 +9798,33 @@ snapshots: jest-diff: 30.4.1 pretty-format: 30.4.1 + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.29.7 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-message-util@30.3.0: + dependencies: + '@babel/code-frame': 7.29.7 + '@jest/types': 30.3.0 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + picomatch: 4.0.4 + pretty-format: 30.3.0 + slash: 3.0.0 + stack-utils: 2.0.6 + jest-message-util@30.4.1: dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@jest/types': 30.4.1 '@types/stack-utils': 2.0.3 chalk: 4.1.2 @@ -9606,116 +9835,150 @@ snapshots: slash: 3.0.0 stack-utils: 2.0.6 + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 24.12.4 + jest-util: 29.7.0 + + jest-mock@30.3.0: + dependencies: + '@jest/types': 30.3.0 + '@types/node': 24.12.4 + jest-util: 30.3.0 + jest-mock@30.4.1: dependencies: '@jest/types': 30.4.1 '@types/node': 24.12.4 jest-util: 30.4.1 - jest-pnp-resolver@1.2.3(jest-resolve@30.4.1): + jest-pnp-resolver@1.2.3(jest-resolve@30.3.0): optionalDependencies: - jest-resolve: 30.4.1 + jest-resolve: 30.3.0 + + jest-regex-util@29.6.3: {} + + jest-regex-util@30.0.1: {} jest-regex-util@30.4.0: {} - jest-resolve-dependencies@30.4.2: + jest-resolve-dependencies@30.3.0: dependencies: - jest-regex-util: 30.4.0 - jest-snapshot: 30.4.1 + jest-regex-util: 30.0.1 + jest-snapshot: 30.3.0 transitivePeerDependencies: - supports-color - jest-resolve@30.4.1: + jest-resolve@30.3.0: dependencies: chalk: 4.1.2 graceful-fs: 4.2.11 - jest-haste-map: 30.4.1 - jest-pnp-resolver: 1.2.3(jest-resolve@30.4.1) - jest-util: 30.4.1 - jest-validate: 30.4.1 + jest-haste-map: 30.3.0 + jest-pnp-resolver: 1.2.3(jest-resolve@30.3.0) + jest-util: 30.3.0 + jest-validate: 30.3.0 slash: 3.0.0 unrs-resolver: 1.11.1 - jest-runner@30.4.2: + jest-runner@30.3.0: dependencies: - '@jest/console': 30.4.1 - '@jest/environment': 30.4.1 - '@jest/test-result': 30.4.1 - '@jest/transform': 30.4.1 - '@jest/types': 30.4.1 + '@jest/console': 30.3.0 + '@jest/environment': 30.3.0 + '@jest/test-result': 30.3.0 + '@jest/transform': 30.3.0 + '@jest/types': 30.3.0 '@types/node': 24.12.4 chalk: 4.1.2 emittery: 0.13.1 exit-x: 0.2.2 graceful-fs: 4.2.11 - jest-docblock: 30.4.0 - jest-environment-node: 30.4.1 - jest-haste-map: 30.4.1 - jest-leak-detector: 30.4.1 - jest-message-util: 30.4.1 - jest-resolve: 30.4.1 - jest-runtime: 30.4.2 - jest-util: 30.4.1 - jest-watcher: 30.4.1 - jest-worker: 30.4.1 + jest-docblock: 30.2.0 + jest-environment-node: 30.3.0 + jest-haste-map: 30.3.0 + jest-leak-detector: 30.3.0 + jest-message-util: 30.3.0 + jest-resolve: 30.3.0 + jest-runtime: 30.3.0 + jest-util: 30.3.0 + jest-watcher: 30.3.0 + jest-worker: 30.3.0 p-limit: 3.1.0 source-map-support: 0.5.13 transitivePeerDependencies: - supports-color - jest-runtime@30.4.2: + jest-runtime@30.3.0: dependencies: - '@jest/environment': 30.4.1 - '@jest/fake-timers': 30.4.1 - '@jest/globals': 30.4.1 + '@jest/environment': 30.3.0 + '@jest/fake-timers': 30.3.0 + '@jest/globals': 30.3.0 '@jest/source-map': 30.0.1 - '@jest/test-result': 30.4.1 - '@jest/transform': 30.4.1 - '@jest/types': 30.4.1 + '@jest/test-result': 30.3.0 + '@jest/transform': 30.3.0 + '@jest/types': 30.3.0 '@types/node': 24.12.4 chalk: 4.1.2 cjs-module-lexer: 2.2.0 collect-v8-coverage: 1.0.3 glob: 10.5.0 graceful-fs: 4.2.11 - jest-haste-map: 30.4.1 - jest-message-util: 30.4.1 - jest-mock: 30.4.1 - jest-regex-util: 30.4.0 - jest-resolve: 30.4.1 - jest-snapshot: 30.4.1 - jest-util: 30.4.1 + jest-haste-map: 30.3.0 + jest-message-util: 30.3.0 + jest-mock: 30.3.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.3.0 + jest-snapshot: 30.3.0 + jest-util: 30.3.0 slash: 3.0.0 strip-bom: 4.0.0 transitivePeerDependencies: - supports-color - jest-snapshot@30.4.1: + jest-snapshot@30.3.0: dependencies: '@babel/core': 7.29.7 - '@babel/generator': 7.29.1 + '@babel/generator': 7.29.7 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7) - '@babel/types': 7.29.0 - '@jest/expect-utils': 30.4.1 + '@babel/types': 7.29.7 + '@jest/expect-utils': 30.3.0 '@jest/get-type': 30.1.0 - '@jest/snapshot-utils': 30.4.1 - '@jest/transform': 30.4.1 - '@jest/types': 30.4.1 + '@jest/snapshot-utils': 30.3.0 + '@jest/transform': 30.3.0 + '@jest/types': 30.3.0 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) chalk: 4.1.2 - expect: 30.4.1 + expect: 30.3.0 graceful-fs: 4.2.11 - jest-diff: 30.4.1 - jest-matcher-utils: 30.4.1 - jest-message-util: 30.4.1 - jest-util: 30.4.1 - pretty-format: 30.4.1 + jest-diff: 30.3.0 + jest-matcher-utils: 30.3.0 + jest-message-util: 30.3.0 + jest-util: 30.3.0 + pretty-format: 30.3.0 semver: 7.8.0 synckit: 0.11.12 transitivePeerDependencies: - supports-color + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 24.12.4 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 + + jest-util@30.3.0: + dependencies: + '@jest/types': 30.3.0 + '@types/node': 24.12.4 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.4 + jest-util@30.4.1: dependencies: '@jest/types': 30.4.1 @@ -9725,40 +9988,56 @@ snapshots: graceful-fs: 4.2.11 picomatch: 4.0.4 - jest-validate@30.4.1: + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-validate@30.3.0: dependencies: '@jest/get-type': 30.1.0 - '@jest/types': 30.4.1 + '@jest/types': 30.3.0 camelcase: 6.3.0 chalk: 4.1.2 leven: 3.1.0 - pretty-format: 30.4.1 + pretty-format: 30.3.0 - jest-watcher@30.4.1: + jest-watcher@30.3.0: dependencies: - '@jest/test-result': 30.4.1 - '@jest/types': 30.4.1 + '@jest/test-result': 30.3.0 + '@jest/types': 30.3.0 '@types/node': 24.12.4 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 - jest-util: 30.4.1 + jest-util: 30.3.0 string-length: 4.0.2 - jest-worker@30.4.1: + jest-worker@29.7.0: + dependencies: + '@types/node': 24.12.4 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest-worker@30.3.0: dependencies: '@types/node': 24.12.4 '@ungap/structured-clone': 1.3.1 - jest-util: 30.4.1 + jest-util: 30.3.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.4.2(@types/node@24.12.4): + jest@30.3.0(@types/node@24.12.4): dependencies: - '@jest/core': 30.4.2 - '@jest/types': 30.4.1 + '@jest/core': 30.3.0 + '@jest/types': 30.3.0 import-local: 3.2.0 - jest-cli: 30.4.2(@types/node@24.12.4) + jest-cli: 30.3.0(@types/node@24.12.4) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -9949,7 +10228,7 @@ snapshots: dependencies: connect: 3.7.0 flow-enums-runtime: 0.0.6 - jest-validate: 30.4.1 + jest-validate: 29.7.0 metro: 0.83.7 metro-cache: 0.83.7 metro-core: 0.83.7 @@ -9973,7 +10252,7 @@ snapshots: flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 invariant: 2.2.4 - jest-worker: 30.4.1 + jest-worker: 29.7.0 micromatch: 4.0.8 nullthrows: 1.1.1 walker: 1.0.8 @@ -10052,7 +10331,7 @@ snapshots: metro@0.83.7: dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7 '@babel/generator': 7.29.1 '@babel/parser': 7.29.3 @@ -10069,7 +10348,7 @@ snapshots: hermes-parser: 0.35.0 image-size: 1.2.1 invariant: 2.2.4 - jest-worker: 30.4.1 + jest-worker: 29.7.0 jsc-safe-url: 0.2.4 lodash.throttle: 4.1.1 metro-babel-transformer: 0.83.7 @@ -10334,7 +10613,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -10399,6 +10678,12 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + pretty-format@30.3.0: + dependencies: + '@jest/schemas': 30.0.5 + ansi-styles: 5.2.0 + react-is: 18.3.1 + pretty-format@30.4.1: dependencies: '@jest/schemas': 30.4.1 @@ -10613,7 +10898,7 @@ snapshots: glob: 7.2.3 hermes-compiler: 0.14.0 invariant: 2.2.4 - jest-environment-node: 30.4.1 + jest-environment-node: 29.7.0 memoize-one: 5.2.1 metro-runtime: 0.83.7 metro-source-map: 0.83.7 diff --git a/formulus/src/lib/collectTranslationLocales.test.ts b/formulus/src/lib/collectTranslationLocales.test.ts index 80fb713ff..abfb4896e 100644 --- a/formulus/src/lib/collectTranslationLocales.test.ts +++ b/formulus/src/lib/collectTranslationLocales.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from '@jest/globals'; import { collectTranslationLocalesFromUiSchema } from './collectTranslationLocales'; describe('collectTranslationLocalesFromUiSchema', () => { diff --git a/formulus/src/lib/formLocale.test.ts b/formulus/src/lib/formLocale.test.ts index 598ab6488..ecb7dfdac 100644 --- a/formulus/src/lib/formLocale.test.ts +++ b/formulus/src/lib/formLocale.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from '@jest/globals'; import { FORM_LOCALE_DEFAULT, isStaleFormLocalePreference, diff --git a/formulus/src/services/SyncService.ts b/formulus/src/services/SyncService.ts index 5c19f9c76..a4b79af5b 100644 --- a/formulus/src/services/SyncService.ts +++ b/formulus/src/services/SyncService.ts @@ -6,6 +6,7 @@ import type { SynkronusSyncOptions } from '../sync/syncProgress'; import { notificationService } from './NotificationService'; import { getUserFacingAppBundleUpdateErrorMessage } from './appBundleUpdateErrors'; import { FormService } from './FormService'; +import { formLocaleIndexService } from './FormLocaleIndexService'; import { autoLogin, getUserFacingSyncErrorMessage, @@ -397,8 +398,6 @@ export class SyncService { const formService = await FormService.getInstance(); await formService.invalidateCache(); - const { formLocaleIndexService } = - await import('./FormLocaleIndexService'); await formLocaleIndexService.refreshIndex(); const syncTime = new Date().toLocaleTimeString(); diff --git a/formulus/src/services/__tests__/SyncService.autoLogin.test.ts b/formulus/src/services/__tests__/SyncService.autoLogin.test.ts index 9378a64d9..aa4bc292a 100644 --- a/formulus/src/services/__tests__/SyncService.autoLogin.test.ts +++ b/formulus/src/services/__tests__/SyncService.autoLogin.test.ts @@ -113,6 +113,11 @@ jest.mock('../FormService', () => ({ }), }, })); +jest.mock('../FormLocaleIndexService', () => ({ + formLocaleIndexService: { + refreshIndex: jest.fn().mockResolvedValue([]), + }, +})); import { jest,