// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:file/memory.dart'; import 'package:flutter_tools/src/artifacts.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/convert.dart'; import 'package:flutter_tools/src/localizations/gen_l10n.dart'; import 'package:flutter_tools/src/localizations/gen_l10n_types.dart'; import 'package:flutter_tools/src/localizations/localizations_utils.dart'; import '../src/common.dart'; import '../src/context.dart'; const String defaultTemplateArbFileName = 'app_en.arb'; const String defaultOutputFileString = 'output-localization-file.dart'; const String defaultClassNameString = 'AppLocalizations'; const String singleMessageArbFileString = ''' { "title": "Title", "@title": { "description": "Title for the application." } }'''; const String twoMessageArbFileString = ''' { "title": "Title", "@title": { "description": "Title for the application." }, "subtitle": "Subtitle", "@subtitle": { "description": "Subtitle for the application." } }'''; const String esArbFileName = 'app_es.arb'; const String singleEsMessageArbFileString = ''' { "title": "Título" }'''; const String singleZhMessageArbFileString = ''' { "title": "标题" }'''; const String intlImportDartCode = ''' import 'package:intl/intl.dart' as intl; '''; const String foundationImportDartCode = ''' import 'package:flutter/foundation.dart'; '''; void _standardFlutterDirectoryL10nSetup(FileSystem fs) { final Directory l10nDirectory = fs.currentDirectory.childDirectory('lib').childDirectory('l10n') ..createSync(recursive: true); l10nDirectory.childFile(defaultTemplateArbFileName).writeAsStringSync(singleMessageArbFileString); l10nDirectory.childFile(esArbFileName).writeAsStringSync(singleEsMessageArbFileString); fs.file('pubspec.yaml') ..createSync(recursive: true) ..writeAsStringSync(''' flutter: generate: true '''); } void main() { late MemoryFileSystem fs; late BufferLogger logger; late Artifacts artifacts; late String defaultL10nPath; LocalizationsGenerator setupLocalizations( Map localeToArbFile, { String? yamlFile, String? outputPathString, String? outputFileString, String? headerString, String? headerFile, String? untranslatedMessagesFile, bool isFromYaml = false, bool usesNullableGetter = true, String? inputsAndOutputsListPath, List? preferredSupportedLocales, bool useDeferredLoading = false, bool useEscaping = false, bool areResourceAttributeRequired = false, bool suppressWarnings = false, bool relaxSyntax = false, bool useNamedParameters = false, void Function(Directory)? setup, }) { final Directory l10nDirectory = fs.directory(defaultL10nPath)..createSync(recursive: true); for (final String locale in localeToArbFile.keys) { l10nDirectory.childFile('app_$locale.arb').writeAsStringSync(localeToArbFile[locale]!); } if (setup != null) { setup(l10nDirectory); } return LocalizationsGenerator( fileSystem: fs, inputPathString: l10nDirectory.path, outputPathString: outputPathString ?? l10nDirectory.path, templateArbFileName: defaultTemplateArbFileName, outputFileString: outputFileString ?? defaultOutputFileString, classNameString: defaultClassNameString, headerString: headerString, headerFile: headerFile, logger: logger, untranslatedMessagesFile: untranslatedMessagesFile, inputsAndOutputsListPath: inputsAndOutputsListPath, usesNullableGetter: usesNullableGetter, preferredSupportedLocales: preferredSupportedLocales, useDeferredLoading: useDeferredLoading, useEscaping: useEscaping, areResourceAttributesRequired: areResourceAttributeRequired, suppressWarnings: suppressWarnings, useRelaxedSyntax: relaxSyntax, useNamedParameters: useNamedParameters, ) ..loadResources() ..writeOutputFiles(isFromYaml: isFromYaml); } String getGeneratedFileContent({String? locale}) { final String fileName = locale == null ? 'output-localization-file.dart' : 'output-localization-file_$locale.dart'; return fs.file(fs.path.join(defaultL10nPath, fileName)).readAsStringSync(); } setUp(() { fs = MemoryFileSystem.test(); logger = BufferLogger.test(); artifacts = Artifacts.test(); defaultL10nPath = fs.path.join('lib', 'l10n'); precacheLanguageAndRegionTags(); }); group('Setters', () { testWithoutContext('setInputDirectory fails if the directory does not exist', () { expect( () => LocalizationsGenerator.inputDirectoryFromPath(fs, 'lib', fs.directory('bogus')), throwsA( isA().having( (L10nException e) => e.message, 'message', contains('Make sure that the correct path was provided'), ), ), ); }); testWithoutContext('setting className fails if input string is empty', () { _standardFlutterDirectoryL10nSetup(fs); expect( () => LocalizationsGenerator.classNameFromString(''), throwsA( isA().having( (L10nException e) => e.message, 'message', contains('cannot be empty'), ), ), ); }); testWithoutContext('sets absolute path of the target Flutter project', () { // Set up project directory. final Directory l10nDirectory = fs.currentDirectory .childDirectory('absolute') .childDirectory('path') .childDirectory('to') .childDirectory('flutter_project') .childDirectory('lib') .childDirectory('l10n')..createSync(recursive: true); l10nDirectory .childFile(defaultTemplateArbFileName) .writeAsStringSync(singleMessageArbFileString); l10nDirectory.childFile(esArbFileName).writeAsStringSync(singleEsMessageArbFileString); // Run localizations generator in specified absolute path. final String flutterProjectPath = fs.path.join('absolute', 'path', 'to', 'flutter_project'); LocalizationsGenerator( fileSystem: fs, projectPathString: flutterProjectPath, inputPathString: defaultL10nPath, outputPathString: defaultL10nPath, templateArbFileName: defaultTemplateArbFileName, outputFileString: defaultOutputFileString, classNameString: defaultClassNameString, logger: logger, ) ..loadResources() ..writeOutputFiles(); // Output files should be generated in the provided absolute path. expect( fs.isFileSync( fs.path.join(flutterProjectPath, 'lib', 'l10n', 'output-localization-file_en.dart'), ), true, ); expect( fs.isFileSync( fs.path.join(flutterProjectPath, 'lib', 'l10n', 'output-localization-file_es.dart'), ), true, ); }); testWithoutContext('throws error when directory at absolute path does not exist', () { // Set up project directory. final Directory l10nDirectory = fs.currentDirectory .childDirectory('lib') .childDirectory('l10n')..createSync(recursive: true); l10nDirectory .childFile(defaultTemplateArbFileName) .writeAsStringSync(singleMessageArbFileString); l10nDirectory.childFile(esArbFileName).writeAsStringSync(singleEsMessageArbFileString); // Project path should be intentionally a directory that does not exist. expect( () => LocalizationsGenerator( fileSystem: fs, projectPathString: 'absolute/path/to/flutter_project', inputPathString: defaultL10nPath, outputPathString: defaultL10nPath, templateArbFileName: defaultTemplateArbFileName, outputFileString: defaultOutputFileString, classNameString: defaultClassNameString, logger: logger, ), throwsA( isA().having( (L10nException e) => e.message, 'message', contains('Directory does not exist'), ), ), ); }); testWithoutContext('throws error when arb file does not exist', () { // Set up project directory. fs.currentDirectory.childDirectory('lib').childDirectory('l10n').createSync(recursive: true); // Arb file should be nonexistent in the l10n directory. expect( () => LocalizationsGenerator( fileSystem: fs, projectPathString: './', inputPathString: defaultL10nPath, outputPathString: defaultL10nPath, templateArbFileName: defaultTemplateArbFileName, outputFileString: defaultOutputFileString, classNameString: defaultClassNameString, logger: logger, ), throwsA( isA().having( (L10nException e) => e.message, 'message', contains(', does not exist.'), ), ), ); }); group('className should only take valid Dart class names', () { setUp(() { _standardFlutterDirectoryL10nSetup(fs); }); testWithoutContext('fails on string with spaces', () { expect( () => LocalizationsGenerator.classNameFromString('String with spaces'), throwsA( isA().having( (L10nException e) => e.message, 'message', contains('is not a valid public Dart class name'), ), ), ); }); testWithoutContext('fails on non-alphanumeric symbols', () { expect( () => LocalizationsGenerator.classNameFromString('TestClass@123'), throwsA( isA().having( (L10nException e) => e.message, 'message', contains('is not a valid public Dart class name'), ), ), ); }); testWithoutContext('fails on camel-case', () { expect( () => LocalizationsGenerator.classNameFromString('camelCaseClassName'), throwsA( isA().having( (L10nException e) => e.message, 'message', contains('is not a valid public Dart class name'), ), ), ); }); testWithoutContext('fails when starting with a number', () { expect( () => LocalizationsGenerator.classNameFromString('123ClassName'), throwsA( isA().having( (L10nException e) => e.message, 'message', contains('is not a valid public Dart class name'), ), ), ); }); }); }); testWithoutContext('correctly adds a headerString when it is set', () { final LocalizationsGenerator generator = setupLocalizations({ 'en': singleMessageArbFileString, 'es': singleEsMessageArbFileString, }, headerString: '/// Sample header'); expect(generator.header, '/// Sample header'); }); testWithoutContext('correctly adds a headerFile when it is set', () { final LocalizationsGenerator generator = setupLocalizations( {'en': singleMessageArbFileString, 'es': singleEsMessageArbFileString}, headerFile: 'header.txt', setup: (Directory l10nDirectory) { l10nDirectory.childFile('header.txt').writeAsStringSync('/// Sample header in a text file'); }, ); expect(generator.header, '/// Sample header in a text file'); }); testWithoutContext('sets templateArbFileName with more than one underscore correctly', () { setupLocalizations({ 'en': singleMessageArbFileString, 'es': singleEsMessageArbFileString, }); final Directory outputDirectory = fs.directory(defaultL10nPath); expect(outputDirectory.childFile('output-localization-file.dart').existsSync(), isTrue); expect(outputDirectory.childFile('output-localization-file_en.dart').existsSync(), isTrue); expect(outputDirectory.childFile('output-localization-file_es.dart').existsSync(), isTrue); }); testWithoutContext('filenames with invalid locales should not be recognized', () { expect( () { // This attempts to create 'app_localizations_en_CA_foo.arb'. setupLocalizations({ 'en': singleMessageArbFileString, 'en_CA_foo': singleMessageArbFileString, }); }, throwsA( isA().having( (L10nException e) => e.message, 'message', contains("The following .arb file's locale could not be determined"), ), ), ); }); testWithoutContext('correctly creates an untranslated messages file', () { final String untranslatedMessagesFilePath = fs.path.join( 'lib', 'l10n', 'unimplemented_message_translations.json', ); setupLocalizations({ 'en': twoMessageArbFileString, 'es': singleMessageArbFileString, }, untranslatedMessagesFile: untranslatedMessagesFilePath); final String unimplementedOutputString = fs.file(untranslatedMessagesFilePath).readAsStringSync(); try { // Since ARB file is essentially JSON, decoding it should not fail. json.decode(unimplementedOutputString); } on Exception { fail('Parsing arb file should not fail'); } expect(unimplementedOutputString, contains('es')); expect(unimplementedOutputString, contains('subtitle')); }); testWithoutContext('untranslated messages suggestion is printed when translation is missing: ' 'command line message', () { setupLocalizations({ 'en': twoMessageArbFileString, 'es': singleEsMessageArbFileString, }); expect( logger.statusText, contains('To see a detailed report, use the --untranslated-messages-file'), ); expect( logger.statusText, contains('flutter gen-l10n --untranslated-messages-file=desiredFileName.txt'), ); }); testWithoutContext('untranslated messages suggestion is printed when translation is missing: ' 'l10n.yaml message', () { setupLocalizations({ 'en': twoMessageArbFileString, 'es': singleEsMessageArbFileString, }, isFromYaml: true); expect( logger.statusText, contains('To see a detailed report, use the untranslated-messages-file'), ); expect(logger.statusText, contains('untranslated-messages-file: desiredFileName.txt')); }); testWithoutContext('unimplemented messages suggestion is not printed when all messages ' 'are fully translated', () { setupLocalizations({ 'en': twoMessageArbFileString, 'es': twoMessageArbFileString, }); expect(logger.statusText, equals('')); }); testWithoutContext('untranslated messages file included in generated JSON list of outputs', () { final String untranslatedMessagesFilePath = fs.path.join( 'lib', 'l10n', 'unimplemented_message_translations.json', ); setupLocalizations( {'en': twoMessageArbFileString, 'es': singleEsMessageArbFileString}, untranslatedMessagesFile: untranslatedMessagesFilePath, inputsAndOutputsListPath: defaultL10nPath, ); final File inputsAndOutputsList = fs.file( fs.path.join(defaultL10nPath, 'gen_l10n_inputs_and_outputs.json'), ); expect(inputsAndOutputsList.existsSync(), isTrue); final Map jsonResult = json.decode(inputsAndOutputsList.readAsStringSync()) as Map; expect(jsonResult.containsKey('outputs'), isTrue); final List outputList = jsonResult['outputs'] as List; expect(outputList, contains(contains('unimplemented_message_translations.json'))); }); testWithoutContext( 'uses inputPathString as outputPathString when the outputPathString is null', () { _standardFlutterDirectoryL10nSetup(fs); LocalizationsGenerator( fileSystem: fs, inputPathString: defaultL10nPath, // outputPathString is intentionally not defined templateArbFileName: defaultTemplateArbFileName, outputFileString: defaultOutputFileString, classNameString: defaultClassNameString, logger: logger, ) ..loadResources() ..writeOutputFiles(); final Directory outputDirectory = fs.directory(defaultL10nPath); expect(outputDirectory.childFile('output-localization-file.dart').existsSync(), isTrue); expect(outputDirectory.childFile('output-localization-file_en.dart').existsSync(), isTrue); expect(outputDirectory.childFile('output-localization-file_es.dart').existsSync(), isTrue); }, ); testWithoutContext( 'correctly generates output files in non-default output directory if it already exists', () { final Directory l10nDirectory = fs.currentDirectory.childDirectory(defaultL10nPath) ..createSync(recursive: true); l10nDirectory .childFile(defaultTemplateArbFileName) .writeAsStringSync(singleMessageArbFileString); l10nDirectory.childFile(esArbFileName).writeAsStringSync(singleEsMessageArbFileString); LocalizationsGenerator( fileSystem: fs, inputPathString: defaultL10nPath, outputPathString: fs.path.join('lib', 'l10n', 'output'), templateArbFileName: defaultTemplateArbFileName, outputFileString: defaultOutputFileString, classNameString: defaultClassNameString, logger: logger, ) ..loadResources() ..writeOutputFiles(); final Directory outputDirectory = fs.directory(defaultL10nPath).childDirectory('output'); expect(outputDirectory.existsSync(), isTrue); expect(outputDirectory.childFile('output-localization-file.dart').existsSync(), isTrue); expect(outputDirectory.childFile('output-localization-file_en.dart').existsSync(), isTrue); expect(outputDirectory.childFile('output-localization-file_es.dart').existsSync(), isTrue); }, ); testWithoutContext( 'correctly creates output directory if it does not exist and writes files in it', () { _standardFlutterDirectoryL10nSetup(fs); LocalizationsGenerator( fileSystem: fs, inputPathString: defaultL10nPath, outputPathString: fs.path.join('lib', 'l10n', 'output'), templateArbFileName: defaultTemplateArbFileName, outputFileString: defaultOutputFileString, classNameString: defaultClassNameString, logger: logger, ) ..loadResources() ..writeOutputFiles(); final Directory outputDirectory = fs.directory(defaultL10nPath).childDirectory('output'); expect(outputDirectory.existsSync(), isTrue); expect(outputDirectory.childFile('output-localization-file.dart').existsSync(), isTrue); expect(outputDirectory.childFile('output-localization-file_en.dart').existsSync(), isTrue); expect(outputDirectory.childFile('output-localization-file_es.dart').existsSync(), isTrue); }, ); testWithoutContext('generates nullable localizations class getter via static `of` method ' 'by default', () { final LocalizationsGenerator generator = setupLocalizations({ 'en': singleMessageArbFileString, 'es': singleEsMessageArbFileString, }); expect(generator.outputDirectory.existsSync(), isTrue); expect( generator.outputDirectory.childFile('output-localization-file.dart').existsSync(), isTrue, ); expect( generator.outputDirectory.childFile('output-localization-file.dart').readAsStringSync(), contains('static AppLocalizations? of(BuildContext context)'), ); expect( generator.outputDirectory.childFile('output-localization-file.dart').readAsStringSync(), contains('return Localizations.of(context, AppLocalizations);'), ); }); testWithoutContext( 'can generate non-nullable localizations class getter via static `of` method ', () { final LocalizationsGenerator generator = setupLocalizations({ 'en': singleMessageArbFileString, 'es': singleEsMessageArbFileString, }, usesNullableGetter: false); expect(generator.outputDirectory.existsSync(), isTrue); expect( generator.outputDirectory.childFile('output-localization-file.dart').existsSync(), isTrue, ); expect( generator.outputDirectory.childFile('output-localization-file.dart').readAsStringSync(), contains('static AppLocalizations of(BuildContext context)'), ); expect( generator.outputDirectory.childFile('output-localization-file.dart').readAsStringSync(), contains('return Localizations.of(context, AppLocalizations)!;'), ); }, ); testWithoutContext('creates list of inputs and outputs when file path is specified', () { setupLocalizations({ 'en': singleMessageArbFileString, 'es': singleEsMessageArbFileString, }, inputsAndOutputsListPath: defaultL10nPath); final File inputsAndOutputsList = fs.file( fs.path.join(defaultL10nPath, 'gen_l10n_inputs_and_outputs.json'), ); expect(inputsAndOutputsList.existsSync(), isTrue); final Map jsonResult = json.decode(inputsAndOutputsList.readAsStringSync()) as Map; expect(jsonResult.containsKey('inputs'), isTrue); final List inputList = jsonResult['inputs'] as List; expect(inputList, contains(fs.path.absolute('lib', 'l10n', 'app_en.arb'))); expect(inputList, contains(fs.path.absolute('lib', 'l10n', 'app_es.arb'))); expect(jsonResult.containsKey('outputs'), isTrue); final List outputList = jsonResult['outputs'] as List; expect( outputList, contains(fs.path.absolute(defaultL10nPath, 'output-localization-file.dart')), ); expect( outputList, contains(fs.path.absolute(defaultL10nPath, 'output-localization-file_en.dart')), ); expect( outputList, contains(fs.path.absolute(defaultL10nPath, 'output-localization-file_es.dart')), ); }); testWithoutContext('setting both a headerString and a headerFile should fail', () { expect( () { setupLocalizations( {'en': singleMessageArbFileString, 'es': singleEsMessageArbFileString}, headerString: '/// Sample header in a text file', headerFile: 'header.txt', setup: (Directory l10nDirectory) { l10nDirectory .childFile('header.txt') .writeAsStringSync('/// Sample header in a text file'); }, ); }, throwsA( isA().having( (L10nException e) => e.message, 'message', contains('Cannot accept both header and header file arguments'), ), ), ); }); testWithoutContext('setting a headerFile that does not exist should fail', () { expect( () { setupLocalizations({ 'en': singleMessageArbFileString, 'es': singleEsMessageArbFileString, }, headerFile: 'header.txt'); }, throwsA( isA().having( (L10nException e) => e.message, 'message', contains('Failed to read header file'), ), ), ); }); group('generateLocalizations', () { testWithoutContext('works even if CWD does not have a pubspec.yaml', () async { final Directory projectDir = fs.currentDirectory.childDirectory('project') ..createSync(recursive: true); final Directory l10nDirectory = projectDir.childDirectory('lib').childDirectory('l10n') ..createSync(recursive: true); l10nDirectory .childFile(defaultTemplateArbFileName) .writeAsStringSync(singleMessageArbFileString); l10nDirectory.childFile(esArbFileName).writeAsStringSync(singleEsMessageArbFileString); projectDir.childFile('pubspec.yaml') ..createSync(recursive: true) ..writeAsStringSync(''' flutter: generate: true '''); final Logger logger = BufferLogger.test(); logger.printError('An error output from a different tool in flutter_tools'); // Should run without error. await generateLocalizations( fileSystem: fs, options: LocalizationOptions( arbDir: Uri.directory(defaultL10nPath).path, outputDir: Uri.directory(defaultL10nPath, windows: false).path, templateArbFile: Uri.file(defaultTemplateArbFileName, windows: false).path, syntheticPackage: false, ), logger: logger, projectDir: projectDir, dependenciesDir: fs.currentDirectory, artifacts: artifacts, processManager: FakeProcessManager.any(), ); }); testWithoutContext('other logs from flutter_tools does not affect gen-l10n', () async { _standardFlutterDirectoryL10nSetup(fs); final Logger logger = BufferLogger.test(); logger.printError('An error output from a different tool in flutter_tools'); // Should run without error. await generateLocalizations( fileSystem: fs, options: LocalizationOptions( arbDir: Uri.directory(defaultL10nPath).path, outputDir: Uri.directory(defaultL10nPath, windows: false).path, templateArbFile: Uri.file(defaultTemplateArbFileName, windows: false).path, syntheticPackage: false, ), logger: logger, projectDir: fs.currentDirectory, dependenciesDir: fs.currentDirectory, artifacts: artifacts, processManager: FakeProcessManager.any(), ); }); testWithoutContext('forwards arguments correctly', () async { _standardFlutterDirectoryL10nSetup(fs); final LocalizationOptions options = LocalizationOptions( header: 'HEADER', arbDir: Uri.directory(defaultL10nPath).path, useDeferredLoading: true, outputClass: 'Foo', outputLocalizationFile: Uri.file('bar.dart', windows: false).path, outputDir: Uri.directory(defaultL10nPath, windows: false).path, preferredSupportedLocales: ['es'], templateArbFile: Uri.file(defaultTemplateArbFileName, windows: false).path, untranslatedMessagesFile: Uri.file('untranslated', windows: false).path, syntheticPackage: false, requiredResourceAttributes: true, nullableGetter: false, ); // Verify that values are correctly passed through the localizations target. final LocalizationsGenerator generator = await generateLocalizations( fileSystem: fs, options: options, logger: logger, projectDir: fs.currentDirectory, dependenciesDir: fs.currentDirectory, artifacts: artifacts, processManager: FakeProcessManager.any(), ); expect(generator.inputDirectory.path, '/lib/l10n/'); expect(generator.outputDirectory.path, '/lib/l10n/'); expect(generator.templateArbFile.path, '/lib/l10n/app_en.arb'); expect(generator.baseOutputFile.path, '/lib/l10n/bar.dart'); expect(generator.className, 'Foo'); expect(generator.preferredSupportedLocales.single, LocaleInfo.fromString('es')); expect(generator.header, 'HEADER'); expect(generator.useDeferredLoading, isTrue); expect(generator.inputsAndOutputsListFile?.path, '/gen_l10n_inputs_and_outputs.json'); expect(generator.useSyntheticPackage, isFalse); expect(generator.projectDirectory?.path, '/'); expect(generator.areResourceAttributesRequired, isTrue); expect(generator.untranslatedMessagesFile?.path, 'untranslated'); expect(generator.usesNullableGetter, isFalse); // Just validate one file. expect(fs.file('/lib/l10n/bar_en.dart').readAsStringSync(), ''' HEADER // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'bar.dart'; // ignore_for_file: type=lint /// The translations for English (`en`). class FooEn extends Foo { FooEn([String locale = 'en']) : super(locale); @override String get title => 'Title'; } '''); }); testUsingContext('throws exception on missing flutter: generate: true flag', () async { _standardFlutterDirectoryL10nSetup(fs); // Missing flutter: generate: true should throw exception. fs.file('pubspec.yaml') ..createSync(recursive: true) ..writeAsStringSync(''' flutter: uses-material-design: true '''); final LocalizationOptions options = LocalizationOptions( header: 'HEADER', headerFile: Uri.file('header', windows: false).path, arbDir: Uri.file('arb', windows: false).path, useDeferredLoading: true, outputClass: 'Foo', outputLocalizationFile: Uri.file('bar', windows: false).path, preferredSupportedLocales: ['en_US'], templateArbFile: Uri.file('example.arb', windows: false).path, untranslatedMessagesFile: Uri.file('untranslated', windows: false).path, ); expect( () => generateLocalizations( fileSystem: fs, options: options, logger: BufferLogger.test(), projectDir: fs.currentDirectory, dependenciesDir: fs.currentDirectory, artifacts: artifacts, processManager: FakeProcessManager.any(), ), throwsToolExit( message: 'Attempted to generate localizations code without having the ' 'flutter: generate flag turned on.', ), ); }); testUsingContext('uses the same line terminator as pubspec.yaml', () async { _standardFlutterDirectoryL10nSetup(fs); fs.file('pubspec.yaml') ..createSync(recursive: true) ..writeAsStringSync(''' flutter:\r generate: true\r '''); final LocalizationOptions options = LocalizationOptions( arbDir: fs.path.join('lib', 'l10n'), outputClass: defaultClassNameString, outputLocalizationFile: defaultOutputFileString, ); await generateLocalizations( fileSystem: fs, options: options, logger: BufferLogger.test(), projectDir: fs.currentDirectory, dependenciesDir: fs.currentDirectory, artifacts: artifacts, processManager: FakeProcessManager.any(), ); final String content = getGeneratedFileContent(locale: 'en'); expect(content, contains('\r\n')); }); testWithoutContext('blank lines generated nicely', () async { _standardFlutterDirectoryL10nSetup(fs); // Test without headers. await generateLocalizations( fileSystem: fs, options: LocalizationOptions( arbDir: Uri.directory(defaultL10nPath).path, outputDir: Uri.directory(defaultL10nPath, windows: false).path, templateArbFile: Uri.file(defaultTemplateArbFileName, windows: false).path, syntheticPackage: false, ), logger: BufferLogger.test(), projectDir: fs.currentDirectory, dependenciesDir: fs.currentDirectory, artifacts: artifacts, processManager: FakeProcessManager.any(), ); expect(fs.file('/lib/l10n/app_localizations_en.dart').readAsStringSync(), ''' // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; // ignore_for_file: type=lint /// The translations for English (`en`). class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); @override String get title => 'Title'; } '''); // Test with headers. await generateLocalizations( fileSystem: fs, options: LocalizationOptions( header: 'HEADER', arbDir: Uri.directory(defaultL10nPath).path, outputDir: Uri.directory(defaultL10nPath, windows: false).path, templateArbFile: Uri.file(defaultTemplateArbFileName, windows: false).path, syntheticPackage: false, ), logger: logger, projectDir: fs.currentDirectory, dependenciesDir: fs.currentDirectory, artifacts: artifacts, processManager: FakeProcessManager.any(), ); expect(fs.file('/lib/l10n/app_localizations_en.dart').readAsStringSync(), ''' HEADER // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'app_localizations.dart'; // ignore_for_file: type=lint /// The translations for English (`en`). class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); @override String get title => 'Title'; } '''); }); }); group('loadResources', () { testWithoutContext( 'correctly initializes supportedLocales and supportedLanguageCodes properties', () { _standardFlutterDirectoryL10nSetup(fs); final LocalizationsGenerator generator = LocalizationsGenerator( fileSystem: fs, inputPathString: defaultL10nPath, outputPathString: defaultL10nPath, templateArbFileName: defaultTemplateArbFileName, outputFileString: defaultOutputFileString, classNameString: defaultClassNameString, logger: logger, )..loadResources(); expect(generator.supportedLocales.contains(LocaleInfo.fromString('en')), true); expect(generator.supportedLocales.contains(LocaleInfo.fromString('es')), true); }, ); testWithoutContext( 'correctly sorts supportedLocales and supportedLanguageCodes alphabetically', () { final Directory l10nDirectory = fs.currentDirectory .childDirectory('lib') .childDirectory('l10n')..createSync(recursive: true); // Write files in non-alphabetical order so that read performs in that order l10nDirectory.childFile('app_zh.arb').writeAsStringSync(singleZhMessageArbFileString); l10nDirectory.childFile('app_es.arb').writeAsStringSync(singleEsMessageArbFileString); l10nDirectory.childFile('app_en.arb').writeAsStringSync(singleMessageArbFileString); final LocalizationsGenerator generator = LocalizationsGenerator( fileSystem: fs, inputPathString: defaultL10nPath, outputPathString: defaultL10nPath, templateArbFileName: defaultTemplateArbFileName, outputFileString: defaultOutputFileString, classNameString: defaultClassNameString, logger: logger, )..loadResources(); expect(generator.supportedLocales.first, LocaleInfo.fromString('en')); expect(generator.supportedLocales.elementAt(1), LocaleInfo.fromString('es')); expect(generator.supportedLocales.elementAt(2), LocaleInfo.fromString('zh')); }, ); testWithoutContext( 'adds preferred locales to the top of supportedLocales and supportedLanguageCodes', () { final Directory l10nDirectory = fs.currentDirectory .childDirectory('lib') .childDirectory('l10n')..createSync(recursive: true); l10nDirectory.childFile('app_en.arb').writeAsStringSync(singleMessageArbFileString); l10nDirectory.childFile('app_es.arb').writeAsStringSync(singleEsMessageArbFileString); l10nDirectory.childFile('app_zh.arb').writeAsStringSync(singleZhMessageArbFileString); const List preferredSupportedLocale = ['zh', 'es']; final LocalizationsGenerator generator = LocalizationsGenerator( fileSystem: fs, inputPathString: defaultL10nPath, outputPathString: defaultL10nPath, templateArbFileName: defaultTemplateArbFileName, outputFileString: defaultOutputFileString, classNameString: defaultClassNameString, preferredSupportedLocales: preferredSupportedLocale, logger: logger, )..loadResources(); expect(generator.supportedLocales.first, LocaleInfo.fromString('zh')); expect(generator.supportedLocales.elementAt(1), LocaleInfo.fromString('es')); expect(generator.supportedLocales.elementAt(2), LocaleInfo.fromString('en')); }, ); testWithoutContext( 'throws an error attempting to add preferred locales when there is no corresponding arb file for that locale', () { final Directory l10nDirectory = fs.currentDirectory .childDirectory('lib') .childDirectory('l10n')..createSync(recursive: true); l10nDirectory.childFile('app_en.arb').writeAsStringSync(singleMessageArbFileString); l10nDirectory.childFile('app_es.arb').writeAsStringSync(singleEsMessageArbFileString); l10nDirectory.childFile('app_zh.arb').writeAsStringSync(singleZhMessageArbFileString); const List preferredSupportedLocale = ['am', 'es']; expect( () { LocalizationsGenerator( fileSystem: fs, inputPathString: defaultL10nPath, outputPathString: defaultL10nPath, templateArbFileName: defaultTemplateArbFileName, outputFileString: defaultOutputFileString, classNameString: defaultClassNameString, preferredSupportedLocales: preferredSupportedLocale, logger: logger, ).loadResources(); }, throwsA( isA().having( (L10nException e) => e.message, 'message', contains("The preferred supported locale, 'am', cannot be added."), ), ), ); }, ); testWithoutContext('correctly sorts arbPathString alphabetically', () { final Directory l10nDirectory = fs.currentDirectory .childDirectory('lib') .childDirectory('l10n')..createSync(recursive: true); // Write files in non-alphabetical order so that read performs in that order l10nDirectory.childFile('app_zh.arb').writeAsStringSync(singleZhMessageArbFileString); l10nDirectory.childFile('app_es.arb').writeAsStringSync(singleEsMessageArbFileString); l10nDirectory.childFile('app_en.arb').writeAsStringSync(singleMessageArbFileString); final LocalizationsGenerator generator = LocalizationsGenerator( fileSystem: fs, inputPathString: defaultL10nPath, outputPathString: defaultL10nPath, templateArbFileName: defaultTemplateArbFileName, outputFileString: defaultOutputFileString, classNameString: defaultClassNameString, logger: logger, )..loadResources(); expect(generator.arbPathStrings.first, fs.path.join('lib', 'l10n', 'app_en.arb')); expect(generator.arbPathStrings.elementAt(1), fs.path.join('lib', 'l10n', 'app_es.arb')); expect(generator.arbPathStrings.elementAt(2), fs.path.join('lib', 'l10n', 'app_zh.arb')); }); testWithoutContext('correctly parses @@locale property in arb file', () { const String arbFileWithEnLocale = ''' { "@@locale": "en", "title": "Title", "@title": { "description": "Title for the application" } }'''; const String arbFileWithZhLocale = ''' { "@@locale": "zh", "title": "标题", "@title": { "description": "Title for the application" } }'''; final Directory l10nDirectory = fs.currentDirectory .childDirectory('lib') .childDirectory('l10n')..createSync(recursive: true); l10nDirectory.childFile('first_file.arb').writeAsStringSync(arbFileWithEnLocale); l10nDirectory.childFile('second_file.arb').writeAsStringSync(arbFileWithZhLocale); final LocalizationsGenerator generator = LocalizationsGenerator( fileSystem: fs, inputPathString: defaultL10nPath, outputPathString: defaultL10nPath, templateArbFileName: 'first_file.arb', outputFileString: defaultOutputFileString, classNameString: defaultClassNameString, logger: logger, )..loadResources(); expect(generator.supportedLocales.contains(LocaleInfo.fromString('en')), true); expect(generator.supportedLocales.contains(LocaleInfo.fromString('zh')), true); }); testWithoutContext( 'correctly requires @@locale property in arb file to match the filename locale suffix', () { const String arbFileWithEnLocale = ''' { "@@locale": "en", "title": "Stocks", "@title": { "description": "Title for the Stocks application" } }'''; const String arbFileWithZhLocale = ''' { "@@locale": "zh", "title": "标题", "@title": { "description": "Title for the Stocks application" } }'''; final Directory l10nDirectory = fs.currentDirectory .childDirectory('lib') .childDirectory('l10n')..createSync(recursive: true); l10nDirectory.childFile('app_es.arb').writeAsStringSync(arbFileWithEnLocale); l10nDirectory.childFile('app_am.arb').writeAsStringSync(arbFileWithZhLocale); expect( () { LocalizationsGenerator( fileSystem: fs, inputPathString: defaultL10nPath, outputPathString: defaultL10nPath, templateArbFileName: 'app_es.arb', outputFileString: defaultOutputFileString, classNameString: defaultClassNameString, logger: logger, ).loadResources(); }, throwsA( isA().having( (L10nException e) => e.message, 'message', contains('The locale specified in @@locale and the arb filename do not match.'), ), ), ); }, ); testWithoutContext("throws when arb file's locale could not be determined", () { fs.currentDirectory.childDirectory('lib').childDirectory('l10n') ..createSync(recursive: true) ..childFile('app.arb').writeAsStringSync(singleMessageArbFileString); expect( () { LocalizationsGenerator( fileSystem: fs, inputPathString: defaultL10nPath, outputPathString: defaultL10nPath, templateArbFileName: 'app.arb', outputFileString: defaultOutputFileString, classNameString: defaultClassNameString, logger: logger, ).loadResources(); }, throwsA( isA().having( (L10nException e) => e.message, 'message', contains('locale could not be determined'), ), ), ); }); testWithoutContext('throws when an empty string is used as a key', () { const String arbFileStringWithEmptyResourceId = ''' { "market": "MARKET", "": { "description": "This key is invalid" } }'''; final Directory l10nDirectory = fs.currentDirectory .childDirectory('lib') .childDirectory('l10n')..createSync(recursive: true); l10nDirectory.childFile('app_en.arb').writeAsStringSync(arbFileStringWithEmptyResourceId); expect( () => LocalizationsGenerator( fileSystem: fs, inputPathString: defaultL10nPath, outputPathString: defaultL10nPath, templateArbFileName: 'app_en.arb', outputFileString: defaultOutputFileString, classNameString: defaultClassNameString, logger: logger, ).loadResources(), throwsA( isA().having( (L10nException e) => e.message, 'message', contains('Invalid ARB resource name ""'), ), ), ); }); testWithoutContext('throws when the same locale is detected more than once', () { const String secondMessageArbFileString = ''' { "market": "MARKET", "@market": { "description": "Label for the Market tab" } }'''; final Directory l10nDirectory = fs.currentDirectory .childDirectory('lib') .childDirectory('l10n')..createSync(recursive: true); l10nDirectory.childFile('app_en.arb').writeAsStringSync(singleMessageArbFileString); l10nDirectory.childFile('app2_en.arb').writeAsStringSync(secondMessageArbFileString); expect( () { LocalizationsGenerator( fileSystem: fs, inputPathString: defaultL10nPath, outputPathString: defaultL10nPath, templateArbFileName: 'app_en.arb', outputFileString: defaultOutputFileString, classNameString: defaultClassNameString, logger: logger, ).loadResources(); }, throwsA( isA().having( (L10nException e) => e.message, 'message', contains("Multiple arb files with the same 'en' locale detected"), ), ), ); }); testWithoutContext('throws when the base locale does not exist', () { final Directory l10nDirectory = fs.currentDirectory .childDirectory('lib') .childDirectory('l10n')..createSync(recursive: true); l10nDirectory.childFile('app_en_US.arb').writeAsStringSync(singleMessageArbFileString); expect( () { LocalizationsGenerator( fileSystem: fs, inputPathString: defaultL10nPath, outputPathString: defaultL10nPath, templateArbFileName: 'app_en_US.arb', outputFileString: defaultOutputFileString, classNameString: defaultClassNameString, logger: logger, ).loadResources(); }, throwsA( isA().having( (L10nException e) => e.message, 'message', contains('Arb file for a fallback, en, does not exist'), ), ), ); }); testWithoutContext('AppResourceBundle throws if file contains non-string value', () { const String inputPathString = 'lib/l10n'; const String templateArbFileName = 'app_en.arb'; const String outputFileString = 'app_localizations.dart'; const String classNameString = 'AppLocalizations'; fs.file(fs.path.join(inputPathString, templateArbFileName)) ..createSync(recursive: true) ..writeAsStringSync('{ "helloWorld": "Hello World!" }'); fs.file(fs.path.join(inputPathString, 'app_es.arb')) ..createSync(recursive: true) ..writeAsStringSync('{ "helloWorld": {} }'); final LocalizationsGenerator generator = LocalizationsGenerator( fileSystem: fs, inputPathString: inputPathString, templateArbFileName: templateArbFileName, outputFileString: outputFileString, classNameString: classNameString, logger: logger, ); expect( () => generator.loadResources(), throwsToolExit( message: 'Localized message for key "helloWorld" in ' '"lib/l10n/app_es.arb" is not a string.', ), ); }); }); group('writeOutputFiles', () { testWithoutContext('multiple messages with syntax error all log their errors', () { try { setupLocalizations({ 'en': r''' { "msg1": "{", "msg2": "{ {" }''', }); } on L10nException catch (error) { expect(error.message, equals('Found syntax errors.')); expect( logger.errorText, contains(''' [app_en.arb:msg1] ICU Syntax Error: Expected "identifier" but found no tokens. { ^ [app_en.arb:msg2] ICU Syntax Error: Expected "identifier" but found "{". { { ^'''), ); } }); testWithoutContext('no description generates generic comment', () { setupLocalizations({ 'en': r''' { "helloWorld": "Hello world!" }''', }); expect(getGeneratedFileContent(), contains('/// No description provided for @helloWorld.')); }); testWithoutContext('multiline descriptions are correctly formatted as comments', () { setupLocalizations({ 'en': r''' { "helloWorld": "Hello world!", "@helloWorld": { "description": "The generic example string in every language.\nUse this for tests!" } }''', }); expect( getGeneratedFileContent(), contains(''' /// The generic example string in every language. /// Use this for tests!'''), ); }); testWithoutContext( 'message without placeholders - should generate code comment with description and template message translation', () { setupLocalizations({ 'en': singleMessageArbFileString, 'es': singleEsMessageArbFileString, }); final String content = getGeneratedFileContent(); expect(content, contains('/// Title for the application.')); expect( content, contains(''' /// In en, this message translates to: /// **'Title'**'''), ); }, ); testWithoutContext('template message translation handles newline characters', () { setupLocalizations({ 'en': r''' { "title": "Title \n of the application", "@title": { "description": "Title for the application." } }''', 'es': singleEsMessageArbFileString, }); final String content = getGeneratedFileContent(); expect(content, contains('/// Title for the application.')); expect( content, contains(r''' /// In en, this message translates to: /// **'Title \n of the application'**'''), ); }); testWithoutContext( 'message with placeholders - should generate code comment with description and template message translation', () { setupLocalizations({ 'en': r''' { "price": "The price of this item is: ${price}", "@price": { "description": "The price of an online shopping cart item.", "placeholders": { "price": { "type": "double", "format": "decimalPattern" } } } }''', 'es': r''' { "price": "El precio de este artículo es: ${price}" }''', }); final String content = getGeneratedFileContent(); expect(content, contains('/// The price of an online shopping cart item.')); expect( content, contains(r''' /// In en, this message translates to: /// **'The price of this item is: \${price}'**'''), ); }, ); testWithoutContext('should generate a file per language', () { setupLocalizations({ 'en': singleMessageArbFileString, 'en_CA': ''' { "title": "Canadian Title" }''', }); expect( getGeneratedFileContent(locale: 'en'), contains('class AppLocalizationsEn extends AppLocalizations'), ); expect( getGeneratedFileContent(locale: 'en'), contains('class AppLocalizationsEnCa extends AppLocalizationsEn'), ); expect(() => getGeneratedFileContent(locale: 'en_US'), throwsException); }); testWithoutContext( 'language imports are sorted when preferredSupportedLocaleString is given', () { const List preferredSupportedLocales = ['zh']; setupLocalizations({ 'en': singleMessageArbFileString, 'zh': singleZhMessageArbFileString, 'es': singleEsMessageArbFileString, }, preferredSupportedLocales: preferredSupportedLocales); final String content = getGeneratedFileContent(); expect( content, contains(''' import 'output-localization-file_en.dart'; import 'output-localization-file_es.dart'; import 'output-localization-file_zh.dart'; '''), ); }, ); // Regression test for https://github.com/flutter/flutter/issues/88356 testWithoutContext('full output file suffix is retained', () { setupLocalizations({ 'en': singleMessageArbFileString, }, outputFileString: 'output-localization-file.g.dart'); final String baseLocalizationsFile = fs .file(fs.path.join(defaultL10nPath, 'output-localization-file.g.dart')) .readAsStringSync(); expect( baseLocalizationsFile, contains(''' import 'output-localization-file_en.g.dart'; '''), ); final String englishLocalizationsFile = fs .file(fs.path.join(defaultL10nPath, 'output-localization-file_en.g.dart')) .readAsStringSync(); expect( englishLocalizationsFile, contains(''' import 'output-localization-file.g.dart'; '''), ); }); testWithoutContext('throws an exception when invalid output file name is passed in', () { expect( () { setupLocalizations({ 'en': singleMessageArbFileString, }, outputFileString: 'asdf'); }, throwsA( isA().having( (L10nException e) => e.message, 'message', allOf( contains('output-localization-file'), contains('asdf'), contains('is invalid'), contains('The file name must have a .dart extension.'), ), ), ), ); expect( () { setupLocalizations({ 'en': singleMessageArbFileString, }, outputFileString: '.g.dart'); }, throwsA( isA().having( (L10nException e) => e.message, 'message', allOf( contains('output-localization-file'), contains('.g.dart'), contains('is invalid'), contains('The base name cannot be empty.'), ), ), ), ); }); testWithoutContext('imports are deferred and loaded when useDeferredImports are set', () { setupLocalizations({ 'en': singleMessageArbFileString, }, useDeferredLoading: true); final String content = getGeneratedFileContent(); expect( content, contains(''' import 'output-localization-file_en.dart' deferred as output-localization-file_en; '''), ); expect(content, contains('output-localization-file_en.loadLibrary()')); }); group('placeholder tests', () { testWithoutContext( 'should automatically infer placeholders that are not explicitly defined', () { setupLocalizations({ 'en': ''' { "helloWorld": "Hello {name}" }''', }); final String content = getGeneratedFileContent(locale: 'en'); expect(content, contains('String helloWorld(Object name) {')); }, ); testWithoutContext('placeholder parameter list should be consistent between languages', () { setupLocalizations({ 'en': ''' { "helloWorld": "Hello {name}", "@helloWorld": { "placeholders": { "name": {} } } }''', 'es': ''' { "helloWorld": "Hola" } ''', }); expect(getGeneratedFileContent(locale: 'en'), contains('String helloWorld(Object name) {')); expect(getGeneratedFileContent(locale: 'es'), contains('String helloWorld(Object name) {')); }); testWithoutContext( 'braces are ignored as special characters if placeholder does not exist', () { setupLocalizations({ 'en': ''' { "helloWorld": "Hello {name}", "@@helloWorld": { "placeholders": { "names": {} } } }''', }, relaxSyntax: true); final String content = getGeneratedFileContent(locale: 'en'); expect(content, contains("String get helloWorld => 'Hello {name}'")); }, ); // Regression test for https://github.com/flutter/flutter/issues/163627 // // If placeholders have no explicit type (like `int` or `String`) set // their type can be inferred. // // Later in the pipeline it is ensured that each locales placeholder types // matches the definitions in the template. // // If only the types of the template had been inferred, // and not for the translation there would be a mismatch: // in this case `num` for count and `null` (the default), which is incompatible // and `getSyntheticGeneratedFileContent` would throw an exception. // // This test ensures that both template and locale can be equally partially defined // in the arb. testWithoutContext( 'translation placeholder type definitions can be inferred for plurals', () { setupLocalizations({ 'en': ''' { "helloWorld": "{count, plural, one{Hello World!} other{Hello Worlds!}}", "@helloWorld": { "description": "The conventional newborn programmer greeting", "placeholders": { "count": {} } } }''', 'de': ''' { "helloWorld": "{count, plural, one{Hallo Welt!} other{Hallo Welten!}}", "@helloWorld": { "description": "The conventional newborn programmer greeting", "placeholders": { "count": {} } } }''', }); expect(getGeneratedFileContent(locale: 'en'), isA()); }, ); }); group('DateTime tests', () { testWithoutContext('imports package:intl', () { setupLocalizations({ 'en': ''' { "@@locale": "en", "springBegins": "Spring begins on {springStartDate}", "@springBegins": { "description": "The first day of spring", "placeholders": { "springStartDate": { "type": "DateTime", "format": "yMd" } } } }''', }); expect(getGeneratedFileContent(locale: 'en'), contains(intlImportDartCode)); }); testWithoutContext('throws an exception when improperly formatted date is passed in', () { expect( () { setupLocalizations({ 'en': ''' { "springBegins": "Spring begins on {springStartDate}", "@springBegins": { "placeholders": { "springStartDate": { "type": "DateTime", "format": "asdf" } } } }''', }); }, throwsA( isA().having( (L10nException e) => e.message, 'message', allOf( contains('message "springBegins"'), contains('locale "en"'), contains('asdf'), contains('springStartDate'), contains('does not have a corresponding DateFormat'), ), ), ), ); }); testWithoutContext('use standard date format whenever possible', () { setupLocalizations({ 'en': ''' { "springBegins": "Spring begins on {springStartDate}", "@springBegins": { "placeholders": { "springStartDate": { "type": "DateTime", "format": "yMd", "isCustomDateFormat": "true" } } } }''', }); final String content = getGeneratedFileContent(locale: 'en'); expect(content, contains('DateFormat.yMd(localeName)')); }); testWithoutContext('handle arbitrary formatted date', () { setupLocalizations({ 'en': ''' { "@@locale": "en", "springBegins": "Spring begins on {springStartDate}", "@springBegins": { "description": "The first day of spring", "placeholders": { "springStartDate": { "type": "DateTime", "format": "asdf o'clock", "isCustomDateFormat": "true" } } } }''', }); final String content = getGeneratedFileContent(locale: 'en'); expect(content, contains(r"DateFormat('asdf o\'clock', localeName)")); }); testWithoutContext('handle arbitrary formatted date with actual boolean', () { setupLocalizations({ 'en': ''' { "@@locale": "en", "springBegins": "Spring begins on {springStartDate}", "@springBegins": { "description": "The first day of spring", "placeholders": { "springStartDate": { "type": "DateTime", "format": "asdf o'clock", "isCustomDateFormat": true } } } }''', }); final String content = getGeneratedFileContent(locale: 'en'); expect(content, contains(r"DateFormat('asdf o\'clock', localeName)")); }); testWithoutContext('handles adding two valid formats', () { setupLocalizations({ 'en': ''' { "loggedIn": "Last logged in on {lastLoginDate}", "@loggedIn": { "placeholders": { "lastLoginDate": { "type": "DateTime", "format": "yMd+jms" } } } }''', }); final String content = getGeneratedFileContent(locale: 'en'); expect(content, contains(r'DateFormat.yMd(localeName).add_jms()')); }); testWithoutContext('handles adding three valid formats', () { setupLocalizations({ 'en': ''' { "loggedIn": "Last logged in on {lastLoginDate}", "@loggedIn": { "placeholders": { "lastLoginDate": { "type": "DateTime", "format": "yMMMMEEEEd+QQQQ+Hm" } } } }''', }); final String content = getGeneratedFileContent(locale: 'en'); expect(content, contains(r'DateFormat.yMMMMEEEEd(localeName).add_QQQQ().add_Hm()')); }); testWithoutContext('throws an exception when adding invalid formats', () { expect( () { setupLocalizations({ 'en': ''' { "loggedIn": "Last logged in on {lastLoginDate}", "@loggedIn": { "placeholders": { "lastLoginDate": { "type": "DateTime", "format": "foo+bar+baz" } } } }''', }); }, throwsA( isA().having( (L10nException e) => e.message, 'message', allOf( contains('message "loggedIn"'), contains('locale "en"'), contains('"foo+bar+baz"'), contains('lastLoginDate'), contains('contains at least one invalid date format'), ), ), ), ); }); testWithoutContext('throws an exception when adding formats and trailing plus sign', () { expect( () { setupLocalizations({ 'en': ''' { "loggedIn": "Last logged in on {lastLoginDate}", "@loggedIn": { "placeholders": { "lastLoginDate": { "type": "DateTime", "format": "yMd+Hm+" } } } }''', }); }, throwsA( isA().having( (L10nException e) => e.message, 'message', allOf( contains('message "loggedIn"'), contains('locale "en"'), contains('"yMd+Hm+"'), contains('lastLoginDate'), contains('contains at least one invalid date format'), ), ), ), ); }); testWithoutContext('throws an exception when no format attribute is passed in', () { expect( () { setupLocalizations({ 'en': ''' { "springBegins": "Spring begins on {springStartDate}", "@springBegins": { "description": "The first day of spring", "placeholders": { "springStartDate": { "type": "DateTime" } } } }''', }); }, throwsA( isA().having( (L10nException e) => e.message, 'message', allOf( contains('message "springBegins"'), contains('locale "en"'), contains('the "format" attribute needs to be set'), ), ), ), ); }); testWithoutContext('handle date with multiple locale', () { setupLocalizations({ 'en': ''' { "@@locale": "en", "springBegins": "Spring begins on {springStartDate}", "@springBegins": { "description": "The first day of spring", "placeholders": { "springStartDate": { "type": "DateTime", "format": "MMMd" } } } }''', 'ja': ''' { "@@locale": "ja", "springBegins": "春が始まるのは{springStartDate}", "@springBegins": { "placeholders": { "springStartDate": { "type": "DateTime", "format": "MMMMd" } } } }''', }); expect(getGeneratedFileContent(locale: 'en'), contains('intl.DateFormat.MMMd(localeName)')); expect( getGeneratedFileContent(locale: 'ja'), contains('intl.DateFormat.MMMMd(localeName)'), ); expect( getGeneratedFileContent(locale: 'en'), contains('String springBegins(DateTime springStartDate)'), ); expect( getGeneratedFileContent(locale: 'ja'), contains('String springBegins(DateTime springStartDate)'), ); }); testWithoutContext( 'handle date with multiple locale when only template has placeholders', () { setupLocalizations({ 'en': ''' { "@@locale": "en", "springBegins": "Spring begins on {springStartDate}", "@springBegins": { "description": "The first day of spring", "placeholders": { "springStartDate": { "type": "DateTime", "format": "MMMd" } } } }''', 'ja': ''' { "@@locale": "ja", "springBegins": "春が始まるのは{springStartDate}" }''', }); expect( getGeneratedFileContent(locale: 'en'), contains('intl.DateFormat.MMMd(localeName)'), ); expect( getGeneratedFileContent(locale: 'ja'), contains('intl.DateFormat.MMMd(localeName)'), ); expect( getGeneratedFileContent(locale: 'en'), contains('String springBegins(DateTime springStartDate)'), ); expect( getGeneratedFileContent(locale: 'ja'), contains('String springBegins(DateTime springStartDate)'), ); }, ); testWithoutContext('handle date with multiple locale when there is unused placeholder', () { setupLocalizations({ 'en': ''' { "@@locale": "en", "springBegins": "Spring begins on {springStartDate}", "@springBegins": { "description": "The first day of spring", "placeholders": { "springStartDate": { "type": "DateTime", "format": "MMMd" } } } }''', 'ja': ''' { "@@locale": "ja", "springBegins": "春が始まるのは{springStartDate}", "@springBegins": { "description": "The first day of spring", "placeholders": { "notUsed": { "type": "DateTime", "format": "MMMMd" } } } }''', }); expect(getGeneratedFileContent(locale: 'en'), contains('intl.DateFormat.MMMd(localeName)')); expect(getGeneratedFileContent(locale: 'ja'), contains('intl.DateFormat.MMMd(localeName)')); expect( getGeneratedFileContent(locale: 'en'), contains('String springBegins(DateTime springStartDate)'), ); expect( getGeneratedFileContent(locale: 'ja'), contains('String springBegins(DateTime springStartDate)'), ); expect(getGeneratedFileContent(locale: 'ja'), isNot(contains('notUsed'))); }); testWithoutContext('handle date with multiple locale when placeholders are incompatible', () { expect( () { setupLocalizations({ 'en': ''' { "@@locale": "en", "springBegins": "Spring begins on {springStartDate}", "@springBegins": { "description": "The first day of spring", "placeholders": { "springStartDate": { "type": "DateTime", "format": "MMMd" } } } }''', 'ja': ''' { "@@locale": "ja", "springBegins": "春が始まるのは{springStartDate}", "@springBegins": { "description": "The first day of spring", "placeholders": { "springStartDate": { "type": "String" } } } }''', }); }, throwsA( isA().having( (L10nException e) => e.message, 'message', allOf( contains('placeholder "springStartDate"'), contains('locale "ja"'), contains( '"type" resource attribute set to the type "String" in locale "ja", but it is "DateTime" in the template placeholder.', ), ), ), ), ); }); testWithoutContext( 'handle date with multiple locale when non-template placeholder does not specify type', () { expect( () { setupLocalizations({ 'en': ''' { "@@locale": "en", "springBegins": "Spring begins on {springStartDate}", "@springBegins": { "description": "The first day of spring", "placeholders": { "springStartDate": { "type": "DateTime", "format": "MMMd" } } } }''', 'ja': ''' { "@@locale": "ja", "springBegins": "春が始まるのは{springStartDate}", "@springBegins": { "description": "The first day of spring", "placeholders": { "springStartDate": { "format": "MMMMd" } } } }''', }); }, throwsA( isA().having( (L10nException e) => e.message, 'message', allOf( contains('placeholder "springStartDate"'), contains('locale "ja"'), contains( 'has its "type" resource attribute set to the type "Object" in locale "ja", but it is "DateTime" in the template placeholder.', ), ), ), ), ); }, ); testWithoutContext('handle ordinary formatted date and arbitrary formatted date', () { setupLocalizations({ 'en': ''' { "@@locale": "en", "springBegins": "Spring begins on {springStartDate}", "@springBegins": { "description": "The first day of spring", "placeholders": { "springStartDate": { "type": "DateTime", "format": "MMMd" } } } }''', 'ja': ''' { "@@locale": "ja", "springBegins": "春が始まるのは{springStartDate}", "@springBegins": { "placeholders": { "springStartDate": { "type": "DateTime", "format": "立春", "isCustomDateFormat": "true" } } } }''', }); expect(getGeneratedFileContent(locale: 'en'), contains('intl.DateFormat.MMMd(localeName)')); expect(getGeneratedFileContent(locale: 'ja'), contains(r"DateFormat('立春', localeName)")); expect( getGeneratedFileContent(locale: 'en'), contains('String springBegins(DateTime springStartDate)'), ); expect( getGeneratedFileContent(locale: 'ja'), contains('String springBegins(DateTime springStartDate)'), ); }); testWithoutContext('handle arbitrary formatted date with multiple locale', () { setupLocalizations({ 'en': ''' { "@@locale": "en", "springBegins": "Spring begins on {springStartDate}", "@springBegins": { "description": "The first day of spring", "placeholders": { "springStartDate": { "type": "DateTime", "format": "asdf o'clock", "isCustomDateFormat": "true" } } } }''', 'ja': ''' { "@@locale": "ja", "springBegins": "春が始まるのは{springStartDate}", "@springBegins": { "placeholders": { "springStartDate": { "type": "DateTime", "format": "立春", "isCustomDateFormat": "true" } } } }''', }); expect( getGeneratedFileContent(locale: 'en'), contains(r"DateFormat('asdf o\'clock', localeName)"), ); expect(getGeneratedFileContent(locale: 'ja'), contains(r"DateFormat('立春', localeName)")); expect( getGeneratedFileContent(locale: 'en'), contains('String springBegins(DateTime springStartDate)'), ); expect( getGeneratedFileContent(locale: 'ja'), contains('String springBegins(DateTime springStartDate)'), ); }); }); group('NumberFormat tests', () { testWithoutContext('imports package:intl', () { setupLocalizations({ 'en': ''' { "courseCompletion": "You have completed {progress} of the course.", "@courseCompletion": { "description": "The amount of progress the student has made in their class.", "placeholders": { "progress": { "type": "double", "format": "percentPattern" } } } }''', }); final String content = getGeneratedFileContent(locale: 'en'); expect(content, contains(intlImportDartCode)); }); testWithoutContext('throws an exception when improperly formatted number is passed in', () { expect( () { setupLocalizations({ 'en': ''' { "courseCompletion": "You have completed {progress} of the course.", "@courseCompletion": { "description": "The amount of progress the student has made in their class.", "placeholders": { "progress": { "type": "double", "format": "asdf" } } } }''', }); }, throwsA( isA().having( (L10nException e) => e.message, 'message', allOf( contains('message "courseCompletion"'), contains('locale "en"'), contains('asdf'), contains('progress'), contains('does not have a corresponding NumberFormat'), ), ), ), ); }); }); group('plural messages', () { testWithoutContext( 'intl package import should be omitted in subclass files when no plurals are included', () { setupLocalizations({ 'en': singleMessageArbFileString, 'es': singleEsMessageArbFileString, }); expect(getGeneratedFileContent(locale: 'es'), contains(intlImportDartCode)); }, ); testWithoutContext('warnings are generated when plural parts are repeated', () { setupLocalizations({ 'en': ''' { "helloWorlds": "{count,plural, =0{Hello}zero{hello} other{hi}}", "@helloWorlds": { "description": "Properly formatted but has redundant zero cases." } }''', }); expect(logger.hadWarningOutput, isTrue); expect( logger.warningText, contains(''' [app_en.arb:helloWorlds] ICU Syntax Warning: The plural part specified below is overridden by a later plural part. {count,plural, =0{Hello}zero{hello} other{hi}} ^'''), ); }); testWithoutContext('undefined plural cases throws syntax error', () { try { setupLocalizations({ 'en': ''' { "count": "{count,plural, =0{None} =1{One} =2{Two} =3{Undefined Behavior!} other{Hmm...}}" }''', }); } on L10nException catch (error) { expect(error.message, contains('Found syntax errors.')); expect(logger.hadErrorOutput, isTrue); expect( logger.errorText, contains(''' [app_en.arb:count] The plural cases must be one of "=0", "=1", "=2", "zero", "one", "two", "few", "many", or "other. 3 is not a valid plural case. {count,plural, =0{None} =1{One} =2{Two} =3{Undefined Behavior!} other{Hmm...}} ^'''), ); } }); testWithoutContext( 'should automatically infer plural placeholders that are not explicitly defined', () { setupLocalizations({ 'en': ''' { "helloWorlds": "{count,plural, =0{Hello}=1{Hello World}=2{Hello two worlds}few{Hello {count} worlds}many{Hello all {count} worlds}other{Hello other {count} worlds}}", "@helloWorlds": { "description": "Improperly formatted since it has no placeholder attribute." } }''', }); expect( getGeneratedFileContent(locale: 'en'), contains('String helloWorlds(num count) {'), ); }, ); testWithoutContext( 'should throw attempting to generate a plural message with incorrect format for placeholders', () { expect( () { setupLocalizations({ 'en': ''' { "helloWorlds": "{count,plural, =0{Hello}=1{Hello World}=2{Hello two worlds}few{Hello {count} worlds}many{Hello all {count} worlds}other{Hello other {count} worlds}}", "@helloWorlds": { "placeholders": "Incorrectly a string, should be a map." } }''', }); }, throwsA( isA().having( (L10nException e) => e.message, 'message', allOf( contains('message "helloWorlds"'), contains('is not properly formatted'), contains('Ensure that it is a map with string valued keys'), ), ), ), ); }, ); }); group('select messages', () { testWithoutContext( 'should automatically infer select placeholders that are not explicitly defined', () { setupLocalizations({ 'en': ''' { "genderSelect": "{gender, select, female {She} male {He} other {they} }", "@genderSelect": { "description": "Improperly formatted since it has no placeholder attribute." } }''', }); expect( getGeneratedFileContent(locale: 'en'), contains('String genderSelect(String gender) {'), ); }, ); testWithoutContext( 'should throw attempting to generate a select message with incorrect format for placeholders', () { expect( () { setupLocalizations({ 'en': ''' { "genderSelect": "{gender, select, female {She} male {He} other {they} }", "@genderSelect": { "placeholders": "Incorrectly a string, should be a map." } }''', }); }, throwsA( isA().having( (L10nException e) => e.message, 'message', allOf( contains('message "genderSelect"'), contains('is not properly formatted'), contains('Ensure that it is a map with string valued keys'), ), ), ), ); }, ); testWithoutContext( 'should throw attempting to generate a select message with an incorrect message', () { try { setupLocalizations({ 'en': ''' { "genderSelect": "{gender, select,}", "@genderSelect": { "placeholders": { "gender": {} } } }''', }); } on L10nException { expect( logger.errorText, contains(''' [app_en.arb:genderSelect] ICU Syntax Error: Select expressions must have an "other" case. {gender, select,} ^'''), ); } }, ); }); group('argument messages', () { testWithoutContext('should generate proper calls to intl.DateFormat', () { setupLocalizations({ 'en': ''' { "datetime": "{today, date, ::yMd}" }''', }); expect( getGeneratedFileContent(locale: 'en'), contains('intl.DateFormat.yMd(localeName).format(today)'), ); }); testWithoutContext('should generate proper calls to intl.DateFormat when using time', () { setupLocalizations({ 'en': ''' { "datetime": "{current, time, ::jms}" }''', }); expect( getGeneratedFileContent(locale: 'en'), contains('intl.DateFormat.jms(localeName).format(current)'), ); }); testWithoutContext( 'should not complain when placeholders are explicitly typed to DateTime', () { setupLocalizations({ 'en': ''' { "datetime": "{today, date, ::yMd}", "@datetime": { "placeholders": { "today": { "type": "DateTime" } } } }''', }); expect( getGeneratedFileContent(locale: 'en'), contains('String datetime(DateTime today) {'), ); }, ); testWithoutContext( 'should automatically infer date time placeholders that are not explicitly defined', () { setupLocalizations({ 'en': ''' { "datetime": "{today, date, ::yMd}" }''', }); expect( getGeneratedFileContent(locale: 'en'), contains('String datetime(DateTime today) {'), ); }, ); testWithoutContext('should throw on invalid DateFormat', () { try { setupLocalizations({ 'en': ''' { "datetime": "{today, date, ::yMMMMMd}" }''', }); assert(false); } on L10nException { expect( logger.errorText, allOf( contains('message "datetime"'), contains('locale "en"'), contains( 'date format "yMMMMMd" for placeholder today does not have a corresponding DateFormat constructor', ), ), ); } }); }); // All error handling for messages should collect errors on a per-error // basis and log them out individually. Then, it will throw an L10nException. group('error handling tests', () { testWithoutContext('syntax/code-gen errors properly logs errors per message', () { // TODO(thkim1011): Fix error handling so that long indents don't get truncated. // See https://github.com/flutter/flutter/issues/120490. try { setupLocalizations({ 'en': ''' { "hello": "Hello { name", "plural": "This is an incorrectly formatted plural: { count, plural, zero{No frog} one{One frog} other{{count} frogs}", "explanationWithLexingError": "The 'string above is incorrect as it forgets to close the brace", "pluralWithInvalidCase": "{ count, plural, woohoo{huh?} other{lol} }" }''', }, useEscaping: true); } on L10nException { expect( logger.errorText, contains(''' [app_en.arb:hello] ICU Syntax Error: Expected "}" but found no tokens. Hello { name ^ [app_en.arb:plural] ICU Syntax Error: Expected "}" but found no tokens. This is an incorrectly formatted plural: { count, plural, zero{No frog} one{One frog} other{{count} frogs} ^ [app_en.arb:explanationWithLexingError] ICU Lexing Error: Unmatched single quotes. The 'string above is incorrect as it forgets to close the brace ^ [app_en.arb:pluralWithInvalidCase] ICU Syntax Error: Plural expressions case must be one of "zero", "one", "two", "few", "many", or "other". { count, plural, woohoo{huh?} other{lol} } ^'''), ); } }); testWithoutContext('errors thrown in multiple languages are all shown', () { try { setupLocalizations({ 'en': '{ "hello": "Hello { name" }', 'es': '{ "hello": "Hola { name" }', }); } on L10nException { expect( logger.errorText, contains(''' [app_en.arb:hello] ICU Syntax Error: Expected "}" but found no tokens. Hello { name ^ [app_es.arb:hello] ICU Syntax Error: Expected "}" but found no tokens. Hola { name ^'''), ); } }); }); testWithoutContext( 'intl package import should be kept in subclass files when plurals are included', () { const String pluralMessageArb = ''' { "helloWorlds": "{count,plural, =0{Hello} =1{Hello World} =2{Hello two worlds} few{Hello {count} worlds} many{Hello all {count} worlds} other{Hello other {count} worlds}}", "@helloWorlds": { "description": "A plural message", "placeholders": { "count": {} } } } '''; const String pluralMessageEsArb = ''' { "helloWorlds": "{count,plural, =0{ES - Hello} =1{ES - Hello World} =2{ES - Hello two worlds} few{ES - Hello {count} worlds} many{ES - Hello all {count} worlds} other{ES - Hello other {count} worlds}}" } '''; setupLocalizations({'en': pluralMessageArb, 'es': pluralMessageEsArb}); expect(getGeneratedFileContent(locale: 'en'), contains(intlImportDartCode)); expect(getGeneratedFileContent(locale: 'es'), contains(intlImportDartCode)); }, ); testWithoutContext( 'intl package import should be kept in subclass files when select is included', () { const String selectMessageArb = ''' { "genderSelect": "{gender, select, female {She} male {He} other {they} }", "@genderSelect": { "description": "A select message", "placeholders": { "gender": {} } } } '''; const String selectMessageEsArb = ''' { "genderSelect": "{gender, select, female {ES - She} male {ES - He} other {ES - they} }" } '''; setupLocalizations({'en': selectMessageArb, 'es': selectMessageEsArb}); expect(getGeneratedFileContent(locale: 'en'), contains(intlImportDartCode)); expect(getGeneratedFileContent(locale: 'es'), contains(intlImportDartCode)); }, ); testWithoutContext('check indentation on generated files', () { setupLocalizations({ 'en': singleMessageArbFileString, 'es': singleEsMessageArbFileString, }); // Tests a few of the lines in the generated code. // Localizations lookup code final String localizationsFile = getGeneratedFileContent(); expect(localizationsFile.contains(' switch (locale.languageCode) {'), true); expect(localizationsFile.contains(" case 'en': return AppLocalizationsEn();"), true); expect(localizationsFile.contains(" case 'es': return AppLocalizationsEs();"), true); expect(localizationsFile.contains(' }'), true); // Supported locales list expect( localizationsFile.contains(' static const List supportedLocales = ['), true, ); expect(localizationsFile.contains(" Locale('en'),"), true); expect(localizationsFile.contains(" Locale('es')"), true); expect(localizationsFile.contains(' ];'), true); }); testWithoutContext( 'foundation package import should be omitted from file template when deferred loading = true', () { setupLocalizations({ 'en': singleMessageArbFileString, 'es': singleEsMessageArbFileString, }, useDeferredLoading: true); expect(getGeneratedFileContent(), isNot(contains(foundationImportDartCode))); }, ); testWithoutContext( 'foundation package import should be kept in file template when deferred loading = false', () { setupLocalizations({ 'en': singleMessageArbFileString, 'es': singleEsMessageArbFileString, }); expect(getGeneratedFileContent(), contains(foundationImportDartCode)); }, ); testWithoutContext('check for string interpolation rules', () { const String enArbCheckList = ''' { "one": "The number of {one} elapsed is: 44", "@one": { "description": "test one", "placeholders": { "one": { "type": "String" } } }, "two": "哈{two}哈", "@two": { "description": "test two", "placeholders": { "two": { "type": "String" } } }, "three": "m{three}m", "@three": { "description": "test three", "placeholders": { "three": { "type": "String" } } }, "four": "I have to work _{four}_ sometimes.", "@four": { "description": "test four", "placeholders": { "four": { "type": "String" } } }, "five": "{five} elapsed.", "@five": { "description": "test five", "placeholders": { "five": { "type": "String" } } }, "six": "{six}m", "@six": { "description": "test six", "placeholders": { "six": { "type": "String" } } }, "seven": "hours elapsed: {seven}", "@seven": { "description": "test seven", "placeholders": { "seven": { "type": "String" } } }, "eight": " {eight}", "@eight": { "description": "test eight", "placeholders": { "eight": { "type": "String" } } }, "nine": "m{nine}", "@nine": { "description": "test nine", "placeholders": { "nine": { "type": "String" } } } } '''; // It's fine that the arb is identical -- Just checking // generated code for use of '${variable}' vs '$variable' const String esArbCheckList = ''' { "one": "The number of {one} elapsed is: 44", "two": "哈{two}哈", "three": "m{three}m", "four": "I have to work _{four}_ sometimes.", "five": "{five} elapsed.", "six": "{six}m", "seven": "hours elapsed: {seven}", "eight": " {eight}", "nine": "m{nine}" } '''; setupLocalizations({'en': enArbCheckList, 'es': esArbCheckList}); final String localizationsFile = getGeneratedFileContent(locale: 'es'); expect(localizationsFile, contains(r'$one')); expect(localizationsFile, contains(r'$two')); expect(localizationsFile, contains(r'${three}')); expect(localizationsFile, contains(r'${four}')); expect(localizationsFile, contains(r'$five')); expect(localizationsFile, contains(r'${six}m')); expect(localizationsFile, contains(r'$seven')); expect(localizationsFile, contains(r'$eight')); expect(localizationsFile, contains(r'$nine')); }); testWithoutContext('check for string interpolation rules - plurals', () { const String enArbCheckList = ''' { "first": "{count,plural, =0{test {count} test} =1{哈{count}哈} =2{m{count}m} few{_{count}_} many{{count} test} other{{count}m}}", "@first": { "description": "First set of plural messages to test.", "placeholders": { "count": {} } }, "second": "{count,plural, =0{test {count}} other{ {count}}}", "@second": { "description": "Second set of plural messages to test.", "placeholders": { "count": {} } }, "third": "{total,plural, =0{test {total}} other{ {total}}}", "@third": { "description": "Third set of plural messages to test, for number.", "placeholders": { "total": { "type": "int", "format": "compactLong" } } } } '''; // It's fine that the arb is identical -- Just checking // generated code for use of '${variable}' vs '$variable' const String esArbCheckList = ''' { "first": "{count,plural, =0{test {count} test} =1{哈{count}哈} =2{m{count}m} few{_{count}_} many{{count} test} other{{count}m}}", "second": "{count,plural, =0{test {count}} other{ {count}}}" } '''; setupLocalizations({'en': enArbCheckList, 'es': esArbCheckList}); final String localizationsFile = getGeneratedFileContent(locale: 'es'); expect(localizationsFile, contains(r'test $count test')); expect(localizationsFile, contains(r'哈$count哈')); expect(localizationsFile, contains(r'm${count}m')); expect(localizationsFile, contains(r'_${count}_')); expect(localizationsFile, contains(r'$count test')); expect(localizationsFile, contains(r'${count}m')); expect(localizationsFile, contains(r'test $count')); expect(localizationsFile, contains(r' $count')); expect(localizationsFile, contains(r'String totalString = totalNumberFormat')); expect(localizationsFile, contains(r'totalString')); expect(localizationsFile, contains(r'totalString')); }); testWithoutContext('should throw with descriptive error message when failing to parse the ' 'arb file', () { const String arbFileWithTrailingComma = ''' { "title": "Stocks", "@title": { "description": "Title for the Stocks application" }, }'''; expect( () { setupLocalizations({'en': arbFileWithTrailingComma}); }, throwsA( isA().having( (L10nException e) => e.message, 'message', allOf( contains('app_en.arb'), contains('FormatException'), contains('Unexpected character'), ), ), ), ); }); testWithoutContext( 'should throw when resource is missing resource attribute (isResourceAttributeRequired = true)', () { const String arbFileWithMissingResourceAttribute = ''' { "title": "Stocks" }'''; expect( () { setupLocalizations({ 'en': arbFileWithMissingResourceAttribute, }, areResourceAttributeRequired: true); }, throwsA( isA().having( (L10nException e) => e.message, 'message', contains('Resource attribute "@title" was not found'), ), ), ); }, ); group('checks for method/getter formatting', () { testWithoutContext('cannot contain non-alphanumeric symbols', () { const String nonAlphaNumericArbFile = ''' { "title!!": "Stocks", "@title!!": { "description": "Title for the Stocks application" } }'''; expect( () => setupLocalizations({'en': nonAlphaNumericArbFile}), throwsA( isA().having( (L10nException e) => e.message, 'message', contains('Invalid ARB resource name'), ), ), ); }); testWithoutContext('must start with lowercase character', () { const String nonAlphaNumericArbFile = ''' { "Title": "Stocks", "@Title": { "description": "Title for the Stocks application" } }'''; expect( () => setupLocalizations({'en': nonAlphaNumericArbFile}), throwsA( isA().having( (L10nException e) => e.message, 'message', contains('Invalid ARB resource name'), ), ), ); }); testWithoutContext('cannot start with a number', () { const String nonAlphaNumericArbFile = ''' { "123title": "Stocks", "@123title": { "description": "Title for the Stocks application" } }'''; expect( () => setupLocalizations({'en': nonAlphaNumericArbFile}), throwsA( isA().having( (L10nException e) => e.message, 'message', contains('Invalid ARB resource name'), ), ), ); }); testWithoutContext('can start with and contain a dollar sign', () { const String dollarArbFile = r''' { "$title$": "Stocks", "@$title$": { "description": "Title for the application" } }'''; setupLocalizations({'en': dollarArbFile}); }); }); testWithoutContext('throws when the language code is not supported', () { const String arbFileWithInvalidCode = ''' { "@@locale": "invalid", "title": "invalid" }'''; final Directory l10nDirectory = fs.currentDirectory .childDirectory('lib') .childDirectory('l10n')..createSync(recursive: true); l10nDirectory.childFile('app_invalid.arb').writeAsStringSync(arbFileWithInvalidCode); expect( () { LocalizationsGenerator( fileSystem: fs, inputPathString: defaultL10nPath, outputPathString: defaultL10nPath, templateArbFileName: 'app_invalid.arb', outputFileString: defaultOutputFileString, classNameString: defaultClassNameString, logger: logger, ) ..loadResources() ..writeOutputFiles(); }, throwsA( isA().having( (L10nException e) => e.message, 'message', contains('"invalid" is not a supported language code.'), ), ), ); }); testWithoutContext('handle number with multiple locale', () { setupLocalizations({ 'en': ''' { "@@locale": "en", "money": "Sum {number}", "@money": { "placeholders": { "number": { "type": "int", "format": "currency" } } } }''', 'ja': ''' { "@@locale": "ja", "money": "合計 {number}", "@money": { "placeholders": { "number": { "type": "int", "format": "decimalPatternDigits", "optionalParameters": { "decimalDigits": 3 } } } } }''', }); expect(getGeneratedFileContent(locale: 'en'), contains('String money(int number)')); expect(getGeneratedFileContent(locale: 'ja'), contains('String money(int number)')); expect(getGeneratedFileContent(locale: 'en'), contains('intl.NumberFormat.currency(')); expect( getGeneratedFileContent(locale: 'ja'), contains('intl.NumberFormat.decimalPatternDigits('), ); expect(getGeneratedFileContent(locale: 'ja'), contains('decimalDigits: 3')); }); testWithoutContext( 'handle number with multiple locale specifying a format only in template', () { setupLocalizations({ 'en': ''' { "@@locale": "en", "money": "Sum {number}", "@money": { "placeholders": { "number": { "type": "int", "format": "decimalPatternDigits", "optionalParameters": { "decimalDigits": 3 } } } } }''', 'ja': ''' { "@@locale": "ja", "money": "合計 {number}", "@money": { "placeholders": { "number": { "type": "int" } } } }''', }); expect(getGeneratedFileContent(locale: 'en'), contains('String money(int number)')); expect(getGeneratedFileContent(locale: 'ja'), contains('String money(int number)')); expect( getGeneratedFileContent(locale: 'en'), contains('intl.NumberFormat.decimalPatternDigits('), ); expect(getGeneratedFileContent(locale: 'en'), contains('decimalDigits: 3')); expect(getGeneratedFileContent(locale: 'en'), contains(r"return 'Sum $numberString'")); expect(getGeneratedFileContent(locale: 'ja'), isNot(contains('intl.NumberFormat'))); expect(getGeneratedFileContent(locale: 'ja'), contains(r"return '合計 $number'")); }, ); testWithoutContext( 'handle number with multiple locale specifying a format only in non-template', () { setupLocalizations({ 'en': ''' { "@@locale": "en", "money": "Sum {number}", "@money": { "placeholders": { "number": { "type": "int" } } } }''', 'ja': ''' { "@@locale": "ja", "money": "合計 {number}", "@money": { "placeholders": { "number": { "type": "int", "format": "decimalPatternDigits", "optionalParameters": { "decimalDigits": 3 } } } } }''', }); expect(getGeneratedFileContent(locale: 'en'), contains('String money(int number)')); expect(getGeneratedFileContent(locale: 'ja'), contains('String money(int number)')); expect(getGeneratedFileContent(locale: 'en'), isNot(contains('intl.NumberFormat'))); expect(getGeneratedFileContent(locale: 'en'), contains(r"return 'Sum $number'")); expect( getGeneratedFileContent(locale: 'ja'), contains('intl.NumberFormat.decimalPatternDigits('), ); expect(getGeneratedFileContent(locale: 'ja'), contains('decimalDigits: 3')); expect(getGeneratedFileContent(locale: 'ja'), contains(r"return '合計 $numberString'")); }, ); }); testWithoutContext('can use type: int without specifying a format', () { const String arbFile = ''' { "orderNumber": "This is order #{number}.", "@orderNumber": { "description": "The title for an order with a given number.", "placeholders": { "number": { "type": "int" } } } }'''; setupLocalizations({'en': arbFile}); expect( getGeneratedFileContent(locale: 'en'), containsIgnoringWhitespace(r''' String orderNumber(int number) { return 'This is order #$number.'; } '''), ); expect(getGeneratedFileContent(locale: 'en'), contains(intlImportDartCode)); }); testWithoutContext('app localizations lookup is a public method', () { setupLocalizations({'en': singleMessageArbFileString}); expect( getGeneratedFileContent(), containsIgnoringWhitespace(r''' AppLocalizations lookupAppLocalizations(Locale locale) { '''), ); }); testWithoutContext('escaping with single quotes', () { const String arbFile = ''' { "singleQuote": "Flutter''s amazing!", "@singleQuote": { "description": "A message with a single quote." } }'''; setupLocalizations({'en': arbFile}, useEscaping: true); expect(getGeneratedFileContent(locale: 'en'), contains(r"Flutter\'s amazing")); }); testWithoutContext('suppress warnings flag actually suppresses warnings', () { const String pluralMessageWithOverriddenParts = ''' { "helloWorlds": "{count,plural, =0{Hello}zero{hello} other{hi}}", "@helloWorlds": { "description": "Properly formatted but has redundant zero cases.", "placeholders": { "count": {} } } }'''; setupLocalizations({ 'en': pluralMessageWithOverriddenParts, }, suppressWarnings: true); expect(logger.hadWarningOutput, isFalse); }); testWithoutContext('can use decimalPatternDigits with decimalDigits optional parameter', () { const String arbFile = ''' { "treeHeight": "Tree height is {height}m.", "@treeHeight": { "placeholders": { "height": { "type": "double", "format": "decimalPatternDigits", "optionalParameters": { "decimalDigits": 3 } } } } }'''; setupLocalizations({'en': arbFile}); final String localizationsFile = getGeneratedFileContent(locale: 'en'); expect( localizationsFile, containsIgnoringWhitespace(r''' String treeHeight(double height) { '''), ); expect( localizationsFile, containsIgnoringWhitespace(r''' NumberFormat.decimalPatternDigits( locale: localeName, decimalDigits: 3 ); '''), ); }); // Regression test for https://github.com/flutter/flutter/issues/125461. testWithoutContext('dollar signs are escaped properly when there is a select clause', () { const String dollarSignWithSelect = r''' { "dollarSignWithSelect": "$nice_bug\nHello Bug! Manifestation #1 {selectPlaceholder, select, case{message} other{messageOther}}" }'''; setupLocalizations({'en': dollarSignWithSelect}); expect( getGeneratedFileContent(locale: 'en'), contains(r'\$nice_bug\nHello Bug! Manifestation #1 $_temp0'), ); }); testWithoutContext('can generate method with named parameter', () { const String arbFile = ''' { "helloName": "Hello {name}!", "@helloName": { "description": "A more personal greeting", "placeholders": { "name": { "type": "String", "description": "The name of the person to greet" } } }, "helloNameAndAge": "Hello {name}! You are {age} years old.", "@helloNameAndAge": { "description": "A more personal greeting", "placeholders": { "name": { "type": "String", "description": "The name of the person to greet" }, "age": { "type": "int", "description": "The age of the person to greet" } } } } '''; setupLocalizations({'en': arbFile}, useNamedParameters: true); final String localizationsFile = getGeneratedFileContent(locale: 'en'); expect( localizationsFile, containsIgnoringWhitespace(r''' String helloName({required String name}) { '''), ); expect( localizationsFile, containsIgnoringWhitespace(r''' String helloNameAndAge({required String name, required int age}) { '''), ); }); // Regression test for https://github.com/flutter/flutter/issues/165794. testWithoutContext('handles missing placeholders gracefully', () async { const String en = r''' { "test": "No placeholder in here" }'''; const String da = r''' { "test": "Placeholder in here {value}", "@test": { "placeholders": { "value": { "type": "String" } } } } '''; setupLocalizations({'en': en, 'da': da}); final String localizationsFile = getGeneratedFileContent(locale: 'en'); expect( localizationsFile, containsIgnoringWhitespace(r'''String get test => 'No placeholder in here'''), ); }); }