diff --git a/very_good_core/hooks/lib/src/models/android_application_id.dart b/very_good_core/hooks/lib/src/models/android_application_id.dart index bf548a9..3c03741 100644 --- a/very_good_core/hooks/lib/src/models/android_application_id.dart +++ b/very_good_core/hooks/lib/src/models/android_application_id.dart @@ -20,12 +20,17 @@ extension type AndroidApplicationId(String value) { required String organizationName, required String projectName, }) { - return AndroidApplicationId( - '${organizationName.snakeCase}.${projectName.snakeCase}', - ); + final segments = []; + for (final segment in organizationName.split('.')) { + if (segment.isEmpty) continue; + segments.add(segment.snakeCase); + } + segments.add(projectName.snakeCase); + + return AndroidApplicationId(segments.join('.')); } - /// Checks if the provided [applicationId] is valid, returning `true` if it is + /// Checks if the [AndroidApplicationId] is valid, returning `true` if it is /// and `false` otherwise. /// /// Although the application ID looks like a traditional Kotlin or Java @@ -39,8 +44,8 @@ extension type AndroidApplicationId(String value) { /// See also: /// /// * [Set the application ID Android documentation](https://developer.android.com/build/configure-app-module#set-application-id) - static bool isValid(AndroidApplicationId applicationId) { - final segments = applicationId.value.split('.'); + bool get isValid { + final segments = value.split('.'); if (segments.length < 2) { // It must have at least two segments (one or more dots). return false; diff --git a/very_good_core/hooks/lib/src/models/ios_application_id.dart b/very_good_core/hooks/lib/src/models/ios_application_id.dart index c467e20..b4bf4fe 100644 --- a/very_good_core/hooks/lib/src/models/ios_application_id.dart +++ b/very_good_core/hooks/lib/src/models/ios_application_id.dart @@ -20,8 +20,13 @@ extension type IosApplicationId(String value) { required String organizationName, required String projectName, }) { - return IosApplicationId( - '${organizationName.paramCase}.${projectName.paramCase}', - ); + final parts = []; + for (final part in organizationName.split('.')) { + if (part.isEmpty) continue; + parts.add(part.paramCase); + } + parts.add(projectName.paramCase); + + return IosApplicationId(parts.join('.')); } } diff --git a/very_good_core/hooks/lib/src/models/very_good_core_configuration.dart b/very_good_core/hooks/lib/src/models/very_good_core_configuration.dart index 0e92a57..142bcd6 100644 --- a/very_good_core/hooks/lib/src/models/very_good_core_configuration.dart +++ b/very_good_core/hooks/lib/src/models/very_good_core_configuration.dart @@ -1,3 +1,4 @@ +import 'package:equatable/equatable.dart'; import 'package:very_good_core_hooks/very_good_core_hooks.dart'; /// The variables specified by this hook. @@ -49,7 +50,7 @@ enum _VeryGoodCoreConfigurationVariables { /// {@template very_good_core_configuration} /// Configuration for the `very_good_core` brick. /// {@endtemplate} -class VeryGoodCoreConfiguration { +class VeryGoodCoreConfiguration extends Equatable { /// {@macro very_good_core_configuration} VeryGoodCoreConfiguration({ String? projectName, @@ -77,7 +78,7 @@ class VeryGoodCoreConfiguration { organizationName: this.organizationName, projectName: this.projectName, ); - if (!AndroidApplicationId.isValid(this.androidApplicationId)) { + if (!this.androidApplicationId.isValid) { throw InvalidAndroidApplicationIdFormat(this.androidApplicationId); } @@ -85,44 +86,45 @@ class VeryGoodCoreConfiguration { AndroidNamespace.fromApplicationId(this.androidApplicationId); } - /// Deserializes a [VeryGoodCoreConfiguration] from a `Map`. - factory VeryGoodCoreConfiguration.fromJson(Map json) { + /// Deserializes a [VeryGoodCoreConfiguration] from a `Map` + /// used to represent the configuration in the `HookContext.vars` map. + factory VeryGoodCoreConfiguration.fromHooksVars(Map vars) { final projectName = - json[_VeryGoodCoreConfigurationVariables.projectName.key]; + vars[_VeryGoodCoreConfigurationVariables.projectName.key]; if (projectName is! String?) { throw ArgumentError.value( - json, - 'json', + vars, + 'vars', '''Expected a value for key "${_VeryGoodCoreConfigurationVariables.projectName.key}" to be of type String?, got $projectName.''', ); } final organizationName = - json[_VeryGoodCoreConfigurationVariables.organizationName.key]; + vars[_VeryGoodCoreConfigurationVariables.organizationName.key]; if (organizationName is! String?) { throw ArgumentError.value( - json, - 'json', + vars, + 'vars', '''Expected a value for key "${_VeryGoodCoreConfigurationVariables.organizationName.key}" to be of type String?, got $organizationName.''', ); } final applicationId = - json[_VeryGoodCoreConfigurationVariables.applicationId.key]; + vars[_VeryGoodCoreConfigurationVariables.applicationId.key]; if (applicationId is! String?) { throw ArgumentError.value( - json, - 'json', + vars, + 'vars', '''Expected a value for key "${_VeryGoodCoreConfigurationVariables.applicationId.key}" to be of type String?, got $applicationId.''', ); } final description = - json[_VeryGoodCoreConfigurationVariables.description.key]; + vars[_VeryGoodCoreConfigurationVariables.description.key]; if (description is! String?) { throw ArgumentError.value( - json, - 'json', + vars, + 'vars', '''Expected a value for key "${_VeryGoodCoreConfigurationVariables.description.key}" to be of type String?, got $description.''', ); } @@ -160,4 +162,15 @@ class VeryGoodCoreConfiguration { /// {@macro android_application_id} late final AndroidApplicationId androidApplicationId; + + @override + List get props => [ + projectName, + organizationName, + description, + windowsApplicationId, + iosApplicationId, + androidNamespace, + androidApplicationId, + ]; } diff --git a/very_good_core/hooks/lib/src/models/windows_application_id.dart b/very_good_core/hooks/lib/src/models/windows_application_id.dart index 50d4f8c..5498e6b 100644 --- a/very_good_core/hooks/lib/src/models/windows_application_id.dart +++ b/very_good_core/hooks/lib/src/models/windows_application_id.dart @@ -14,8 +14,13 @@ extension type WindowsApplicationId(String value) { required String organizationName, required String projectName, }) { - return WindowsApplicationId( - '${organizationName.paramCase}.${projectName.paramCase}', - ); + final parts = []; + for (final part in organizationName.split('.')) { + if (part.isEmpty) continue; + parts.add(part.paramCase); + } + parts.add(projectName.paramCase); + + return WindowsApplicationId(parts.join('.')); } } diff --git a/very_good_core/hooks/pre_gen.dart b/very_good_core/hooks/pre_gen.dart index dd48898..0c57bf9 100644 --- a/very_good_core/hooks/pre_gen.dart +++ b/very_good_core/hooks/pre_gen.dart @@ -2,7 +2,7 @@ import 'package:mason/mason.dart'; import 'package:very_good_core_hooks/very_good_core_hooks.dart'; void run(HookContext context) { - final configuration = VeryGoodCoreConfiguration.fromJson(context.vars); + final configuration = VeryGoodCoreConfiguration.fromHooksVars(context.vars); context.vars = { /// Below are all the variables that are accessible in the templates. diff --git a/very_good_core/hooks/pubspec.yaml b/very_good_core/hooks/pubspec.yaml index dc0c968..6ddb3d2 100644 --- a/very_good_core/hooks/pubspec.yaml +++ b/very_good_core/hooks/pubspec.yaml @@ -4,6 +4,7 @@ environment: sdk: ^3.4.0 dependencies: + equatable: ^2.0.5 mason: ^0.1.0-dev.52 dev_dependencies: diff --git a/very_good_core/hooks/test/pre_gen_test.dart b/very_good_core/hooks/test/pre_gen_test.dart index ef10f41..8e98191 100644 --- a/very_good_core/hooks/test/pre_gen_test.dart +++ b/very_good_core/hooks/test/pre_gen_test.dart @@ -14,6 +14,30 @@ void main() { context = _MockHookContext(); }); + test('populates variables', () { + final vars = { + 'project_name': 'my_app', + 'org_name': 'com.example', + 'application_id': 'app_id', + 'description': 'A new Flutter project.', + }; + when(() => context.vars).thenReturn(vars); + + pre_gen.run(context); + + expect( + context.vars, + { + 'project_name': 'my_app', + 'org_name': 'com.example', + 'application_id': 'app_id', + 'description': 'A new Flutter project.', + 'application_id_android': 'com.example.my_app', + 'application_id': 'com.example.my-app', + }, + ); + }); + group('application_id_android', () { test('when specified is unmodified', () { final vars = { diff --git a/very_good_core/hooks/test/src/models/android_application_id_test.dart b/very_good_core/hooks/test/src/models/android_application_id_test.dart new file mode 100644 index 0000000..e78e36f --- /dev/null +++ b/very_good_core/hooks/test/src/models/android_application_id_test.dart @@ -0,0 +1,60 @@ +import 'package:test/test.dart'; +import 'package:very_good_core_hooks/very_good_core_hooks.dart'; + +void main() { + group('$AndroidApplicationId', () { + group('fallback', () { + test( + 'concatenates organization name with project name in snake case', + () { + const organizationName = 'com.example.hello-world'; + const projectName = 'my app'; + final androidApplicationId = AndroidApplicationId.fallback( + organizationName: organizationName, + projectName: projectName, + ); + expect(androidApplicationId.value, 'com.example.hello_world.my_app'); + }, + ); + + test( + 'ignores empty segments', + () { + const organizationName = 'com..example..hello-world'; + const projectName = 'my app'; + final androidApplicationId = AndroidApplicationId.fallback( + organizationName: organizationName, + projectName: projectName, + ); + expect(androidApplicationId.value, 'com.example.hello_world.my_app'); + }, + ); + }); + + group('isValid', () { + group('returns true', () { + test('when Android ID is valid', () { + final androidApplicationId = AndroidApplicationId('com.example.app'); + expect(androidApplicationId.isValid, isTrue); + }); + }); + + group('returns false', () { + test('when Android ID has less than two segments', () { + final androidApplicationId = AndroidApplicationId('com'); + expect(androidApplicationId.isValid, isFalse); + }); + + test('when Android ID has a segment that starts with a non-letter', () { + final androidApplicationId = AndroidApplicationId('1com.example.app'); + expect(androidApplicationId.isValid, isFalse); + }); + + test('when Android ID has a segment with a special character', () { + final androidApplicationId = AndroidApplicationId('com.example.app!'); + expect(androidApplicationId.isValid, isFalse); + }); + }); + }); + }); +} diff --git a/very_good_core/hooks/test/src/models/very_good_core_configuration_test.dart b/very_good_core/hooks/test/src/models/very_good_core_configuration_test.dart new file mode 100644 index 0000000..ae93f71 --- /dev/null +++ b/very_good_core/hooks/test/src/models/very_good_core_configuration_test.dart @@ -0,0 +1,161 @@ +import 'package:test/test.dart'; +import 'package:very_good_core_hooks/very_good_core_hooks.dart'; + +void main() { + group('$VeryGoodCoreConfiguration', () { + group('defaults', () { + test('project_name to "my_app"', () { + final configuration = VeryGoodCoreConfiguration(); + expect(configuration.projectName, 'my_app'); + }); + + test('organization_name to "com.example"', () { + final configuration = VeryGoodCoreConfiguration(); + expect(configuration.organizationName, 'com.example'); + }); + + test('description to "A Very Good App"', () { + final configuration = VeryGoodCoreConfiguration(); + expect(configuration.description, 'A Very Good App'); + }); + + test('windows_application_id to "com.example.my-app"', () { + final configuration = VeryGoodCoreConfiguration(); + expect(configuration.windowsApplicationId.value, 'com.example.my-app'); + }); + + test('ios_application_id to "com.example.my-app"', () { + final configuration = VeryGoodCoreConfiguration(); + expect(configuration.iosApplicationId.value, 'com.example.my-app'); + }); + + test('android_application_id to "com.example.my_app"', () { + final configuration = VeryGoodCoreConfiguration(); + expect(configuration.androidApplicationId.value, 'com.example.my_app'); + }); + }); + + group('throws', () { + group('a $InvalidAndroidApplicationIdFormat when Android ID', () { + test('has special characters', () { + expect( + () => VeryGoodCoreConfiguration( + androidApplicationId: AndroidApplicationId('com.example.my_app!'), + ), + throwsA(isA()), + ); + }); + + test('parts start with numeric character', () { + expect( + () => VeryGoodCoreConfiguration( + androidApplicationId: + AndroidApplicationId('1com.1example.1my_app'), + ), + throwsA(isA()), + ); + }); + + test('has a single part', () { + expect( + () => VeryGoodCoreConfiguration( + androidApplicationId: AndroidApplicationId('com'), + ), + throwsA(isA()), + ); + }); + }); + }); + + group('fromHooksVars', () { + test('decodes as expected', () { + final vars = { + 'project_name': 'very good app', + 'org_name': 'com.verygood', + 'application_id': 'com.verygood.very_good_app', + 'description': 'A Very Good App', + }; + + final configuration = VeryGoodCoreConfiguration.fromHooksVars(vars); + expect( + configuration, + equals( + VeryGoodCoreConfiguration( + projectName: 'very good app', + organizationName: 'com.verygood', + description: 'A Very Good App', + windowsApplicationId: + WindowsApplicationId('com.verygood.very_good_app'), + iosApplicationId: IosApplicationId('com.verygood.very_good_app'), + androidApplicationId: + AndroidApplicationId('com.verygood.very_good_app'), + androidNamespace: AndroidNamespace('com.verygood.very_good_app'), + ), + ), + ); + }); + + group('throws $ArgumentError', () { + test('when "project_name" is not a String?', () { + final vars = {'project_name': 42}; + + expect( + () => VeryGoodCoreConfiguration.fromHooksVars(vars), + throwsA( + isA().having( + (error) => error.message, + 'message', + '''Expected a value for key "project_name" to be of type String?, got 42.''', + ), + ), + ); + }); + + test('when "org_name" is not a String?', () { + final vars = {'org_name': 42}; + + expect( + () => VeryGoodCoreConfiguration.fromHooksVars(vars), + throwsA( + isA().having( + (error) => error.message, + 'message', + '''Expected a value for key "org_name" to be of type String?, got 42.''', + ), + ), + ); + }); + + test('when "application_id" is not a String?', () { + final vars = {'application_id': 42}; + + expect( + () => VeryGoodCoreConfiguration.fromHooksVars(vars), + throwsA( + isA().having( + (error) => error.message, + 'message', + '''Expected a value for key "application_id" to be of type String?, got 42.''', + ), + ), + ); + }); + + test('when "description" is not a String?', () { + final vars = {'description': 42}; + + expect( + () => VeryGoodCoreConfiguration.fromHooksVars(vars), + throwsA( + isA().having( + (error) => error.message, + 'message', + '''Expected a value for key "description" to be of type String?, got 42.''', + ), + ), + ); + }); + }); + }); + }); +}