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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ git push origin v1.1.1-alpha.3
- **Custom app bridge (v1.1.0+):** `persistObservation` (headless write), `sync`, `getConnectivityStatus`, `getCurrentDataRevisionCount`, and `openFormplayer` options `skipFinalize` / `skipDraftSelection` — contract in [`FormulusInterfaceDefinition.ts`](formulus/src/webview/FormulusInterfaceDefinition.ts); run `pnpm run sync-interface` in formplayer after changes.
- **Sub-observations:** Each nested Formplayer session validates its own schema; `skipFinalize` only skips the Finalize page. Custom validators are per-session — see [Custom Extensions — nested sessions](https://opendataensemble.org/docs/guides/custom-extensions#nested-sessions-and-custom-validators) (docs site).
- **Shared UI tokens:** Install **tokens** before **components** / **formplayer** where the docs require it (see package READMEs and formplayer AGENTS).
- **i18n (two layers):** ODE-owned locales (`en`/`pt`/`fr`) for Formulus + Formplayer chrome via Settings → Language; form-owned copy via optional `translations` on `ui.json` elements (preprocessed at form init). See [FORM_LOCALIZATION_GUIDE.md](FORM_LOCALIZATION_GUIDE.md) (monorepo) and [form translations guide](https://opendataensemble.org/docs/guides/form-translations) (published).
- **i18n (two layers):** ODE-owned locales (`en`/`pt`/`fr`/`sw`) for Formulus + Formplayer chrome via Settings → Language; form-owned copy via optional `translations` on `ui.json` elements (preprocessed at form init). See [FORM_LOCALIZATION_GUIDE.md](FORM_LOCALIZATION_GUIDE.md) (monorepo) and [form translations guide](https://opendataensemble.org/docs/guides/form-translations) (published).

---

Expand Down
6 changes: 3 additions & 3 deletions FORM_LOCALIZATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ How **user-defined forms** (JSON Schema + `ui.json` in custom app bundles) suppo

| Layer | Who owns it | Where strings live | When it applies |
|-------|-------------|-------------------|-----------------|
| **ODE chrome** | ODE platform | `formulus-formplayer/src/locales/{en,pt,fr}.json`, Formulus i18n catalogs | Next/Back, validation errors, sub-obs “+ Add …” template, loading text |
| **ODE chrome** | ODE platform | `formulus-formplayer/src/locales/{en,pt,fr,sw}.json`, Formulus i18n catalogs | Next/Back, validation errors, sub-obs “+ Add …” template, loading text |
| **Form copy** | Form / app author | `ui.json` base fields + optional `translations` blocks | Field labels, descriptions, SwipeLayout headers, custom widget copy |

Form authors **do not** edit ODE locale JSON. They embed translations in each form’s `ui.json`. ODE developers extend chrome strings when adding new platform UI.
Expand All @@ -21,7 +21,7 @@ Form authors **do not** edit ODE locale JSON. They embed translations in each fo

## How form locale is chosen

1. User setting in Formulus **Settings → Language** (`auto`, `en`, `pt`, `fr`)
1. User setting in Formulus **Settings → Language** (`auto`, `en`, `pt`, `fr`, `sw`)
2. **Auto** → device language; if unsupported, bundle `defaultLocale` from `app.config.json`
3. Fallback `en`

Expand Down Expand Up @@ -240,7 +240,7 @@ When changing merge rules or label resolution, update **`applyFormUiTranslations
## Author checklist

- [ ] Every visible field has `Control.label` in `ui.json` (not only `schema.title`)
- [ ] Base `label` set; `translations` added for other ODE UI locales you ship (`en`, `pt`, `fr`)
- [ ] Base `label` set; `translations` added for other ODE UI locales you ship (`en`, `pt`, `fr`, `sw`)
- [ ] SwipeLayout headers/buttons translated where needed
- [ ] Sub-obs columns use `key` only; labels live on linked child forms
- [ ] Custom question type strings in `options`, not in renderer JS
Expand Down
8 changes: 3 additions & 5 deletions desktop/src/lib/uiLocale.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** Desktop UI locale — mirrors Formulus Settings key and resolution rules. */

const STORAGE_KEY = '@ode/uiLocale';
const ODE_UI_LOCALES = ['en', 'pt', 'fr'] as const;
const ODE_UI_LOCALES = ['en', 'pt', 'fr', 'sw'] as const;
export type DesktopUiLocale = (typeof ODE_UI_LOCALES)[number];
export type UiLocalePreference = 'auto' | DesktopUiLocale;

Expand Down Expand Up @@ -32,11 +32,9 @@ export function getDesktopLocalePreference(): UiLocalePreference {
const stored = localStorage.getItem(STORAGE_KEY);
if (
stored === 'auto' ||
stored === 'en' ||
stored === 'pt' ||
stored === 'fr'
(stored != null && (ODE_UI_LOCALES as readonly string[]).includes(stored))
) {
return stored;
return stored as UiLocalePreference;
}
} catch {
// ignore
Expand Down
1 change: 1 addition & 0 deletions desktop/src/pages/FormPreviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,7 @@ export function FormPreviewPage() {
<option value="en">English</option>
<option value="pt">Português</option>
<option value="fr">Français</option>
<option value="sw">Kiswahili</option>
</select>
<select
id="form-locale-select"
Expand Down
8 changes: 4 additions & 4 deletions formulus-formplayer/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,10 @@ When a Control becomes not visible (`visible: false` from JSON Forms rules), For

Two **independent** layers:

| Layer | Owner | Mechanism |
| ---------- | ----------- | ----------------------------------------------------------------------------------------- |
| ODE chrome | ODE | `src/locales/{en,pt,fr}.json`, `createOdeI18n()`, JsonForms `i18n` prop, `useOdeT()` hook |
| Form copy | Form author | Optional `translations` on `ui.json`; `applyFormUiTranslations()` at init in `App.tsx` |
| Layer | Owner | Mechanism |
| ---------- | ----------- | -------------------------------------------------------------------------------------------- |
| ODE chrome | ODE | `src/locales/{en,pt,fr,sw}.json`, `createOdeI18n()`, JsonForms `i18n` prop, `useOdeT()` hook |
| Form copy | Form author | Optional `translations` on `ui.json`; `applyFormUiTranslations()` at init in `App.tsx` |

- Host passes `params.locale` (reserved bridge key, not observation data).
- Custom question types receive localized `options` from `ui.json` via `CustomQuestionTypeAdapter`.
Expand Down
2 changes: 1 addition & 1 deletion formulus-formplayer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Use `pnpm run build:copy` to build the project and copy the bundle into the Form

## Internationalization

- **ODE chrome** — `src/locales/{en,pt,fr}.json`, wired via `createOdeI18n()` and JsonForms `i18n`. Host sets `params.locale`.
- **ODE chrome** — `src/locales/{en,pt,fr,sw}.json`, wired via `createOdeI18n()` and JsonForms `i18n`. Host sets `params.locale`.
- **Form copy** — optional `translations` objects on `ui.json`; merged once at init by `applyFormUiTranslations()`. See [form translations](https://opendataensemble.org/docs/guides/form-translations).

## Javascript interface
Expand Down
2 changes: 2 additions & 0 deletions formulus-formplayer/src/i18n/createOdeI18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { JsonFormsI18nState, Translator } from '@jsonforms/core';
import en from '../locales/en.json';
import pt from '../locales/pt.json';
import fr from '../locales/fr.json';
import sw from '../locales/sw.json';
import type { OdeUiLocale } from './localeUtils';

type Catalog = Record<string, string>;
Expand All @@ -11,6 +12,7 @@ const CATALOGS: Record<OdeUiLocale, Catalog> = {
en: en as Catalog,
pt: pt as Catalog,
fr: fr as Catalog,
sw: sw as Catalog,
};

/** Simple {{key}} interpolation for catalog strings. */
Expand Down
23 changes: 23 additions & 0 deletions formulus-formplayer/src/i18n/localeUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { matchOdeCatalogLocale, resolveFormplayerLocale } from './localeUtils';
import { odeT } from './createOdeI18n';
import en from '../locales/en.json';
import sw from '../locales/sw.json';

describe('Formplayer locale resolution', () => {
it('matches explicit sw and regional tags to the Swahili catalog', () => {
expect(matchOdeCatalogLocale('sw')).toBe('sw');
expect(matchOdeCatalogLocale('sw-KE')).toBe('sw');
expect(matchOdeCatalogLocale('sw-TZ')).toBe('sw');
expect(resolveFormplayerLocale('sw-KE')).toBe('sw');
expect(resolveFormplayerLocale('sw-TZ')).toBe('sw');
});

it('Swahili catalog matches English keys and is not an English fallback', () => {
const enKeys = Object.keys(en).sort();
const swKeys = Object.keys(sw).sort();
expect(swKeys).toEqual(enKeys);
expect(odeT('sw', 'nav.nextScreen')).toBe('Skrini inayofuata');
expect(odeT('sw', 'nav.nextScreen')).not.toBe(en['nav.nextScreen']);
});
});
2 changes: 1 addition & 1 deletion formulus-formplayer/src/i18n/localeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Locale utilities (mirrored from formulus/src/lib/locale.ts for Formplayer).
*/

export const ODE_UI_LOCALES = ['en', 'pt', 'fr'] as const;
export const ODE_UI_LOCALES = ['en', 'pt', 'fr', 'sw'] as const;
export type OdeUiLocale = (typeof ODE_UI_LOCALES)[number];

export function normalizeLocaleTag(tag: string): string {
Expand Down
1 change: 1 addition & 0 deletions formulus-formplayer/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"finalize.no": "No",
"finalize.none": "None",
"finalize.empty": "Empty",
"finalize.value.notApplicable": "Not applicable",
"finalize.value.photoCaptured": "Photo captured",
"finalize.value.photoWithName": "Photo: {{name}}",
"finalize.value.captured": "Captured",
Expand Down
1 change: 1 addition & 0 deletions formulus-formplayer/src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"finalize.no": "Non",
"finalize.none": "Aucun",
"finalize.empty": "Vide",
"finalize.value.notApplicable": "Non applicable",
"finalize.value.photoCaptured": "Photo capturée",
"finalize.value.photoWithName": "Photo : {{name}}",
"finalize.value.captured": "Capturée",
Expand Down
1 change: 1 addition & 0 deletions formulus-formplayer/src/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"finalize.no": "Não",
"finalize.none": "Nenhum",
"finalize.empty": "Vazio",
"finalize.value.notApplicable": "Não aplicável",
"finalize.value.photoCaptured": "Fotografia capturada",
"finalize.value.photoWithName": "Fotografia: {{name}}",
"finalize.value.captured": "Capturada",
Expand Down
117 changes: 117 additions & 0 deletions formulus-formplayer/src/locales/sw.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
{
"form.loading": "Inapakia fomu...",
"form.waitingForData": "Inasubiri data kutoka Formulus...",
"form.submitSuccess": "Fomu imewasilishwa!",
"form.errorLoading": "Hitilafu ya kupakia fomu",
"form.errorInitializing": "Hitilafu ya kuanzisha fomu",
"form.errorProcessing": "Hitilafu ya kuchakata data ya fomu",
"form.schemaMissing": "Muundo wa fomu haupo. Uonyeshaji wa fomu unaweza kushindikana au kuwa haujakamilika.",
"form.submitFailed": "Imeshindwa kuwasilisha fomu. Tafadhali jaribu tena.",
"form.submitMissingInit": "Haiwezi kuwasilisha fomu kwa sababu data ya kuanzisha haipo.",
"form.submitValidatorErrors": "Haiwezi kuwasilisha fomu hadi hitilafu za uthibitishaji maalum zitatuliwe.",
"form.missingFormId": "Kitambulisho cha fomu hakipo. Haiwezi kuanzisha fomu.",
"form.noNativeHost": "Haiwezi kuwasiliana na programu mwenyeji. Formplayer inaweza kuwa inaendeshwa katika kivinjari pekee.",
"form.initTimeout": "Imeshindwa kuanzisha fomu: Hakuna data iliyopokelewa kutoka kwa programu mwenyeji. Tafadhali jaribu tena.",
"nav.previous": "Iliyotangulia",
"nav.next": "Ifuatayo",
"nav.done": "Maliza",
"nav.finalize": "Kamilisha",
"nav.previousScreen": "Skrini iliyotangulia",
"nav.nextScreen": "Skrini inayofuata",
"draft.title": "Endelea na rasimu au anza mpya",
"draft.resume": "Endelea",
"draft.startNew": "Anza fomu mpya",
"draft.delete": "Futa rasimu",
"draft.cancel": "Ghairi",
"draft.savedAt": "Imehifadhiwa {{date}}",
"draft.savedAtLine": "Rasimu imehifadhiwa {{date}}",
"draft.recentDrafts": "Rasimu za hivi karibuni ({{count}})",
"draft.none": "Hakuna rasimu za hivi karibuni za fomu hii.",
"draft.formLabel": "Fomu: {{formType}}",
"draft.newFormSection": "Fomu mpya",
"draft.deleteConfirm": "Una uhakika unataka kufuta rasimu hii? Kitendo hiki hakiwezi kutenduliwa.",
"finalize.title": "Kamilisha",
"finalize.summary": "MUHTASARI WA FOMU",
"finalize.notProvided": "Haijatolewa",
"finalize.allValid": "Uthibitishaji wote umefaulu! Kagua majibu yako kisha uwasilishe.",
"finalize.fixErrors": "Tafadhali rekebisha hitilafu zifuatazo kabla ya kukamilisha:",
"finalize.reviewHint": "Kagua data yote uliyoweka hapa chini. Bofya sehemu yoyote ili kuihariri.",
"finalize.editField": "Hariri sehemu",
"finalize.yes": "Ndiyo",
"finalize.no": "Hapana",
"finalize.none": "Hakuna",
"finalize.empty": "Tupu",
"finalize.value.notApplicable": "Haitumiki",
"finalize.value.photoCaptured": "Picha imenaswa",
"finalize.value.photoWithName": "Picha: {{name}}",
"finalize.value.captured": "Imenaswa",
"finalize.value.qrScanned": "Msimbo wa QR umechanganuliwa",
"finalize.value.qrWithData": "Msimbo wa QR: {{data}}",
"finalize.value.signatureCaptured": "Saini imenaswa",
"finalize.value.signatureProvided": "Saini imetolewa",
"finalize.value.fileSelected": "Faili imechaguliwa",
"finalize.value.fileWithName": "Faili: {{name}}",
"finalize.value.audioRecorded": "Sauti imerekodiwa",
"finalize.value.audioWithName": "Sauti: {{name}}{{duration}}",
"finalize.value.gpsCaptured": "Mahali pa GPS yamenaswa",
"finalize.value.location": "Mahali: {{coords}}",
"finalize.value.videoCaptured": "Video imenaswa",
"finalize.value.videoWithName": "Video: {{name}}",
"validation.missingRequiredFields": "Sehemu zinazohitajika hazipo",
"validation.missingRequiredField": "Sehemu inayohitajika haipo",
"validation.missingRequiredFieldsNamed": "Sehemu inayohitajika haipo: {{names}}",
"validation.fieldsNeedAttention": "Sehemu {{count}} zinahitaji uangalizi: {{summary}} Gusa Maliza ili kukagua.",
"validation.oneFieldAttention": "Sehemu 1 inahitaji uangalizi",
"validation.fieldsAttention": "Sehemu {{count}} zinahitaji uangalizi",
"validation.moreFields": "(+{{count}} zaidi)",
"validation.tapDoneToReview": "Gusa Maliza ili kukagua.",
"validation.draftOnReturn": "Baadhi ya sehemu zinazohitajika hazipo. Mabadiliko yoyote ambayo hayajahifadhiwa yatapatikana kama rasimu utakaporudi.",
"validation.stayHere": "Baki hapa",
"validation.goBack": "Rudi nyuma",
"subObservation.add": "+ Ongeza rekodi",
"subObservation.addItem": "+ Ongeza {{itemLabel}}",
"subObservation.none": "Hakuna rekodi",
"subObservation.adding": "Inaongeza…",
"subObservation.addingItem": "Inaongeza {{itemLabel}}…",
"media.photo": "Picha",
"media.photoTap": "Gusa ili kunasa picha",
"media.audio": "Sauti",
"media.video": "Video",
"media.gps": "Mahali pa GPS",
"media.qrCode": "Msimbo wa QR",
"media.approximateDate": "Tarehe takriban",
"media.takePhoto": "Piga picha",
"media.openingCamera": "Inafungua kamera...",
"media.retakePhoto": "Piga picha tena",
"media.deletePhoto": "Futa picha",
"media.captureHint": "Nasa picha iliyo wazi.",
"media.capturedPhotoAlt": "Picha iliyonaswa",
"media.invalidFilename": "Jina la faili la picha kutoka kamera si sahihi.",
"media.cameraError": "Hitilafu ya kamera imetokea",
"media.unknownCameraError": "Hitilafu ya kamera isiyojulikana",
"media.captureFailed": "Imeshindwa kunasa picha. Tafadhali jaribu tena.",
"media.recording": "Inarekodi...",
"dynamicEnum.selectOption": "Chagua chaguo...",
"dynamicEnum.loading": "Inapakia chaguo...",
"dynamicEnum.retry": "Jaribu tena",
"dynamicEnum.noOptions": "Hakuna chaguo zinazopatikana",
"dynamicEnum.configMissing": "Usanidi wa x-dynamicEnum haupo",
"dynamicEnum.queryRequired": "x-dynamicEnum: swali linahitajika",
"dynamicEnum.functionNotFound": "Kazi \"{{functionName}}\" haijapatikana. Zinazopatikana: {{available}}.",
"dynamicEnum.loadFailed": "Imeshindwa kupakia chaguo zinazobadilika",
"cqt.errorTitle": "Hitilafu ya aina maalum ya swali",
"cqt.errorBody": "Aina maalum ya swali \"{{format}}\" ilikutana na hitilafu na haikuweza kuonyeshwa.",
"cqt.errorDetails": "Maelezo ya hitilafu (bofya kupanua)",
"cqt.errorContinue": "Fomu itaendelea kufanya kazi, lakini sehemu hii haiwezi kuhaririwa.",
"cqt.unknownError": "Hitilafu isiyojulikana",
"error.required": "Sehemu hii inahitajika",
"error.is a required property": "Sehemu hii inahitajika",
"error.must have required property": "Sehemu hii inahitajika",
"error.minLength": "Lazima iwe angalau herufi {{limit}}",
"error.maxLength": "Lazima iwe herufi {{limit}} au chini",
"error.minimum": "Lazima iwe angalau {{limit}}",
"error.maximum": "Lazima iwe {{limit}} au chini",
"error.pattern": "Muundo si sahihi",
"error.type": "Thamani si sahihi",
"error.Validation error": "Hitilafu ya uthibitishaji"
}
2 changes: 1 addition & 1 deletion formulus/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Server-side timeouts that used to kill long transfers live in Synkronus (`ReadHe

## UI language (i18n)

- **Settings → Language** (`SettingsScreen`): Auto / `en` / `pt` / `fr`; stored in AsyncStorage (`@ode/uiLocale`) via [`LocaleSettingsService`](src/services/LocaleSettingsService.ts).
- **Settings → Language** (`SettingsScreen`): Auto / `en` / `pt` / `fr` / `sw`; stored in AsyncStorage (`@ode/uiLocale`) via [`LocaleSettingsService`](src/services/LocaleSettingsService.ts).
- Resolution: [`src/lib/locale.ts`](src/lib/locale.ts) — preference → device → `app.config.json` `defaultLocale` → `en`.
- Shell strings: `react-i18next` + [`src/locales/`](src/locales/).
- Formplayer receives resolved locale as `params.locale` from [`FormplayerModal`](src/components/FormplayerModal.tsx).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<html lang="en">
<head>
<title>Custom App Placeholder</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
/* ODE_TOKENS_START */
Expand Down
1 change: 1 addition & 0 deletions formulus/src/components/common/LocalePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const LocalePicker: React.FC<LocalePickerProps> = ({ value, onChange }) => {
en: t('settings.language.en'),
pt: t('settings.language.pt'),
fr: t('settings.language.fr'),
sw: t('settings.language.sw'),
};

return (
Expand Down
2 changes: 2 additions & 0 deletions formulus/src/i18n/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { initReactI18next } from 'react-i18next';
import en from '../locales/en.json';
import pt from '../locales/pt.json';
import fr from '../locales/fr.json';
import sw from '../locales/sw.json';
import { localeSettingsService } from '../services/LocaleSettingsService';
import { i18n } from './instance';

const resources = {
en: { translation: en },
pt: { translation: pt },
fr: { translation: fr },
sw: { translation: sw },
};

let initPromise: Promise<typeof i18n> | null = null;
Expand Down
33 changes: 33 additions & 0 deletions formulus/src/lib/locale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
normalizeLocaleTag,
resolveActiveLocale,
} from './locale';
import en from '../locales/en.json';
import sw from '../locales/sw.json';

describe('locale', () => {
it('normalizes locale tags', () => {
Expand All @@ -14,10 +16,15 @@ describe('locale', () => {
it('builds lookup candidates', () => {
expect(localeLookupCandidates('pt-BR')).toEqual(['pt-br', 'pt']);
expect(localeLookupCandidates('en')).toEqual(['en']);
expect(localeLookupCandidates('sw-KE')).toEqual(['sw-ke', 'sw']);
});

it('matches ODE catalog locales', () => {
expect(matchOdeCatalogLocale('pt-BR')).toBe('pt');
expect(matchOdeCatalogLocale('sw')).toBe('sw');
expect(matchOdeCatalogLocale('sw-KE')).toBe('sw');
expect(matchOdeCatalogLocale('sw-TZ')).toBe('sw');
expect(matchOdeCatalogLocale('sw-UG')).toBe('sw');
expect(matchOdeCatalogLocale('de')).toBeNull();
});

Expand All @@ -28,6 +35,12 @@ describe('locale', () => {
deviceLocale: 'en-US',
}),
).toBe('fr');
expect(
resolveActiveLocale({
preference: 'sw',
deviceLocale: 'en-US',
}),
).toBe('sw');
});

it('resolveActiveLocale uses device when auto', () => {
Expand All @@ -37,6 +50,18 @@ describe('locale', () => {
deviceLocale: 'pt-PT',
}),
).toBe('pt');
expect(
resolveActiveLocale({
preference: 'auto',
deviceLocale: 'sw-KE',
}),
).toBe('sw');
expect(
resolveActiveLocale({
preference: 'auto',
deviceLocale: 'sw-TZ',
}),
).toBe('sw');
});

it('resolveActiveLocale falls back to bundle default', () => {
Expand Down Expand Up @@ -67,4 +92,12 @@ describe('locale', () => {
}),
).toBe('en');
});

it('Swahili catalog matches English keys and is not an English fallback', () => {
const enKeys = Object.keys(en).sort();
const swKeys = Object.keys(sw).sort();
expect(swKeys).toEqual(enKeys);
expect(sw['tabs.sync']).toBe('Sawazisha');
expect(sw['tabs.sync']).not.toBe(en['tabs.sync']);
});
});
Loading
Loading