diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1c44901..bff7619 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -13,14 +13,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: subosito/flutter-action@v2 + - uses: jdx/mise-action@v2 + with: + version: 2025.10.21 - name: Install Dependencies - run: flutter pub get + run: mise run setup - name: Format - run: dart format --set-exit-if-changed lib test + run: mise run format - name: Analyze - run: flutter analyze lib test + run: mise run lint - name: Run tests - run: flutter test --no-pub --coverage --test-randomize-ordering-seed random + run: mise run test --coverage - name: Upload coverage to Codecov uses: codecov/codecov-action@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d2e4f8..439c3c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# [next version] + +- Adds optional `fallbackLocales` to options to allow sequential locale fallbacks. + - due to how asset loading works, all locales that were declared as fallback need to be loaded beforehand. +- BREAKING: `AssetBundleLocalizationDataSource.bundlePath` is now required. + - if you were using the default, just pass `localizations` as the argument. +- BREAKING: `LocalizationDataSource.load` now receives a list of locales, instead of a single locale. + - `AssetBundleLocalizationDataSource` now also tries to load files concurrently. + # [0.8.0] - Bump support to flutter 3.38.x [PR](https://github.com/williamhjcho/i18next/pull/24) diff --git a/example/lib/localizations.dart b/example/lib/localizations.dart index b055b94..dd251aa 100644 --- a/example/lib/localizations.dart +++ b/example/lib/localizations.dart @@ -37,3 +37,13 @@ class CounterL10n { String get resetCounter => i18next.t('counter:resetCounter'); } + +class DevL10n { + const DevL10n(this.i18next); + + final I18Next i18next; + + static DevL10n of(BuildContext context) => DevL10n(I18Next.of(context)!); + + String get key => i18next.t('all:key'); +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 23519b2..a6e0d98 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -51,7 +51,10 @@ class _MyAppState extends State { bundlePath: 'localizations', ), // extra formatting options can be added here - options: I18NextOptions(formats: formatters()), + options: I18NextOptions( + formats: formatters(), + fallbackLocales: [Locale('dev')], + ), ), ], home: MyHomePage( @@ -106,6 +109,7 @@ class _MyHomePageState extends State { final theme = Theme.of(context); final homepageL10n = HomePageL10n.of(context); final counterL10n = CounterL10n.of(context); + final devL10n = DevL10n.of(context); return Scaffold( appBar: AppBar(title: Text(homepageL10n.title)), @@ -150,6 +154,8 @@ class _MyHomePageState extends State { onPressed: resetCounter, child: Text(counterL10n.resetCounter), ), + const Divider(), + Text(devL10n.key, style: theme.textTheme.labelSmall), ], ), ), diff --git a/example/localizations/dev/all.json b/example/localizations/dev/all.json new file mode 100644 index 0000000..2132dfd --- /dev/null +++ b/example/localizations/dev/all.json @@ -0,0 +1,3 @@ +{ + "key": "This is a development localization string." +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 92849d1..4b180be 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -30,3 +30,4 @@ flutter: # for now, we'll have to add them manually - localizations/en-US/ - localizations/pt-BR/ + - localizations/dev/ diff --git a/lib/src/data_sources/asset_bundle_data_source.dart b/lib/src/data_sources/asset_bundle_data_source.dart index 43b24ee..c29eaca 100644 --- a/lib/src/data_sources/asset_bundle_data_source.dart +++ b/lib/src/data_sources/asset_bundle_data_source.dart @@ -10,72 +10,79 @@ import 'localization_data_source.dart'; /// A [LocalizationDataSource] that retrieves assets from an [AssetBundle]. class AssetBundleLocalizationDataSource implements LocalizationDataSource { AssetBundleLocalizationDataSource({ - this.bundlePath = 'localizations', + required this.bundlePath, AssetBundle? bundle, this.cache = true, }) : bundle = bundle ?? rootBundle; /// The path prefixed to the asset when retrieving from the [bundle]. /// - /// Defaults to 'localizations'. + /// e.g. `l10n` if your assets are located in `l10n/en-US/feature.json`. final String bundlePath; /// The [AssetBundle] where it retrieves the assets from. /// - /// Defaults no [rootBundle]. + /// Defaults to [rootBundle]. final AssetBundle bundle; + /// Whether to cache the loaded assets from the [bundle]. + /// + /// Defaults to `true`. final bool cache; - /// Loads all '.json' localization files declared in [manifest] with - /// [bundlePath] given a [locale]. The assets themselves must have been - /// previously declared in `pubspec.yaml`. + /// Loads all '.json' localization files declared in the bundle's asset + /// manfiest with [bundlePath] given the [locales]. + /// The assets themselves must have been previously declared in `pubspec.yaml`. /// /// For example, if your project structure is as follows: /// /// ``` - /// /app - /// - l10n - /// - en-US/localizations.json - /// - pt-BR/localizations.json + /// /app/ + /// - l10n/ + /// - en-US/ + /// - common.json + /// - feature_a.json + /// - pt-BR/ + /// - common.json + /// - feature_a.json /// ``` /// /// Then the desired [bundlePath] should be `l10n`. /// - /// - [manifest] determines from where the namespaced files will be loaded - /// from. This file should contain a [Map] where the keys represent the - /// asset's path. Defaults to 'AssetManifest.json'. - /// /// The end result is a [Map] that contains all the namespaces which are /// the file names themselves (case sensitive). @override - Future> load(Locale locale) async { + Future>> load(List locales) async { final assetManifest = await AssetManifest.loadFromAssetBundle(bundle); - final assetFiles = assetManifest.listAssets(); - - /// On every platform you never should try to get the `path.separator`, - /// because Flutter is fetching all assets in `/` style. - /// `path.separator` should only be used to handle OS files. - final bundleLocalePath = '$bundlePath/${locale.toLanguageTag()}'; - - final files = assetFiles - // trailing slash is to guarantee the whole dir matches, otherwise - // it might allow undesired files - .where((key) => key.contains(bundleLocalePath)) - .where((key) => path.extension(key) == '.json'); + final assetFiles = assetManifest + .listAssets() + .where((key) => path.extension(key) == '.json') + .toList(); - return await loadFromFiles(files); + final loadedLocales = >{}; + await Future.wait( + locales.map((locale) async { + /// On every platform you never should try to get the `path.separator`, + /// because Flutter is fetching all assets in `/` style. + /// `path.separator` should only be used to handle OS files. + final assetPath = '$bundlePath/${locale.toLanguageTag()}'; + final localeAssetFiles = assetFiles.where((f) => f.contains(assetPath)); + final namespaces = await loadFromFiles(localeAssetFiles); + loadedLocales[locale] = namespaces; + }), + ); + return loadedLocales; } Future> loadFromFiles(Iterable files) async { - // TODO: make it case insensitive? final namespaces = HashMap(); - for (final file in files) { - // TODO: make this a lazy eval and let loading be handed concurrently? - final namespace = path.basenameWithoutExtension(file); - final string = await bundle.loadString(file, cache: cache); - namespaces[namespace] = jsonDecode(string); - } + await Future.wait( + files.map((file) async { + final namespace = path.basenameWithoutExtension(file); + final string = await bundle.loadString(file, cache: cache); + namespaces[namespace] = jsonDecode(string); + }), + ); return namespaces; } diff --git a/lib/src/data_sources/localization_data_source.dart b/lib/src/data_sources/localization_data_source.dart index e4b86f2..1047207 100644 --- a/lib/src/data_sources/localization_data_source.dart +++ b/lib/src/data_sources/localization_data_source.dart @@ -1,5 +1,5 @@ import 'dart:ui'; abstract class LocalizationDataSource { - Future> load(Locale locale); + Future>> load(List locales); } diff --git a/lib/src/i18next_localization_delegate.dart b/lib/src/i18next_localization_delegate.dart index 5be40ac..99a7184 100644 --- a/lib/src/i18next_localization_delegate.dart +++ b/lib/src/i18next_localization_delegate.dart @@ -16,8 +16,7 @@ class I18NextLocalizationDelegate extends LocalizationsDelegate { required this.dataSource, ResourceStore? resourceStore, this.options, - }) : resourceStore = resourceStore ?? ResourceStore(), - super(); + }) : resourceStore = resourceStore ?? ResourceStore(); /// The list of supported locales by this delegate. /// @@ -77,11 +76,11 @@ class I18NextLocalizationDelegate extends LocalizationsDelegate { @override Future load(Locale locale) { locale = normalizeLocale(locale); - - return dataSource.load(locale).then((namespaces) { + final allLocales = [locale, ...?options?.fallbackLocales]; + return dataSource.load(allLocales).then((loaded) { // TODO: should delete previous locales/namespaces from resource store? - for (final entry in namespaces.entries) { - resourceStore.addNamespace(locale, entry.key, entry.value); + for (final entry in loaded.entries) { + resourceStore.addLocale(entry.key, entry.value); } return I18Next(locale, resourceStore, options: options); }); diff --git a/lib/src/options.dart b/lib/src/options.dart index b1c8b1c..4298fcb 100644 --- a/lib/src/options.dart +++ b/lib/src/options.dart @@ -43,6 +43,7 @@ typedef EscapeHandler = String Function(String input); class I18NextOptions with Diagnosticable { const I18NextOptions({ this.fallbackNamespaces, + this.fallbackLocales, this.namespaceSeparator, this.contextSeparator, this.pluralSeparator, @@ -69,6 +70,7 @@ class I18NextOptions with Diagnosticable { static const I18NextOptions base = I18NextOptions( fallbackNamespaces: null, + fallbackLocales: null, namespaceSeparator: ':', contextSeparator: '_', pluralSeparator: '_', @@ -100,6 +102,18 @@ class I18NextOptions with Diagnosticable { /// Defaults to null. final List? fallbackNamespaces; + /// The locales that will be used to fallback when no key matches were found + /// in the current locale. + /// These locales are evaluated in the order they are put in the list. + /// + /// [fallbackNamespaces] will take priority over locale. + /// [missingKeyHandler] is called only after all locales have been evaluated. + /// + /// obs: the fallback locales must be loaded with the primary locale. + /// + /// Defaults to null. + final List? fallbackLocales; + /// The separator used when splitting the key. /// /// Defaults to ':'. @@ -237,6 +251,7 @@ class I18NextOptions with Diagnosticable { if (other == null) return this; return copyWith( fallbackNamespaces: other.fallbackNamespaces ?? fallbackNamespaces, + fallbackLocales: other.fallbackLocales ?? fallbackLocales, namespaceSeparator: other.namespaceSeparator ?? namespaceSeparator, contextSeparator: other.contextSeparator ?? contextSeparator, pluralSeparator: other.pluralSeparator ?? pluralSeparator, @@ -274,6 +289,7 @@ class I18NextOptions with Diagnosticable { /// properties that aren't null. I18NextOptions copyWith({ List? fallbackNamespaces, + List? fallbackLocales, String? namespaceSeparator, String? contextSeparator, String? pluralSeparator, @@ -299,6 +315,7 @@ class I18NextOptions with Diagnosticable { }) { return I18NextOptions( fallbackNamespaces: fallbackNamespaces ?? this.fallbackNamespaces, + fallbackLocales: fallbackLocales ?? this.fallbackLocales, namespaceSeparator: namespaceSeparator ?? this.namespaceSeparator, contextSeparator: contextSeparator ?? this.contextSeparator, pluralSeparator: pluralSeparator ?? this.pluralSeparator, @@ -330,6 +347,8 @@ class I18NextOptions with Diagnosticable { @override int get hashCode => Object.hashAll([ + fallbackNamespaces, + fallbackLocales, namespaceSeparator, contextSeparator, pluralSeparator, @@ -360,6 +379,7 @@ class I18NextOptions with Diagnosticable { return other.runtimeType == runtimeType && other is I18NextOptions && other.fallbackNamespaces == fallbackNamespaces && + other.fallbackLocales == fallbackLocales && other.namespaceSeparator == namespaceSeparator && other.contextSeparator == contextSeparator && other.pluralSeparator == pluralSeparator && @@ -387,6 +407,7 @@ class I18NextOptions with Diagnosticable { super.debugFillProperties(properties); properties ..add(IterableProperty('fallbackNamespaces', fallbackNamespaces)) + ..add(IterableProperty('fallbackLocales', fallbackLocales)) ..add(StringProperty('namespaceSeparator', namespaceSeparator)) ..add(StringProperty('contextSeparator', contextSeparator)) ..add(StringProperty('pluralSeparator', pluralSeparator)) diff --git a/lib/src/resource_store.dart b/lib/src/resource_store.dart index 499a302..6ea1ce4 100644 --- a/lib/src/resource_store.dart +++ b/lib/src/resource_store.dart @@ -36,6 +36,11 @@ class ResourceStore { _data[locale]?.remove(namespace); } + void addLocale(Locale locale, Map namespaces) { + _data[locale] ??= {}; + _data[locale]?.addAll(namespaces); + } + /// Unregisters the [locale] from the store and from the [cache]. Future removeLocale(Locale locale) async { _data.remove(locale); diff --git a/lib/src/translator.dart b/lib/src/translator.dart index 2fa644c..835cffe 100644 --- a/lib/src/translator.dart +++ b/lib/src/translator.dart @@ -73,32 +73,32 @@ class Translator { keys.add(tempKey += pluralSuffix); } - final namespaces = [ - namespace, - if (options.fallbackNamespaces != null) ...options.fallbackNamespaces!, - ]; + final namespaces = [namespace, ...?options.fallbackNamespaces]; + final locales = [locale, ...?options.fallbackLocales]; - for (final currentNamespace in namespaces) { - for (final currentKey in keys.reversed) { - // TODO: translation context object - try { - final found = find( - locale, - currentNamespace, - currentKey, - variables, - options, - ); - if (found != null) return found; - } catch (error) { - return options.translationFailedHandler?.call( - locale, - currentNamespace, - currentKey, - variables, - options, - error, - ); + for (final locale in locales) { + for (final currentNamespace in namespaces) { + for (final currentKey in keys.reversed) { + // TODO: translation context object + try { + final found = find( + locale, + currentNamespace, + currentKey, + variables, + options, + ); + if (found != null) return found; + } catch (error) { + return options.translationFailedHandler?.call( + locale, + currentNamespace, + currentKey, + variables, + options, + error, + ); + } } } } diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..a5569c5 --- /dev/null +++ b/mise.toml @@ -0,0 +1,17 @@ +[settings] +lockfile = true + +[tools] +flutter = "3.38.5" + +[tasks.setup] +run = 'flutter pub get' + +[tasks.format] +run = 'dart format --set-exit-if-changed .' + +[tasks.lint] +run = "flutter analyze --no-pub ." + +[tasks.test] +run = 'flutter test --no-pub --test-randomize-ordering-seed=random' diff --git a/test/data_sources/asset_bundle_data_source_test.dart b/test/data_sources/asset_bundle_data_source_test.dart index be2f82f..54b5aa1 100644 --- a/test/data_sources/asset_bundle_data_source_test.dart +++ b/test/data_sources/asset_bundle_data_source_test.dart @@ -34,6 +34,8 @@ class MockAssetManifest implements AssetManifest { void main() { const bundlePath = 'bundle/path'; + const anyLocale = Locale('any'); + const enUS = Locale('en', 'US'), pt = Locale('pt'); late MockAssetBundle bundle; late AssetBundleLocalizationDataSource dataSource; @@ -59,11 +61,12 @@ void main() { }); test('given any locale', () async { - await expectLater(dataSource.load(const Locale('any')), completes); + await expectLater(dataSource.load([anyLocale]), completes); }); test('given an unregistered locale', () { - expect(dataSource.load(const Locale('ar')), completion(isEmpty)); + const locale = Locale('ar'); + expect(dataSource.load([locale]), completion({locale: {}})); }); test('given a supported full locale', () async { @@ -72,9 +75,11 @@ void main() { ).thenAnswer((_) async => '{}'); await expectLater( - dataSource.load(const Locale('en', 'US')), + dataSource.load([enUS]), completion( - equals(>{'file1': {}, 'file2': {}}), + equals({ + enUS: {'file1': {}, 'file2': {}}, + }), ), ); @@ -83,10 +88,8 @@ void main() { }); test('given an unsupported long locale', () async { - await expectLater( - dataSource.load(const Locale('pt-BR')), - completion(isEmpty), - ); + const ptBR = Locale('pt-BR'); + await expectLater(dataSource.load([ptBR]), completion({ptBR: {}})); verifyNever( () => bundle.loadString(any(that: contains('$bundlePath/pt/'))), @@ -103,11 +106,12 @@ void main() { when( () => bundle.loadString(any(that: contains('$bundlePath/'))), ).thenAnswer((_) async => '{}'); - await expectLater( - dataSource.load(const Locale('pt')), + dataSource.load([pt]), completion( - equals(>{'file1': {}, 'file2': {}}), + equals({ + pt: {'file1': {}, 'file2': {}}, + }), ), ); @@ -119,10 +123,8 @@ void main() { }); test('given an unsupported short locale', () async { - await expectLater( - dataSource.load(const Locale('ar')), - completion(isEmpty), - ); + const locale = Locale('ar'); + await expectLater(dataSource.load([locale]), completion({locale: {}})); verifyNever( () => bundle.loadString(any(that: contains('$bundlePath/ar/'))), @@ -135,15 +137,56 @@ void main() { ); }); + test('given supported multiple locales', () async { + when( + () => bundle.loadString(any(that: contains('$bundlePath/'))), + ).thenAnswer((_) async => '{}'); + await expectLater( + dataSource.load([enUS, pt]), + completion( + equals({ + enUS: {'file1': {}, 'file2': {}}, + pt: {'file1': {}, 'file2': {}}, + }), + ), + ); + + verify(() => bundle.loadString('$bundlePath/en-US/file1.json')).called(1); + verify(() => bundle.loadString('$bundlePath/en-US/file2.json')).called(1); + verify(() => bundle.loadString('$bundlePath/pt/file1.json')).called(1); + verify(() => bundle.loadString('$bundlePath/pt/file2.json')).called(1); + }); + + test('given supported and unsupported multiple locales', () async { + when( + () => bundle.loadString(any(that: contains('$bundlePath/'))), + ).thenAnswer((_) async => '{}'); + await expectLater( + dataSource.load([enUS, pt, anyLocale]), + completion( + equals({ + enUS: {'file1': {}, 'file2': {}}, + pt: {'file1': {}, 'file2': {}}, + anyLocale: {}, + }), + ), + ); + + verify(() => bundle.loadString('$bundlePath/en-US/file1.json')).called(1); + verify(() => bundle.loadString('$bundlePath/en-US/file2.json')).called(1); + verify(() => bundle.loadString('$bundlePath/pt/file1.json')).called(1); + verify(() => bundle.loadString('$bundlePath/pt/file2.json')).called(1); + }); + test('when bundle errors', () async { const error = 'Some error'; when(() => bundle.loadMock(any())).thenAnswer((_) async => throw error); - await expectLater(dataSource.load(const Locale('any')), throwsA(error)); + await expectLater(dataSource.load([anyLocale]), throwsA(error)); }); test('given incorrect source-path to any bundle asset', () async { - await expectLater(dataSource.load(const Locale('any')), completes); + await expectLater(dataSource.load([anyLocale]), completes); verifyNever(() => bundle.loadString(any(that: contains('bundle\\path')))); }); diff --git a/test/i18next_localization_delegate_test.dart b/test/i18next_localization_delegate_test.dart index eb6229f..dbfcdb9 100644 --- a/test/i18next_localization_delegate_test.dart +++ b/test/i18next_localization_delegate_test.dart @@ -69,14 +69,14 @@ void main() { when(() => dataSource.load(any())).thenAnswer((_) async => {}); await expectLater(localizationDelegate.load(en), completes); - verify(() => dataSource.load(en)).called(1); + verify(() => dataSource.load([en])).called(1); }); test('given a language code matching locale', () async { when(() => dataSource.load(any())).thenAnswer((_) async => {}); await expectLater(localizationDelegate.load(enUS), completes); - verify(() => dataSource.load(en)).called(1); + verify(() => dataSource.load([en])).called(1); }); test('given a non matching language code locale', () async { @@ -86,6 +86,20 @@ void main() { verifyNever(() => dataSource.load(any())); }); + test('given fallback locales', () async { + localizationDelegate = I18NextLocalizationDelegate( + locales: [en], + dataSource: dataSource, + resourceStore: resourceStore, + options: I18NextOptions(fallbackLocales: [ptBR]), + ); + when(() => dataSource.load(any())).thenAnswer((_) async => {}); + + await expectLater(localizationDelegate.load(enUS), completes); + // loads locales that aren't marked in supported locales as well. + verify(() => dataSource.load([en, ptBR])).called(1); + }); + test('when dataSource errors', () async { const error = 'Some error'; when(() => dataSource.load(any())).thenAnswer((_) async => throw error); @@ -96,28 +110,33 @@ void main() { test('when dataSource succeeds', () async { const data1 = {'key': 'ns1'}; const data2 = {'key': 'ns1'}; - when( - () => dataSource.load(any()), - ).thenAnswer((_) async => {'ns1': data1, 'ns2': data2}); + when(() => dataSource.load(any())).thenAnswer( + (_) async => { + en: {'ns1': data1, 'ns2': data2}, + }, + ); final i18next = await localizationDelegate.load(en); expect(i18next.locale, en); - verify(() => resourceStore.addNamespace(en, 'ns1', data1)).called(1); - verify(() => resourceStore.addNamespace(en, 'ns2', data2)).called(1); + verify( + () => resourceStore.addLocale(en, {'ns1': data1, 'ns2': data2}), + ).called(1); }); test('when dataSource is synchronous', () { const data1 = {'key': 'ns1'}; - when( - () => dataSource.load(any()), - ).thenAnswer((_) => SynchronousFuture({'ns1': data1})); + when(() => dataSource.load(any())).thenAnswer( + (_) => SynchronousFuture({ + en: {'ns1': data1}, + }), + ); // checking if this is being called sync I18Next? result; localizationDelegate.load(en).then((value) => result = value); expect(result, isNotNull); expect(result!.locale, en); - verify(() => resourceStore.addNamespace(en, 'ns1', data1)).called(1); + verify(() => resourceStore.addLocale(en, {'ns1': data1})).called(1); }); }); } diff --git a/test/i18next_test.dart b/test/i18next_test.dart index 6691031..8a410f3 100644 --- a/test/i18next_test.dart +++ b/test/i18next_test.dart @@ -217,8 +217,11 @@ void main() { }); group('fallback', () { + const french = Locale('fr'), german = Locale('de'); + const fallbackNamespace1 = 'fallback_namespace_1'; + const fallbackNamespace2 = 'fallback_namespace_2'; + test('given a global fallback key substitution', () { - const fallbackNamespace1 = 'fallback_namespace_1'; i18next = I18Next( locale, resourceStore, @@ -233,9 +236,6 @@ void main() { }); group('given 2 global fallback keys subsitution', () { - const fallbackNamespace1 = 'fallback_namespace_1'; - const fallbackNamespace2 = 'fallback_namespace_2'; - setUp(() { i18next = I18Next( locale, @@ -263,6 +263,105 @@ void main() { expect(i18next.t('$namespace:key'), 'value'); }); }); + + test('given fallback locales', () { + i18next = I18Next( + locale, + resourceStore, + options: const I18NextOptions(fallbackLocales: [french]), + ); + + mockKey('key', 'original value'); + mockKey('key', 'french value', locale: french); + mockKey('other.key', 'french value', locale: french); + + expect(i18next.t('$namespace:key'), 'original value'); + expect(i18next.t('$namespace:other.key'), 'french value'); + }); + + test('given fallback localess', () { + i18next = I18Next( + locale, + resourceStore, + options: const I18NextOptions(fallbackLocales: [french, german]), + ); + + mockKey('french.key', 'french value', locale: french); + mockKey('german.key', 'german value', locale: german); + + expect(i18next.t('$namespace:french.key'), 'french value'); + expect(i18next.t('$namespace:german.key'), 'german value'); + }); + + test('given fallback locales but key is still missing', () { + i18next = I18Next( + locale, + resourceStore, + options: const I18NextOptions(fallbackLocales: [french, german]), + ); + + expect(i18next.t('$namespace:unknown.key'), '$namespace:unknown.key'); + }); + + test('given fallback locales and namespace', () { + i18next = I18Next( + locale, + resourceStore, + options: I18NextOptions( + fallbackNamespaces: [fallbackNamespace1, fallbackNamespace2], + fallbackLocales: [french, german], + missingKeyHandler: expectAsync4( + (locale, key, variables, options) => fail('Should not be called'), + count: 0, + ), + ), + ); + + mockKey('key', 'original value', ns: namespace); + mockKey('key', 'fallbackValue1', ns: fallbackNamespace1); + mockKey('key', 'fallbackValue2', ns: fallbackNamespace2); + mockKey('french.key', 'french value', ns: namespace, locale: french); + mockKey( + 'french.key', + 'french fallback 1', + ns: fallbackNamespace1, + locale: french, + ); + mockKey('german.key', 'german value', ns: namespace, locale: german); + mockKey( + 'german.key', + 'german fallback 2', + ns: fallbackNamespace2, + locale: german, + ); + + expect(i18next.t('$namespace:key'), 'original value'); + expect(i18next.t('$fallbackNamespace1:key'), 'fallbackValue1'); + expect(i18next.t('$fallbackNamespace2:key'), 'fallbackValue2'); + + expect(i18next.t('$namespace:french.key'), 'french value'); + expect(i18next.t('$fallbackNamespace1:french.key'), 'french fallback 1'); + + expect(i18next.t('$namespace:german.key'), 'german value'); + expect(i18next.t('$fallbackNamespace2:german.key'), 'german fallback 2'); + }); + + test('when no fallback finds a key', () { + i18next = I18Next( + locale, + resourceStore, + options: I18NextOptions( + fallbackNamespaces: [fallbackNamespace1, fallbackNamespace2], + fallbackLocales: [french, german], + missingKeyHandler: expectAsync4( + (locale, key, variables, options) => 'final fallback', + count: 1, + ), + ), + ); + + expect(i18next.t('$namespace:key'), 'final fallback'); + }); }); group('pluralization', () {