From 5f68e8a512c81628b4507ce89287363cbf811839 Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Sat, 10 May 2025 12:11:05 -0600 Subject: [PATCH 01/24] add CLI --- CHANGELOG.md | 6 ++ bin/bdd_flutter.dart | 82 +++++++++++++++++++ bin/src/rename.dart | 0 example/bdd_ignore.yaml | 3 - example/pubspec.lock | 49 +++++------ example/test/counter/counter.bdd_test.g.dart | 14 ++-- lib/src/builder.dart | 10 --- .../feature/builder/domain/bdd_ignore.dart | 26 ------ pubspec.yaml | 8 ++ 9 files changed, 125 insertions(+), 73 deletions(-) create mode 100644 bin/bdd_flutter.dart create mode 100644 bin/src/rename.dart delete mode 100644 example/bdd_ignore.yaml delete mode 100644 lib/src/feature/builder/domain/bdd_ignore.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index d88db84..8196b6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.3.0 + +- add custom CLI +- `dart run bdd remane` to remove `.g` from generated files extension +- `dart run bdd build` to generate files from feature files + ## 0.2.0 - change output files extension to `.bdd_scenarios.g.dart` and `.bdd_test.g.dart` diff --git a/bin/bdd_flutter.dart b/bin/bdd_flutter.dart new file mode 100644 index 0000000..014ddab --- /dev/null +++ b/bin/bdd_flutter.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:bdd_flutter/bdd_flutter.dart'; +import 'package:bdd_flutter/src/feature/builder/bdd_builders/bdd_factory.dart'; +import 'package:bdd_flutter/src/feature/builder/domain/bdd_options.dart'; +import 'package:build/build.dart'; +import 'package:yaml/yaml.dart'; + +void main(List arguments) async { + if (arguments.contains('build')) { + await build(arguments); + } else if (arguments.contains('rename')) { + rename(); + } +} + +Future build(List arguments) async { + // loop through all the .feature files in the test/features directory + final features = Directory('test/').listSync(recursive: true).where((file) => file.path.endsWith('.feature')).toList(); + + final ignoredFiles = getIgnoredFiles(); + print(ignoredFiles); + + for (final feature in features) { + print(feature.path); + } + + final options = BuilderOptions({'generate_widget_tests': true}); + final factory = BDDFactory.create(BDDOptions()); + + for (final feature in features) { + final featureFile = File(feature.path); + final featureContent = featureFile.readAsStringSync(); + final parsedFeature = factory.featureBuilder.parseFeature(featureContent); + if (ignoredFiles.contains(feature.path)) { + print('Skipping ${feature.path} because it is ignored'); + continue; + } else { + print('Processing ${feature.path}'); + } + + //build scenarios + final scenarios = await factory.scenarioBuilder.buildScenarioFile(parsedFeature); + final scenariosFile = File('${feature.path.replaceAll('.feature', '')}.bdd_scenarios.g.dart'); + scenariosFile.writeAsStringSync(scenarios); + + //build test file + final testCases = await factory.testFileBuilder.buildTestFile(parsedFeature); + final testFile = File('${feature.path.replaceAll('.feature', '')}.bdd_test.g.dart'); + testFile.writeAsStringSync(testCases); + } +} + +void rename() {} + +List getIgnoredFiles() { + // parse build.yaml file, get the ignore_features property + final buildYaml = File('build.yaml'); + final yaml = loadYaml(buildYaml.readAsStringSync()); + if (yaml == null) return []; + + final targets = yaml['targets']; + if (targets == null) return []; + + final defaultTarget = targets['\$default']; + if (defaultTarget == null) return []; + + final builders = defaultTarget['builders']; + if (builders == null) return []; + + final bddBuilder = builders['bdd_flutter|bdd_test_builder']; + if (bddBuilder == null) return []; + + final options = bddBuilder['options']; + if (options == null) return []; + + final ignoreFeatures = options['ignore_features']; + if (ignoreFeatures == null) return []; + + return List.from(ignoreFeatures); +} diff --git a/bin/src/rename.dart b/bin/src/rename.dart new file mode 100644 index 0000000..e69de29 diff --git a/example/bdd_ignore.yaml b/example/bdd_ignore.yaml deleted file mode 100644 index 1bbdd28..0000000 --- a/example/bdd_ignore.yaml +++ /dev/null @@ -1,3 +0,0 @@ -ignore_files: - - test/sample/sample.bdd_scenarios.dart - - test/sample/sample.bdd_test.dart diff --git a/example/pubspec.lock b/example/pubspec.lock index bb999fb..c57a9a2 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -5,23 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" + sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f url: "https://pub.dev" source: hosted - version: "76.0.0" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.3" + version: "82.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" + sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" url: "https://pub.dev" source: hosted - version: "6.11.0" + version: "7.4.5" args: dependency: transitive description: @@ -133,6 +128,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" clock: dependency: transitive description: @@ -169,10 +172,10 @@ packages: dependency: transitive description: name: coverage - sha256: e3493833ea012784c740e341952298f1cc77f1f01b1bbc3eb4eecf6984fb7f43 + sha256: "802bd084fb82e55df091ec8ad1553a7331b61c08251eef19a508b6f3f3a9858d" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.13.1" crypto: dependency: transitive description: @@ -185,10 +188,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820" + sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac" url: "https://pub.dev" source: hosted - version: "2.3.8" + version: "3.0.1" fake_async: dependency: transitive description: @@ -259,10 +262,10 @@ packages: dependency: transitive description: name: http - sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" http_multi_server: dependency: transitive description: @@ -343,14 +346,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - macros: - dependency: transitive - description: - name: macros - sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" - url: "https://pub.dev" - source: hosted - version: "0.1.3-main.0" matcher: dependency: transitive description: @@ -608,18 +603,18 @@ packages: dependency: transitive description: name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" url: "https://pub.dev" source: hosted - version: "0.1.6" + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" webkit_inspection_protocol: dependency: transitive description: diff --git a/example/test/counter/counter.bdd_test.g.dart b/example/test/counter/counter.bdd_test.g.dart index 8b1940c..54c263c 100644 --- a/example/test/counter/counter.bdd_test.g.dart +++ b/example/test/counter/counter.bdd_test.g.dart @@ -8,15 +8,15 @@ void main() { testWidgets('Increment', (tester) async { //Scenario: Increment final examples = [ - {'value': '1','expectedvalue': '1',}, - {'value': '2','expectedvalue': '2',}, - {'value': '3','expectedvalue': '3',}, + {'value': '1', 'expectedvalue': '1'}, + {'value': '2', 'expectedvalue': '2'}, + {'value': '3', 'expectedvalue': '3'}, ]; for (var example in examples) { - // When I increment the counter by - await IncrementScenario.iIncrementTheCounterBy(tester, example['value']!); - // Then the counter should have value - await IncrementScenario.theCounterShouldHaveValue(tester, example['expected_value']!); + // When I increment the counter by + await IncrementScenario.iIncrementTheCounterBy(tester, example['value']!); + // Then the counter should have value + await IncrementScenario.theCounterShouldHaveValue(tester, example['expected_value']!); } }); }); diff --git a/lib/src/builder.dart b/lib/src/builder.dart index 9b4495e..f0d7fe1 100644 --- a/lib/src/builder.dart +++ b/lib/src/builder.dart @@ -1,7 +1,6 @@ // lib/builder.dart import 'dart:async'; -import 'package:bdd_flutter/src/feature/builder/domain/bdd_ignore.dart'; import 'package:bdd_flutter/src/feature/builder/domain/bdd_options.dart'; import 'package:build/build.dart'; @@ -20,8 +19,6 @@ class BDDTestBuilder implements Builder { @override Future build(BuildStep buildStep) async { - await BDDIgnore.initialize(); - // Check if the feature file should be ignored if (options.ignoreFeatures.contains(buildStep.inputId.path)) { return; @@ -30,13 +27,6 @@ class BDDTestBuilder implements Builder { final factory = BDDFactory.create(options); final feature = await factory.featureBuilder.build(buildStep); - // Check if we should ignore the generated files - final outputId = buildStep.inputId.changeExtension('.bdd_test.g.dart'); - - if (BDDIgnore.shouldIgnore(outputId.path)) { - return; - } - // Skip generation if feature is empty (due to @ignore) if (feature.name.isEmpty) { return; diff --git a/lib/src/feature/builder/domain/bdd_ignore.dart b/lib/src/feature/builder/domain/bdd_ignore.dart deleted file mode 100644 index 21add99..0000000 --- a/lib/src/feature/builder/domain/bdd_ignore.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'dart:io'; - -import 'package:yaml/yaml.dart'; - -class BDDIgnore { - static final List _ignoredFiles = []; - static bool _isInitialized = false; - - static Future initialize() async { - if (_isInitialized) return; - - final ignoreFile = File('bdd_ignore.yaml'); - if (await ignoreFile.exists()) { - final content = await ignoreFile.readAsString(); - final yaml = loadYaml(content); - if (yaml is YamlMap && yaml['ignore_files'] is YamlList) { - _ignoredFiles.addAll((yaml['ignore_files'] as YamlList).map((e) => e.toString()).toList()); - } - } - _isInitialized = true; - } - - static bool shouldIgnore(String filePath) { - return _ignoredFiles.contains(filePath); - } -} diff --git a/pubspec.yaml b/pubspec.yaml index 1c1997c..1a2976c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,11 +7,19 @@ environment: sdk: ^3.1.0 flutter: ">=3.1.0" +topics: + - bdd + - test + - widgettest + - gherkin + dependencies: flutter: sdk: flutter yaml: ^3.0.0 build: ^2.3.0 + args: ^2.4.2 + path: ^1.8.3 dev_dependencies: flutter_test: From 5585f5c3dd73dbaa4c9b7737331eb772a9a27fb8 Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Sat, 10 May 2025 12:19:26 -0600 Subject: [PATCH 02/24] parse option --- bin/bdd_flutter.dart | 40 +++++++++++++++----- example/test/counter/counter.bdd_test.g.dart | 14 +++---- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/bin/bdd_flutter.dart b/bin/bdd_flutter.dart index 014ddab..18faad0 100644 --- a/bin/bdd_flutter.dart +++ b/bin/bdd_flutter.dart @@ -1,10 +1,8 @@ -import 'dart:convert'; +import 'dart:developer' as dev; import 'dart:io'; -import 'package:bdd_flutter/bdd_flutter.dart'; import 'package:bdd_flutter/src/feature/builder/bdd_builders/bdd_factory.dart'; import 'package:bdd_flutter/src/feature/builder/domain/bdd_options.dart'; -import 'package:build/build.dart'; import 'package:yaml/yaml.dart'; void main(List arguments) async { @@ -20,14 +18,9 @@ Future build(List arguments) async { final features = Directory('test/').listSync(recursive: true).where((file) => file.path.endsWith('.feature')).toList(); final ignoredFiles = getIgnoredFiles(); - print(ignoredFiles); - for (final feature in features) { - print(feature.path); - } - - final options = BuilderOptions({'generate_widget_tests': true}); - final factory = BDDFactory.create(BDDOptions()); + final options = getBDDOptions(); + final factory = BDDFactory.create(options); for (final feature in features) { final featureFile = File(feature.path); @@ -80,3 +73,30 @@ List getIgnoredFiles() { return List.from(ignoreFeatures); } + +BDDOptions getBDDOptions() { + final buildYaml = File('build.yaml'); + final yaml = loadYaml(buildYaml.readAsStringSync()); + if (yaml == null) return BDDOptions(); + + final targets = yaml['targets']; + if (targets == null) return BDDOptions(); + + final defaultTarget = targets['\$default']; + if (defaultTarget == null) return BDDOptions(); + + final builders = defaultTarget['builders']; + if (builders == null) return BDDOptions(); + + final bddBuilder = builders['bdd_flutter|bdd_test_builder']; + if (bddBuilder == null) return BDDOptions(); + + final options = bddBuilder['options']; + if (options == null) return BDDOptions(); + + return BDDOptions( + generateWidgetTests: options['generate_widget_tests'] as bool? ?? true, + enableReporter: options['enable_reporter'] as bool? ?? false, + ignoreFeatures: (options['ignore_features'] as YamlList?)?.cast() ?? [], + ); +} diff --git a/example/test/counter/counter.bdd_test.g.dart b/example/test/counter/counter.bdd_test.g.dart index 54c263c..8b1940c 100644 --- a/example/test/counter/counter.bdd_test.g.dart +++ b/example/test/counter/counter.bdd_test.g.dart @@ -8,15 +8,15 @@ void main() { testWidgets('Increment', (tester) async { //Scenario: Increment final examples = [ - {'value': '1', 'expectedvalue': '1'}, - {'value': '2', 'expectedvalue': '2'}, - {'value': '3', 'expectedvalue': '3'}, + {'value': '1','expectedvalue': '1',}, + {'value': '2','expectedvalue': '2',}, + {'value': '3','expectedvalue': '3',}, ]; for (var example in examples) { - // When I increment the counter by - await IncrementScenario.iIncrementTheCounterBy(tester, example['value']!); - // Then the counter should have value - await IncrementScenario.theCounterShouldHaveValue(tester, example['expected_value']!); + // When I increment the counter by + await IncrementScenario.iIncrementTheCounterBy(tester, example['value']!); + // Then the counter should have value + await IncrementScenario.theCounterShouldHaveValue(tester, example['expected_value']!); } }); }); From ba872bf4e0c7dc617a1898e5b3b6cd6ec0c3db20 Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Sat, 10 May 2025 12:31:03 -0600 Subject: [PATCH 03/24] rename cli --- bin/bdd_flutter.dart | 41 +++-------------- bin/src/rename.dart | 46 +++++++++++++++++++ ...rios.g.dart => counter.bdd_scenarios.dart} | 0 ....bdd_test.g.dart => counter.bdd_test.dart} | 2 +- ...arios.g.dart => sample.bdd_scenarios.dart} | 0 ...e.bdd_test.g.dart => sample.bdd_test.dart} | 2 +- 6 files changed, 55 insertions(+), 36 deletions(-) rename example/test/counter/{counter.bdd_scenarios.g.dart => counter.bdd_scenarios.dart} (100%) rename example/test/counter/{counter.bdd_test.g.dart => counter.bdd_test.dart} (95%) rename example/test/sample/{sample.bdd_scenarios.g.dart => sample.bdd_scenarios.dart} (100%) rename example/test/sample/{sample.bdd_test.g.dart => sample.bdd_test.dart} (98%) diff --git a/bin/bdd_flutter.dart b/bin/bdd_flutter.dart index 18faad0..fafbbfa 100644 --- a/bin/bdd_flutter.dart +++ b/bin/bdd_flutter.dart @@ -4,12 +4,14 @@ import 'dart:io'; import 'package:bdd_flutter/src/feature/builder/bdd_builders/bdd_factory.dart'; import 'package:bdd_flutter/src/feature/builder/domain/bdd_options.dart'; import 'package:yaml/yaml.dart'; +import 'src/rename.dart'; void main(List arguments) async { - if (arguments.contains('build')) { + if (arguments.isEmpty) { await build(arguments); - } else if (arguments.contains('rename')) { - rename(); + } else if (arguments.first == 'rename') { + print('rename'); + rename(arguments.skip(1).toList()); } } @@ -17,11 +19,11 @@ Future build(List arguments) async { // loop through all the .feature files in the test/features directory final features = Directory('test/').listSync(recursive: true).where((file) => file.path.endsWith('.feature')).toList(); - final ignoredFiles = getIgnoredFiles(); - final options = getBDDOptions(); final factory = BDDFactory.create(options); + final ignoredFiles = options.ignoreFeatures; + for (final feature in features) { final featureFile = File(feature.path); final featureContent = featureFile.readAsStringSync(); @@ -45,35 +47,6 @@ Future build(List arguments) async { } } -void rename() {} - -List getIgnoredFiles() { - // parse build.yaml file, get the ignore_features property - final buildYaml = File('build.yaml'); - final yaml = loadYaml(buildYaml.readAsStringSync()); - if (yaml == null) return []; - - final targets = yaml['targets']; - if (targets == null) return []; - - final defaultTarget = targets['\$default']; - if (defaultTarget == null) return []; - - final builders = defaultTarget['builders']; - if (builders == null) return []; - - final bddBuilder = builders['bdd_flutter|bdd_test_builder']; - if (bddBuilder == null) return []; - - final options = bddBuilder['options']; - if (options == null) return []; - - final ignoreFeatures = options['ignore_features']; - if (ignoreFeatures == null) return []; - - return List.from(ignoreFeatures); -} - BDDOptions getBDDOptions() { final buildYaml = File('build.yaml'); final yaml = loadYaml(buildYaml.readAsStringSync()); diff --git a/bin/src/rename.dart b/bin/src/rename.dart index e69de29..a6408bb 100644 --- a/bin/src/rename.dart +++ b/bin/src/rename.dart @@ -0,0 +1,46 @@ +import 'dart:io'; + +/// this will remove `.g` from the file name +/// +/// suggested to run after writing test, this will prevent the file from being overwritten +/// the next time the build command is run +void rename(List arguments) { + // arg can be list of feature name, if not provided, all features will be renamed + // if provided, only the features in the list will be renamed + + if (arguments.isEmpty) { + print('No feature name provided, all features will be renamed'); + } else { + print('Renaming features: ${arguments.join(', ')}'); + } + + final testDir = Directory('test'); + if (!testDir.existsSync()) { + print('No test directory found'); + return; + } + + final files = testDir.listSync(recursive: true).where((file) => file.path.endsWith('.g.dart')).map((file) => File(file.path)).where((file) { + if (arguments.isEmpty) return true; + final featureName = file.path.split('/').last.split('.').first; + return arguments.any((arg) => featureName.contains(arg)); + }).toList(); + + if (files.isEmpty) { + print('No generated files found to rename'); + return; + } + + for (final file in files) { + final newName = file.path.replaceAll('.g.dart', '.dart'); + print('Renaming ${file.path} to $newName'); + + // Update import statements in the file + final content = file.readAsStringSync(); + final updatedContent = content.replaceAll('.g.dart', '.dart'); + file.writeAsStringSync(updatedContent); + + // Rename the file + file.renameSync(newName); + } +} diff --git a/example/test/counter/counter.bdd_scenarios.g.dart b/example/test/counter/counter.bdd_scenarios.dart similarity index 100% rename from example/test/counter/counter.bdd_scenarios.g.dart rename to example/test/counter/counter.bdd_scenarios.dart diff --git a/example/test/counter/counter.bdd_test.g.dart b/example/test/counter/counter.bdd_test.dart similarity index 95% rename from example/test/counter/counter.bdd_test.g.dart rename to example/test/counter/counter.bdd_test.dart index 8b1940c..fe19468 100644 --- a/example/test/counter/counter.bdd_test.g.dart +++ b/example/test/counter/counter.bdd_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'counter.bdd_scenarios.g.dart'; +import 'counter.bdd_scenarios.dart'; void main() { group('Counter', () { diff --git a/example/test/sample/sample.bdd_scenarios.g.dart b/example/test/sample/sample.bdd_scenarios.dart similarity index 100% rename from example/test/sample/sample.bdd_scenarios.g.dart rename to example/test/sample/sample.bdd_scenarios.dart diff --git a/example/test/sample/sample.bdd_test.g.dart b/example/test/sample/sample.bdd_test.dart similarity index 98% rename from example/test/sample/sample.bdd_test.g.dart rename to example/test/sample/sample.bdd_test.dart index 2e514fa..2e3876f 100644 --- a/example/test/sample/sample.bdd_test.g.dart +++ b/example/test/sample/sample.bdd_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'sample.bdd_scenarios.g.dart'; +import 'sample.bdd_scenarios.dart'; void main() { group('Sample', () { From e91205c4e95829dd92cc08dca2eb46b6d7fe5cd9 Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Sat, 10 May 2025 16:33:06 -0600 Subject: [PATCH 04/24] eject build_runner --- README.md | 35 +++-- bin/bdd_flutter.dart | 70 +-------- build.yaml | 8 - example/pubspec.lock | 146 +----------------- example/pubspec.yaml | 1 - .../calculator/calculator.bdd_scenarios.dart | 7 +- .../test/calculator/calculator.bdd_test.dart | 24 +-- lib/bdd_flutter.dart | 35 ++--- lib/src/builder.dart | 52 ------- lib/src/constraints/file_extenstion.dart | 7 + .../bdd_builders/bdd_feature_builder.dart | 9 -- .../bdd_builders/bdd_test_file_builder.dart | 17 +- .../bdd_builders/scenario_file_builder.dart | 10 -- lib/src/generator/build_command.dart | 87 +++++++++++ {bin/src => lib/src/runner}/rename.dart | 12 +- pubspec.yaml | 20 +-- 16 files changed, 170 insertions(+), 370 deletions(-) delete mode 100644 build.yaml delete mode 100644 lib/src/builder.dart create mode 100644 lib/src/constraints/file_extenstion.dart create mode 100644 lib/src/generator/build_command.dart rename {bin/src => lib/src/runner}/rename.dart (74%) diff --git a/README.md b/README.md index acd73ea..ee515b4 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A powerful Flutter package that simplifies Behavior Driven Development (BDD) by - โšก Generate boilerplate test files automatically - ๐Ÿงช Support for both widget tests and unit tests - โš™๏ธ Configurable test generation +- ๐Ÿ“„ Ignore specific generated files using `.bdd_config.yaml` ## ๐Ÿ“ฆ Installation @@ -21,7 +22,6 @@ Add the following dependencies to your package's `pubspec.yaml` file: ```yaml dev_dependencies: bdd_flutter: any - build_runner: any ``` ## ๐Ÿš€ Quick Start @@ -41,10 +41,10 @@ Feature: Counter | 3 | 3 | ``` -2. Generate test files: +2. Run the generator to create test files: ```bash -flutter pub run build_runner build +dart run bdd_flutter build ``` 3. Run your tests: @@ -78,21 +78,22 @@ This approach ensures that: - Generated files are properly ignored in version control - You maintain a clean project structure -## โš™๏ธ Configuration +## ๐Ÿš€ Configuration -Configure test generation in your `build.yaml`: +You can configure the generator in `.bdd_config.yaml`: ```yaml -targets: - $default: - builders: - bdd_flutter|bdd_test_builder: - options: - generate_widget_tests: false # Default: true - enable_reporter: true # Default: false - ignore_features: - - "test/features/ignored.feature" - - "test/features/another_ignored.feature" +generate_widget_tests: true +enable_reporter: false +ignore_features: + - test/features/login.feature + - test/features/registration.feature +``` + +Or use command-line arguments: + +```bash +dart run bdd_flutter build --no-widget-tests --enable-reporter --ignore login.feature ``` ### Configuration Options @@ -191,3 +192,7 @@ We welcome contributions! Please feel free to: ## ๐Ÿ“„ License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Additional Information + +For more information, visit the [documentation](https://example.com/bdd_flutter). diff --git a/bin/bdd_flutter.dart b/bin/bdd_flutter.dart index fafbbfa..11bb6b5 100644 --- a/bin/bdd_flutter.dart +++ b/bin/bdd_flutter.dart @@ -1,75 +1,11 @@ -import 'dart:developer' as dev; -import 'dart:io'; - -import 'package:bdd_flutter/src/feature/builder/bdd_builders/bdd_factory.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/bdd_options.dart'; -import 'package:yaml/yaml.dart'; -import 'src/rename.dart'; +import 'package:bdd_flutter/src/generator/build_command.dart'; +import 'package:bdd_flutter/src/runner/rename.dart'; void main(List arguments) async { if (arguments.isEmpty) { - await build(arguments); + await generate(arguments); } else if (arguments.first == 'rename') { print('rename'); rename(arguments.skip(1).toList()); } } - -Future build(List arguments) async { - // loop through all the .feature files in the test/features directory - final features = Directory('test/').listSync(recursive: true).where((file) => file.path.endsWith('.feature')).toList(); - - final options = getBDDOptions(); - final factory = BDDFactory.create(options); - - final ignoredFiles = options.ignoreFeatures; - - for (final feature in features) { - final featureFile = File(feature.path); - final featureContent = featureFile.readAsStringSync(); - final parsedFeature = factory.featureBuilder.parseFeature(featureContent); - if (ignoredFiles.contains(feature.path)) { - print('Skipping ${feature.path} because it is ignored'); - continue; - } else { - print('Processing ${feature.path}'); - } - - //build scenarios - final scenarios = await factory.scenarioBuilder.buildScenarioFile(parsedFeature); - final scenariosFile = File('${feature.path.replaceAll('.feature', '')}.bdd_scenarios.g.dart'); - scenariosFile.writeAsStringSync(scenarios); - - //build test file - final testCases = await factory.testFileBuilder.buildTestFile(parsedFeature); - final testFile = File('${feature.path.replaceAll('.feature', '')}.bdd_test.g.dart'); - testFile.writeAsStringSync(testCases); - } -} - -BDDOptions getBDDOptions() { - final buildYaml = File('build.yaml'); - final yaml = loadYaml(buildYaml.readAsStringSync()); - if (yaml == null) return BDDOptions(); - - final targets = yaml['targets']; - if (targets == null) return BDDOptions(); - - final defaultTarget = targets['\$default']; - if (defaultTarget == null) return BDDOptions(); - - final builders = defaultTarget['builders']; - if (builders == null) return BDDOptions(); - - final bddBuilder = builders['bdd_flutter|bdd_test_builder']; - if (bddBuilder == null) return BDDOptions(); - - final options = bddBuilder['options']; - if (options == null) return BDDOptions(); - - return BDDOptions( - generateWidgetTests: options['generate_widget_tests'] as bool? ?? true, - enableReporter: options['enable_reporter'] as bool? ?? false, - ignoreFeatures: (options['ignore_features'] as YamlList?)?.cast() ?? [], - ); -} diff --git a/build.yaml b/build.yaml deleted file mode 100644 index 9b65fee..0000000 --- a/build.yaml +++ /dev/null @@ -1,8 +0,0 @@ -builders: - bdd_flutter|bdd_test_builder: - import: "package:bdd_flutter/bdd_flutter.dart" - builder_factories: ["bddTestBuilder"] - build_extensions: - { ".feature": [".bdd_scenarios.g.dart", ".bdd_test.g.dart"] } - auto_apply: dependents - build_to: source diff --git a/example/pubspec.lock b/example/pubspec.lock index c57a9a2..ef59f99 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -39,7 +39,7 @@ packages: path: ".." relative: true source: path - version: "0.2.0" + version: "0.0.1" boolean_selector: dependency: transitive description: @@ -48,70 +48,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" - build: - dependency: transitive - description: - name: build - sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 - url: "https://pub.dev" - source: hosted - version: "2.4.2" - build_config: - dependency: transitive - description: - name: build_config - sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" - url: "https://pub.dev" - source: hosted - version: "1.1.2" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" - url: "https://pub.dev" - source: hosted - version: "4.0.4" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 - url: "https://pub.dev" - source: hosted - version: "2.4.4" - build_runner: - dependency: "direct dev" - description: - name: build_runner - sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" - url: "https://pub.dev" - source: hosted - version: "2.4.15" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" - url: "https://pub.dev" - source: hosted - version: "8.0.0" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 - url: "https://pub.dev" - source: hosted - version: "8.9.5" characters: dependency: transitive description: @@ -120,14 +56,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff - url: "https://pub.dev" - source: hosted - version: "2.0.3" cli_config: dependency: transitive description: @@ -144,14 +72,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" - url: "https://pub.dev" - source: hosted - version: "4.10.1" collection: dependency: transitive description: @@ -184,14 +104,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac" - url: "https://pub.dev" - source: hosted - version: "3.0.1" fake_async: dependency: transitive description: @@ -208,14 +120,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -250,22 +154,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - http: - dependency: transitive - description: - name: http - sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" - url: "https://pub.dev" - source: hosted - version: "1.4.0" http_multi_server: dependency: transitive description: @@ -298,14 +186,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.2" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.dev" - source: hosted - version: "4.9.0" leak_tracker: dependency: transitive description: @@ -418,14 +298,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.dev" - source: hosted - version: "1.5.0" shelf: dependency: transitive description: @@ -503,14 +375,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" string_scanner: dependency: transitive description: @@ -551,14 +415,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.8" - timing: - dependency: transitive - description: - name: timing - sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.dev" - source: hosted - version: "1.0.2" typed_data: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 25bb512..b9a29c0 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -16,7 +16,6 @@ dev_dependencies: flutter_lints: ^5.0.0 bdd_flutter: path: ../ - build_runner: ^2.4.11 test: ^1.20.0 flutter: diff --git a/example/test/calculator/calculator.bdd_scenarios.dart b/example/test/calculator/calculator.bdd_scenarios.dart index 12fad21..d044245 100644 --- a/example/test/calculator/calculator.bdd_scenarios.dart +++ b/example/test/calculator/calculator.bdd_scenarios.dart @@ -12,9 +12,10 @@ class AddTwoNumbersScenario { static Future theResultShouldBe3(WidgetTester tester) async { // TODO: Implement Then the result should be 3 } + } -class SubtractTwoNumbersScenario { +class Subtract { static Future iHaveTheNumber5(WidgetTester tester) async { // TODO: Implement Given I have the number 5 } @@ -26,6 +27,7 @@ class SubtractTwoNumbersScenario { static Future theResultShouldBe2(WidgetTester tester) async { // TODO: Implement Then the result should be 2 } + } class MultiplyTwoNumbersScenario { @@ -40,6 +42,7 @@ class MultiplyTwoNumbersScenario { static Future theResultShouldBe6(WidgetTester tester) async { // TODO: Implement Then the result should be 6 } + } class DivideTwoNumbersScenario { @@ -54,4 +57,6 @@ class DivideTwoNumbersScenario { static Future theResultShouldBe(WidgetTester tester, String result) async { // TODO: Implement Then the result should be } + } + diff --git a/example/test/calculator/calculator.bdd_test.dart b/example/test/calculator/calculator.bdd_test.dart index b23e37b..c4a9b2c 100644 --- a/example/test/calculator/calculator.bdd_test.dart +++ b/example/test/calculator/calculator.bdd_test.dart @@ -15,11 +15,11 @@ void main() { testWidgets('Subtract two numbers', (tester) async { //Scenario: Subtract two numbers // Given I have the number 5 - await SubtractTwoNumbersScenario.iHaveTheNumber5(tester); + await Subtract.iHaveTheNumber5(tester); // When I subtract them - await SubtractTwoNumbersScenario.iSubtractThem(tester); + await Subtract.iSubtractThem(tester); // Then the result should be 2 - await SubtractTwoNumbersScenario.theResultShouldBe2(tester); + await Subtract.theResultShouldBe2(tester); }); testWidgets('Multiply two numbers', (tester) async { //Scenario: Multiply two numbers @@ -33,17 +33,17 @@ void main() { testWidgets('Divide two numbers', (tester) async { //Scenario: Divide two numbers final examples = [ - {'number1': '10', 'number2': '2', 'result': '5'}, - {'number1': '10', 'number2': '1', 'result': '10'}, - {'number1': '10', 'number2': '10', 'result': '1'}, + {'number1': '10','number2': '2','result': '5',}, + {'number1': '10','number2': '1','result': '10',}, + {'number1': '10','number2': '10','result': '1',}, ]; for (var example in examples) { - // Given I have the number - await DivideTwoNumbersScenario.iHaveTheNumber(tester, example['number1']!); - // When I divide them - await DivideTwoNumbersScenario.iDivideThem(tester); - // Then the result should be - await DivideTwoNumbersScenario.theResultShouldBe(tester, example['result']!); + // Given I have the number + await DivideTwoNumbersScenario.iHaveTheNumber(tester, example['number1']!); + // When I divide them + await DivideTwoNumbersScenario.iDivideThem(tester); + // Then the result should be + await DivideTwoNumbersScenario.theResultShouldBe(tester, example['result']!); } }); }); diff --git a/lib/bdd_flutter.dart b/lib/bdd_flutter.dart index 0eaff16..26b0f11 100644 --- a/lib/bdd_flutter.dart +++ b/lib/bdd_flutter.dart @@ -1,5 +1,6 @@ +library bdd_flutter; + export 'src/feature/report/test_reporter.dart' show BDDTestReporter; -export 'src/builder.dart' show bddTestBuilder; /// A Flutter package for Behavior-Driven Development (BDD) testing. /// @@ -12,7 +13,7 @@ export 'src/builder.dart' show bddTestBuilder; /// * Generate test files automatically /// * Support for both widget and non-widget tests /// * Customizable test generation options -/// * Ignore specific generated files using bdd_ignore.yaml +/// * Ignore specific generated files using .bdd_config.yaml /// /// ## Getting Started /// @@ -36,35 +37,27 @@ export 'src/builder.dart' show bddTestBuilder; /// Then I should see the home screen /// ``` /// -/// 3. Run the build_runner to generate test files: +/// 3. Run the generator to create test files: /// ```bash -/// flutter pub run build_runner build +/// dart run bdd_flutter build /// ``` /// /// ## Configuration /// -/// You can configure the builder in your `build.yaml`: +/// You can configure the generator in `.bdd_config.yaml`: /// ```yaml -/// targets: -/// $default: -/// builders: -/// bdd_flutter|bdd_test_builder: -/// options: -/// generate_widget_tests: true +/// generate_widget_tests: true +/// enable_reporter: false +/// ignore_features: +/// - test/features/login.feature +/// - test/features/registration.feature /// ``` /// -/// ## Ignoring Generated Files -/// -/// If you need to modify a generated test file, you can prevent it from being -/// overwritten by adding it to `bdd_ignore.yaml`: -/// ```yaml -/// ignore_files: -/// - test/features/login_test.dart -/// - test/features/registration_test.dart +/// Or use command-line arguments: +/// ```bash +/// dart run bdd_flutter build --no-widget-tests --enable-reporter --ignore login.feature /// ``` /// -/// Files listed in `bdd_ignore.yaml` will be skipped during build_runner execution. -/// /// ## Additional Information /// /// For more information, visit the [documentation](https://example.com/bdd_flutter). diff --git a/lib/src/builder.dart b/lib/src/builder.dart deleted file mode 100644 index f0d7fe1..0000000 --- a/lib/src/builder.dart +++ /dev/null @@ -1,52 +0,0 @@ -// lib/builder.dart -import 'dart:async'; - -import 'package:bdd_flutter/src/feature/builder/domain/bdd_options.dart'; -import 'package:build/build.dart'; - -import 'feature/builder/bdd_builders/bdd_factory.dart'; - -/// A builder that generates test files from feature files -class BDDTestBuilder implements Builder { - final BDDOptions options; - - BDDTestBuilder({required this.options}); - - @override - final buildExtensions = const { - r'.feature': ['.bdd_scenarios.g.dart', '.bdd_test.g.dart'], - }; - - @override - Future build(BuildStep buildStep) async { - // Check if the feature file should be ignored - if (options.ignoreFeatures.contains(buildStep.inputId.path)) { - return; - } - - final factory = BDDFactory.create(options); - final feature = await factory.featureBuilder.build(buildStep); - - // Skip generation if feature is empty (due to @ignore) - if (feature.name.isEmpty) { - return; - } - - await factory.scenarioBuilder.build(buildStep, feature); - await factory.testFileBuilder.build(buildStep, feature); - } -} - -Builder bddTestBuilder(BuilderOptions options) { - final config = options.config; - final generateWidgetTests = config['generate_widget_tests'] as bool? ?? true; - final enableReporter = config['enable_reporter'] as bool? ?? false; - final ignoreFeatures = (config['ignore_features'] as List?)?.cast() ?? []; - - final bddOptions = BDDOptions( - generateWidgetTests: generateWidgetTests, - enableReporter: enableReporter, - ignoreFeatures: ignoreFeatures, - ); - return BDDTestBuilder(options: bddOptions); -} diff --git a/lib/src/constraints/file_extenstion.dart b/lib/src/constraints/file_extenstion.dart new file mode 100644 index 0000000..1506b10 --- /dev/null +++ b/lib/src/constraints/file_extenstion.dart @@ -0,0 +1,7 @@ +class FileExtension { + static const String feature = '.feature'; + static const String generatedTest = '.bdd.test.dart'; + static const String generatedScenarios = '.bdd.scenarios.dart'; + static const String renamedTest = '.bdd_test.dart'; + static const String renamedScenarios = '.bdd_scenarios.dart'; +} diff --git a/lib/src/feature/builder/bdd_builders/bdd_feature_builder.dart b/lib/src/feature/builder/bdd_builders/bdd_feature_builder.dart index d9387a3..2cd68b2 100644 --- a/lib/src/feature/builder/bdd_builders/bdd_feature_builder.dart +++ b/lib/src/feature/builder/bdd_builders/bdd_feature_builder.dart @@ -1,5 +1,3 @@ -import 'package:build/build.dart'; - import '../domain/background.dart'; import '../domain/bdd_options.dart'; import '../domain/decorator.dart'; @@ -13,13 +11,6 @@ class BDDFeatureBuilder { BDDFeatureBuilder({required this.options}); - /// Parse a feature file and return a Feature object - Future build(BuildStep buildStep) async { - final inputId = buildStep.inputId; - final featureContent = await buildStep.readAsString(inputId); - return parseFeature(featureContent); - } - Feature parseFeature(String featureContent) { final lines = featureContent.split('\n').map((line) => line.trim()).toList(); String? featureName; diff --git a/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart b/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart index e7145d5..e1a5840 100644 --- a/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart +++ b/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart @@ -1,22 +1,13 @@ -import 'package:bdd_flutter/src/feature/builder/domain/decorator.dart'; -import 'package:build/build.dart'; - +import '../../../constraints/file_extenstion.dart'; +import '../../../extensions/string_x.dart'; +import '../domain/decorator.dart'; import '../domain/feature.dart'; import '../domain/scenario.dart'; import '../domain/step.dart'; -import '../../../extensions/string_x.dart'; final spaceStep = ' '; class BDDTestFileBuilder { - Future build(BuildStep buildStep, Feature feature) async { - final inputId = buildStep.inputId; - final testOutputId = inputId.changeExtension('.bdd_test.g.dart'); - - final testContent = await buildTestFile(feature); - await buildStep.writeAsString(testOutputId, testContent); - } - Future buildTestFile(Feature feature) async { final buffer = StringBuffer(); buffer.writeln("import 'package:flutter_test/flutter_test.dart';"); @@ -25,7 +16,7 @@ class BDDTestFileBuilder { buffer.writeln("import 'package:bdd_flutter/bdd_flutter.dart';"); } - buffer.writeln("import '${feature.name.toSnakeCase}.bdd_scenarios.g.dart';"); + buffer.writeln("import '${feature.name.toSnakeCase}${FileExtension.generatedScenarios}';"); buffer.writeln(); buffer.writeln("void main() {"); diff --git a/lib/src/feature/builder/bdd_builders/scenario_file_builder.dart b/lib/src/feature/builder/bdd_builders/scenario_file_builder.dart index 36d67de..b7fa9ce 100644 --- a/lib/src/feature/builder/bdd_builders/scenario_file_builder.dart +++ b/lib/src/feature/builder/bdd_builders/scenario_file_builder.dart @@ -1,18 +1,8 @@ -import 'package:build/build.dart'; - import '../domain/feature.dart'; import '../domain/scenario.dart'; import '../../../extensions/string_x.dart'; class ScenariosFileBuilder { - Future build(BuildStep buildStep, Feature feature) async { - final inputId = buildStep.inputId; - final scenarioOutputId = inputId.changeExtension('.bdd_scenarios.g.dart'); - - final scenarioContent = await buildScenarioFile(feature); - await buildStep.writeAsString(scenarioOutputId, scenarioContent); - } - Future buildScenarioFile(Feature feature) async { final buffer = StringBuffer(); buffer.writeln("import 'package:flutter_test/flutter_test.dart';"); diff --git a/lib/src/generator/build_command.dart b/lib/src/generator/build_command.dart new file mode 100644 index 0000000..bfeb3b1 --- /dev/null +++ b/lib/src/generator/build_command.dart @@ -0,0 +1,87 @@ +import 'dart:io'; +import 'package:bdd_flutter/src/feature/builder/bdd_builders/bdd_factory.dart'; +import 'package:bdd_flutter/src/feature/builder/domain/bdd_options.dart'; +import 'package:yaml/yaml.dart'; + +import '../constraints/file_extenstion.dart'; + +/// Executes the build command to generate test files from feature files +Future generate(List arguments) async { + final options = parseConfig(arguments); + final factory = BDDFactory.create(options); + + // loop through all the .feature files in the test/features directory + final features = Directory('test/').listSync(recursive: true).where((file) => file.path.endsWith('.feature')).toList(); + + final ignoredFiles = options.ignoreFeatures; + + for (final feature in features) { + final featureFile = File(feature.path); + final featureContent = featureFile.readAsStringSync(); + final parsedFeature = factory.featureBuilder.parseFeature(featureContent); + if (ignoredFiles.contains(feature.path)) { + print('Skipping ${feature.path} because it is ignored'); + continue; + } else { + print('Processing ${feature.path}'); + } + + //build scenarios + final scenarios = await factory.scenarioBuilder.buildScenarioFile(parsedFeature); + final scenariosFile = File('${feature.path.replaceAll('.feature', '')}${FileExtension.generatedScenarios}'); + scenariosFile.writeAsStringSync(scenarios); + + //build test file + final testCases = await factory.testFileBuilder.buildTestFile(parsedFeature); + final testFile = File('${feature.path.replaceAll('.feature', '')}${FileExtension.generatedTest}'); + testFile.writeAsStringSync(testCases); + } +} + +/// Parse configuration from .bdd_config.yaml and command line arguments +BDDOptions parseConfig(List arguments) { + // Start with default values + bool generateWidgetTests = true; + bool enableReporter = false; + List ignoreFeatures = []; + + // Try to load config file + final configFile = File('.bdd_config.yaml'); + if (configFile.existsSync()) { + try { + final yaml = loadYaml(configFile.readAsStringSync()); + if (yaml != null) { + generateWidgetTests = yaml['generate_widget_tests'] as bool? ?? generateWidgetTests; + enableReporter = yaml['enable_reporter'] as bool? ?? enableReporter; + ignoreFeatures = (yaml['ignore_features'] as YamlList?)?.cast() ?? ignoreFeatures; + } + } catch (e) { + print('Warning: Failed to parse .bdd_config.yaml: $e'); + } + } + + // Override with command line arguments + for (var i = 0; i < arguments.length; i++) { + final arg = arguments[i]; + switch (arg) { + case '--no-widget-tests': + generateWidgetTests = false; + break; + case '--enable-reporter': + enableReporter = true; + break; + case '--ignore': + if (i + 1 < arguments.length) { + ignoreFeatures.add(arguments[i + 1]); + i++; // Skip the next argument as it's the feature to ignore + } + break; + } + } + + return BDDOptions( + generateWidgetTests: generateWidgetTests, + enableReporter: enableReporter, + ignoreFeatures: ignoreFeatures, + ); +} diff --git a/bin/src/rename.dart b/lib/src/runner/rename.dart similarity index 74% rename from bin/src/rename.dart rename to lib/src/runner/rename.dart index a6408bb..a254b69 100644 --- a/bin/src/rename.dart +++ b/lib/src/runner/rename.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import '../constraints/file_extenstion.dart'; + /// this will remove `.g` from the file name /// /// suggested to run after writing test, this will prevent the file from being overwritten @@ -20,7 +22,11 @@ void rename(List arguments) { return; } - final files = testDir.listSync(recursive: true).where((file) => file.path.endsWith('.g.dart')).map((file) => File(file.path)).where((file) { + final files = testDir + .listSync(recursive: true) + .where((file) => file.path.endsWith(FileExtension.generatedTest) || file.path.endsWith(FileExtension.generatedScenarios)) + .map((file) => File(file.path)) + .where((file) { if (arguments.isEmpty) return true; final featureName = file.path.split('/').last.split('.').first; return arguments.any((arg) => featureName.contains(arg)); @@ -32,12 +38,12 @@ void rename(List arguments) { } for (final file in files) { - final newName = file.path.replaceAll('.g.dart', '.dart'); + final newName = file.path.replaceAll('.bdd.', '.bdd_'); print('Renaming ${file.path} to $newName'); // Update import statements in the file final content = file.readAsStringSync(); - final updatedContent = content.replaceAll('.g.dart', '.dart'); + final updatedContent = content.replaceAll('.bdd.', '.bdd_'); file.writeAsStringSync(updatedContent); // Rename the file diff --git a/pubspec.yaml b/pubspec.yaml index 1a2976c..20e2ff7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,11 +1,11 @@ name: bdd_flutter -description: "A powerful Flutter package that simplifies Behavior Driven Development (BDD) by automatically generating test files from Gherkin feature files" -version: 0.2.0 +description: A Flutter package for Behavior-Driven Development (BDD) testing. +version: 0.0.1 homepage: https://github.com/samderlust/bdd_flutter environment: - sdk: ^3.1.0 - flutter: ">=3.1.0" + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.0.0" topics: - bdd @@ -14,16 +14,10 @@ topics: - gherkin dependencies: - flutter: - sdk: flutter - yaml: ^3.0.0 - build: ^2.3.0 - args: ^2.4.2 - path: ^1.8.3 + yaml: ^3.1.0 dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^5.0.0 - build_runner: ^2.3.0 - test: ^1.20.0 + lints: ^2.0.0 + test: ^1.24.0 From f0e82e8b788e5fa88ba6ae182c8e81c3c2a00341 Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Sun, 11 May 2025 22:00:25 -0600 Subject: [PATCH 05/24] regenerate missing file --- example/.bdd_flutter/manifest.yaml | 65 +++++++++++++++++++ example/build.yaml | 9 --- example/test/sample/sample.bdd_scenarios.dart | 15 +++++ example/test/sample/sample.bdd_test.dart | 9 +++ example/test/sample/sample.feature | 5 ++ 5 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 example/.bdd_flutter/manifest.yaml delete mode 100644 example/build.yaml diff --git a/example/.bdd_flutter/manifest.yaml b/example/.bdd_flutter/manifest.yaml new file mode 100644 index 0000000..46902a7 --- /dev/null +++ b/example/.bdd_flutter/manifest.yaml @@ -0,0 +1,65 @@ +version: "1.0" +last_generated: "2025-05-11T22:00:04.872951" +features: + - path: "test/calculator/calculator.feature" + last_modified: "2025-05-11T21:39:21.000" + test_file: "test/calculator/calculator.bdd_test.dart" + scenarios: + - name: "Add two numbers" + hash: "5a4c3bc6392ebc55660998e6f458beeb" + line_start: 1 + line_end: 1 + test_method: "testAddtwonumbers" + - name: "Subtract two numbers" + hash: "4f81e69f94bf143662cdbfda5792b5b2" + line_start: 2 + line_end: 2 + test_method: "testSubtracttwonumbers" + - name: "Multiply two numbers" + hash: "49fa6c2c733e007e6778ecf0b8667f06" + line_start: 3 + line_end: 3 + test_method: "testMultiplytwonumbers" + - name: "Divide two numbers" + hash: "056b8dd29e61014e9c2ac7891139811a" + line_start: 4 + line_end: 4 + test_method: "testDividetwonumbers" + - path: "test/sample/sample.feature" + last_modified: "2025-05-11T21:52:29.000" + test_file: "test/sample/sample.bdd_test.dart" + scenarios: + - name: "Sample" + hash: "fa45830159f6abbf09247347da169137" + line_start: 1 + line_end: 1 + test_method: "testSample" + - name: "Counter" + hash: "d90c903ae242400fad736462bcadb23e" + line_start: 2 + line_end: 2 + test_method: "testCounter" + - name: "Counter with examples" + hash: "1a64a7fa506fc48ca40b619a629baea2" + line_start: 3 + line_end: 3 + test_method: "testCounterwithexamples" + - name: "Counter with parameters" + hash: "ecfa8df8c75b022f1d8802511b2ab8ff" + line_start: 4 + line_end: 4 + test_method: "testCounterwithparameters" + - name: "Counter with widget test" + hash: "b04f089730bac2859f0c344456448471" + line_start: 5 + line_end: 5 + test_method: "testCounterwithwidgettest" + - path: "test/counter/counter.feature" + last_modified: "2025-05-04T10:27:15.000" + test_file: "test/counter/counter.bdd_test.dart" + scenarios: + - name: "Increment" + hash: "f71a054d6eca6e81f1147356ab2a7ea8" + line_start: 1 + line_end: 1 + test_method: "testIncrement" diff --git a/example/build.yaml b/example/build.yaml deleted file mode 100644 index 5c39d90..0000000 --- a/example/build.yaml +++ /dev/null @@ -1,9 +0,0 @@ -targets: - $default: - builders: - bdd_flutter|bdd_test_builder: - options: - # generate_widget_tests: - # enable_reporter: true - ignore_features: - - "test/calculator/calculator.feature" diff --git a/example/test/sample/sample.bdd_scenarios.dart b/example/test/sample/sample.bdd_scenarios.dart index 2ea77d9..06bd806 100644 --- a/example/test/sample/sample.bdd_scenarios.dart +++ b/example/test/sample/sample.bdd_scenarios.dart @@ -60,3 +60,18 @@ class CounterWithParametersScenario { } +class CounterWithWidgetTestScenario { + static Future iHaveACounter(WidgetTester tester) async { + // TODO: Implement Given I have a counter + } + + static Future iIncrementTheCounter(WidgetTester tester) async { + // TODO: Implement When I increment the counter + } + + static Future iShouldSeeTheCounterIncremented(WidgetTester tester) async { + // TODO: Implement Then I should see the counter incremented + } + +} + diff --git a/example/test/sample/sample.bdd_test.dart b/example/test/sample/sample.bdd_test.dart index 2e3876f..f209d71 100644 --- a/example/test/sample/sample.bdd_test.dart +++ b/example/test/sample/sample.bdd_test.dart @@ -53,5 +53,14 @@ void main() { await CounterWithParametersScenario.iShouldSeeTheResult( example['result']!); } }); + testWidgets('Counter with widget test', (tester) async { + //Scenario: Counter with widget test + // Given I have a counter + await CounterWithWidgetTestScenario.iHaveACounter(tester); + // When I increment the counter + await CounterWithWidgetTestScenario.iIncrementTheCounter(tester); + // Then I should see the counter incremented + await CounterWithWidgetTestScenario.iShouldSeeTheCounterIncremented(tester); + }); }); } diff --git a/example/test/sample/sample.feature b/example/test/sample/sample.feature index 621458a..687727b 100644 --- a/example/test/sample/sample.feature +++ b/example/test/sample/sample.feature @@ -31,3 +31,8 @@ Feature: Sample | 1 | 2 | | 2 | 3 | | 3 | 4 | + + Scenario: Counter with widget test + Given I have a counter + When I increment the counter + Then I should see the counter incremented From 9b6f080ebd5243a819cd22704d6647ebd2153dc7 Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Mon, 12 May 2025 21:58:33 -0600 Subject: [PATCH 06/24] copywith --- .cursor/rules/flutter-rules.mdc | 134 ++++++++ CHANGELOG.md | 1 + README.md | 92 +++++- bin/bdd_flutter.dart | 38 ++- lib/bdd_flutter.dart | 33 +- lib/src/constraints/file_extenstion.dart | 8 +- .../feature/builder/domain/bdd_options.dart | 81 ++++- lib/src/feature/builder/domain/manifest.dart | 186 +++++++++++ lib/src/feature/logger/logger.dart | 52 +++ lib/src/generator/build_command.dart | 87 ----- lib/src/runner/build_command.dart | 310 ++++++++++++++++++ lib/src/runner/help_command.dart | 15 + pubspec.yaml | 2 - 13 files changed, 932 insertions(+), 107 deletions(-) create mode 100644 .cursor/rules/flutter-rules.mdc create mode 100644 lib/src/feature/builder/domain/manifest.dart create mode 100644 lib/src/feature/logger/logger.dart delete mode 100644 lib/src/generator/build_command.dart create mode 100644 lib/src/runner/build_command.dart create mode 100644 lib/src/runner/help_command.dart diff --git a/.cursor/rules/flutter-rules.mdc b/.cursor/rules/flutter-rules.mdc new file mode 100644 index 0000000..181a4a2 --- /dev/null +++ b/.cursor/rules/flutter-rules.mdc @@ -0,0 +1,134 @@ +--- +description: +globs: +alwaysApply: false +--- + +You are a senior Dart programmer with experience in the Flutter framework and a preference for clean programming and design patterns. + +Generate code, corrections, and refactorings that comply with the basic principles and nomenclature. + +## Dart General Guidelines + +### Basic Principles + +- Use English for all code and documentation. +- Always declare the type of each variable and function (parameters and return value). + - Avoid using any. + - Create necessary types. +- Don't leave blank lines within a function. +- One export per file. + +### Nomenclature + +- Use PascalCase for classes. +- Use camelCase for variables, functions, and methods. +- Use underscores_case for file and directory names. +- Use UPPERCASE for environment variables. + - Avoid magic numbers and define constants. +- Start each function with a verb. +- Use verbs for boolean variables. Example: isLoading, hasError, canDelete, etc. +- Use complete words instead of abbreviations and correct spelling. + - Except for standard abbreviations like API, URL, etc. + - Except for well-known abbreviations: + - i, j for loops + - err for errors + - ctx for contexts + - req, res, next for middleware function parameters + +### Functions + +- In this context, what is understood as a function will also apply to a method. +- Write short functions with a single purpose. Less than 20 instructions. +- Name functions with a verb and something else. + - If it returns a boolean, use isX or hasX, canX, etc. + - If it doesn't return anything, use executeX or saveX, etc. +- Avoid nesting blocks by: + - Early checks and returns. + - Extraction to utility functions. +- Use higher-order functions (map, filter, reduce, etc.) to avoid function nesting. + - Use arrow functions for simple functions (less than 3 instructions). + - Use named functions for non-simple functions. +- Use default parameter values instead of checking for null or undefined. +- Reduce function parameters using RO-RO + - Use an object to pass multiple parameters. + - Use an object to return results. + - Declare necessary types for input arguments and output. +- Use a single level of abstraction. + +### Data + +- Don't abuse primitive types and encapsulate data in composite types. +- Avoid data validations in functions and use classes with internal validation. +- Prefer immutability for data. + - Use readonly for data that doesn't change. + - Use as const for literals that don't change. + +### Classes + +- Follow SOLID principles. +- Prefer composition over inheritance. +- Declare interfaces to define contracts. +- Write small classes with a single purpose. + - Less than 200 instructions. + - Less than 10 public methods. + - Less than 10 properties. + +### Exceptions + +- Use exceptions to handle errors you don't expect. +- If you catch an exception, it should be to: + - Fix an expected problem. + - Add context. + - Otherwise, use a global handler. + +### Testing + +- Follow the Arrange-Act-Assert convention for tests. +- Name test variables clearly. + - Follow the convention: inputX, mockX, actualX, expectedX, etc. +- Write unit tests for each public function. + - Use test doubles to simulate dependencies. + - Except for third-party dependencies that are not expensive to execute. +- Write acceptance tests for each module. + - Follow the Given-When-Then convention. + +## Specific to Flutter + +### Basic Principles + +- Use clean architecture + - see modules if you need to organize code into modules + - see controllers if you need to organize code into controllers + - see services if you need to organize code into services + - see repositories if you need to organize code into repositories + - see entities if you need to organize code into entities +- Use repository pattern for data persistence + - see cache if you need to cache data +- Use controller pattern for business logic with Riverpod +- Use Riverpod to manage state + - see keepAlive if you need to keep the state alive +- Use freezed to manage UI states +- Controller always takes methods as input and updates the UI state that effects the UI +- Use getIt to manage dependencies + - Use singleton for services and repositories + - Use factory for use cases + - Use lazy singleton for controllers +- Use AutoRoute to manage routes + - Use extras to pass data between pages +- Use extensions to manage reusable code +- Use ThemeData to manage themes +- Use AppLocalizations to manage translations +- Use constants to manage constants values +- When a widget tree becomes too deep, it can lead to longer build times and increased memory usage. Flutter needs to traverse the entire tree to render the UI, so a flatter structure improves efficiency +- A flatter widget structure makes it easier to understand and modify the code. Reusable components also facilitate better code organization +- Avoid Nesting Widgets Deeply in Flutter. Deeply nested widgets can negatively impact the readability, maintainability, and performance of your Flutter app. Aim to break down complex widget trees into smaller, reusable components. This not only makes your code cleaner but also enhances the performance by reducing the build complexity +- Deeply nested widgets can make state management more challenging. By keeping the tree shallow, it becomes easier to manage state and pass data between widgets +- Break down large widgets into smaller, focused widgets +- Utilize const constructors wherever possible to reduce rebuilds + +### Testing + +- Use the standard widget testing for flutter +- Use integration tests for each api module. + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8196b6f..8333da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - add custom CLI - `dart run bdd remane` to remove `.g` from generated files extension - `dart run bdd build` to generate files from feature files +- [] separete command option and config options ## 0.2.0 diff --git a/README.md b/README.md index ee515b4..a84d346 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,18 @@ A powerful Flutter package that simplifies Behavior Driven Development (BDD) by automatically generating test files from Gherkin feature files. Write expressive tests in plain English using Given/When/Then scenarios and let BDD Flutter handle the boilerplate code generation. +## ๐Ÿšจ Breaking Changes in v1.0.0 + +- The package no longer uses `build_runner`. Instead, it now uses a simpler CLI approach: + 1. Remove `build_runner` from your dev_dependencies if you added it previously + 2. Use `dart run bdd_flutter build` to generate test files + 3. Generated files will use the `.bdd.dart` extension for better clarity + +To migrate: + +1. Remove build_runner if present in your dev_dependencies +2. If you want to keep current test files, consider add `.feature` file paths into `bdd_config.yaml` under `ignore_features` section (see [Configuration](#-configuration) for more details) + ## โœจ Features - ๐Ÿ“ Parse `.feature` files written in Gherkin syntax @@ -14,6 +26,9 @@ A powerful Flutter package that simplifies Behavior Driven Development (BDD) by - ๐Ÿงช Support for both widget tests and unit tests - โš™๏ธ Configurable test generation - ๐Ÿ“„ Ignore specific generated files using `.bdd_config.yaml` +- ๐Ÿ“„ Incremental generation to preserve user-written code +- ๐Ÿ“„ Configuration in `.bdd_flutter/config.yaml` +- ๐Ÿ“„ Manifest tracking in `.bdd_flutter/manifest.yaml` ## ๐Ÿ“ฆ Installation @@ -21,7 +36,7 @@ Add the following dependencies to your package's `pubspec.yaml` file: ```yaml dev_dependencies: - bdd_flutter: any + bdd_flutter: ^1.0.0 ``` ## ๐Ÿš€ Quick Start @@ -59,9 +74,10 @@ When working with generated test files, follow these best practices: 1. **Generated Files**: - - Generated files will have the `.g.dart` extension (e.g., `counter_test.bdd_test.g.dart` or `counter_scenarios.g.dart`) + - Generated files will have the `.bdd.*.dart` extension (e.g., `counter_test.bdd.test.dart` or `counter_scenarios.bdd.scenarios.dart`) - After implementing your tests, it's recommended to: - - Remove the `.g` extension from the file name + - Remove the `.bdd.*` extension from the file name + - or you can run `dart run bdd_flutter rename` to rename the files - Add an ignore decorator to your feature file (@ignore) - This will prevent the generated files from being overwritten by subsequent builds @@ -80,7 +96,7 @@ This approach ensures that: ## ๐Ÿš€ Configuration -You can configure the generator in `.bdd_config.yaml`: +You can configure the generator in `bdd_config.yaml`: ```yaml generate_widget_tests: true @@ -196,3 +212,71 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file ## Additional Information For more information, visit the [documentation](https://example.com/bdd_flutter). + +## Project Structure + +``` +your_project/ +โ”œโ”€โ”€ .bdd_flutter/ +โ”‚ โ”œโ”€โ”€ config.yaml # Configuration settings +โ”‚ โ””โ”€โ”€ manifest.yaml # Generation state tracking +โ”œโ”€โ”€ test/ +โ”‚ โ”œโ”€โ”€ features/ +โ”‚ โ”‚ โ””โ”€โ”€ login.feature +โ”‚ โ””โ”€โ”€ features_test/ +โ”‚ โ”œโ”€โ”€ login_test.dart +โ”‚ โ””โ”€โ”€ login_scenarios.dart +โ””โ”€โ”€ pubspec.yaml +``` + +## Generation Modes + +The package supports three generation modes: + +### 1. Incremental Update (Default) + +```bash +dart run bdd_flutter build +``` + +- Processes new and modified scenarios +- Preserves user-written code +- Tracks changes in `.bdd_flutter/manifest.yaml` + +### 2. Force Regenerate + +```bash +dart run bdd_flutter build --force +``` + +- Regenerates all test files +- Overwrites existing files +- Use with caution + +### 3. New Files Only + +```bash +dart run bdd_flutter build --new-only +``` + +- Only processes new feature files +- Skips existing files +- Useful for initial setup + +## Best Practices + +1. **Version Control** + + - Add `.bdd_flutter/manifest.yaml` to `.gitignore` + - Keep `.bdd_flutter/config.yaml` in version control + +2. **Feature Files** + + - Keep feature files in `test/features/` + - Use descriptive names for scenarios + - Follow Gherkin syntax guidelines + +3. **Test Files** + - Don't modify generated test files directly + - Add your implementation in the provided methods + - Use the incremental update mode to preserve changes diff --git a/bin/bdd_flutter.dart b/bin/bdd_flutter.dart index 11bb6b5..594cde5 100644 --- a/bin/bdd_flutter.dart +++ b/bin/bdd_flutter.dart @@ -1,11 +1,41 @@ -import 'package:bdd_flutter/src/generator/build_command.dart'; +import 'package:bdd_flutter/src/runner/build_command.dart'; +import 'package:bdd_flutter/src/runner/help_command.dart'; import 'package:bdd_flutter/src/runner/rename.dart'; void main(List arguments) async { if (arguments.isEmpty) { await generate(arguments); - } else if (arguments.first == 'rename') { - print('rename'); - rename(arguments.skip(1).toList()); + } else { + final command = BDDCommand.fromName(arguments.first); + switchCase(command, arguments.skip(1).toList()); + } +} + +void switchCase(BDDCommand command, List arguments) { + switch (command) { + case BDDCommand.rename: + rename(arguments); + break; + default: + help(); + break; + } +} + +enum BDDCommand { + build('build', 'Build the test files'), + clean('clean', 'Clean the test files'), + rename('rename', 'Rename the test files'), + help('help', 'Show the help'), + version('version', 'Show the version'), + unknown('unknown', 'Unknown command'); + + final String name; + final String description; + + const BDDCommand(this.name, this.description); + + static BDDCommand fromName(String name) { + return values.firstWhere((command) => command.name == name, orElse: () => unknown); } } diff --git a/lib/bdd_flutter.dart b/lib/bdd_flutter.dart index 26b0f11..fd40ce4 100644 --- a/lib/bdd_flutter.dart +++ b/lib/bdd_flutter.dart @@ -13,7 +13,8 @@ export 'src/feature/report/test_reporter.dart' show BDDTestReporter; /// * Generate test files automatically /// * Support for both widget and non-widget tests /// * Customizable test generation options -/// * Ignore specific generated files using .bdd_config.yaml +/// * Incremental generation to preserve user-written code +/// * Configuration in .bdd_flutter/config.yaml /// /// ## Getting Started /// @@ -39,12 +40,19 @@ export 'src/feature/report/test_reporter.dart' show BDDTestReporter; /// /// 3. Run the generator to create test files: /// ```bash +/// # Default: Incremental update (process new and modified scenarios) /// dart run bdd_flutter build +/// +/// # Force regenerate all files +/// dart run bdd_flutter build --force +/// +/// # Only process new feature files +/// dart run bdd_flutter build --new-only /// ``` /// /// ## Configuration /// -/// You can configure the generator in `.bdd_config.yaml`: +/// Configuration is stored in `.bdd_flutter/config.yaml`: /// ```yaml /// generate_widget_tests: true /// enable_reporter: false @@ -53,11 +61,30 @@ export 'src/feature/report/test_reporter.dart' show BDDTestReporter; /// - test/features/registration.feature /// ``` /// -/// Or use command-line arguments: +/// Command-line arguments override config file settings: /// ```bash /// dart run bdd_flutter build --no-widget-tests --enable-reporter --ignore login.feature /// ``` /// +/// ## Generation Modes +/// +/// The package supports three generation modes: +/// +/// 1. **Incremental Update** (default): +/// - Processes new and modified scenarios +/// - Preserves user-written code +/// - Tracks changes in .bdd_flutter/manifest.yaml +/// +/// 2. **Force Regenerate** (--force): +/// - Regenerates all test files +/// - Overwrites existing files +/// - Use with caution +/// +/// 3. **New Files Only** (--new-only): +/// - Only processes new feature files +/// - Skips existing files +/// - Useful for initial setup +/// /// ## Additional Information /// /// For more information, visit the [documentation](https://example.com/bdd_flutter). diff --git a/lib/src/constraints/file_extenstion.dart b/lib/src/constraints/file_extenstion.dart index 1506b10..bc7bfa4 100644 --- a/lib/src/constraints/file_extenstion.dart +++ b/lib/src/constraints/file_extenstion.dart @@ -1,7 +1,7 @@ class FileExtension { static const String feature = '.feature'; - static const String generatedTest = '.bdd.test.dart'; - static const String generatedScenarios = '.bdd.scenarios.dart'; - static const String renamedTest = '.bdd_test.dart'; - static const String renamedScenarios = '.bdd_scenarios.dart'; + static const String generatedTest = '.bdd_test.dart'; + static const String generatedScenarios = '.bdd_scenarios.dart'; + // static const String renamedTest = '.bdd_test.dart'; + // static const String renamedScenarios = '.bdd_scenarios.dart'; } diff --git a/lib/src/feature/builder/domain/bdd_options.dart b/lib/src/feature/builder/domain/bdd_options.dart index 651da36..0c57fc7 100644 --- a/lib/src/feature/builder/domain/bdd_options.dart +++ b/lib/src/feature/builder/domain/bdd_options.dart @@ -1,11 +1,86 @@ +import 'dart:io'; +import 'package:yaml/yaml.dart'; + class BDDOptions { final bool generateWidgetTests; final bool enableReporter; final List ignoreFeatures; + final bool force; + final bool newOnly; BDDOptions({ - this.generateWidgetTests = true, - this.enableReporter = false, - this.ignoreFeatures = const [], + required this.generateWidgetTests, + required this.enableReporter, + required this.ignoreFeatures, + this.force = false, + this.newOnly = false, }); + + BDDOptions copyWith({ + bool? generateWidgetTests, + bool? enableReporter, + List? ignoreFeatures, + bool? force, + bool? newOnly, + }) { + return BDDOptions( + generateWidgetTests: generateWidgetTests ?? this.generateWidgetTests, + enableReporter: enableReporter ?? this.enableReporter, + ignoreFeatures: ignoreFeatures ?? this.ignoreFeatures, + force: force ?? this.force, + newOnly: newOnly ?? this.newOnly, + ); + } + + static const String bddDir = '.bdd_flutter'; + static const String configPath = '$bddDir/config.yaml'; + + static Future ensureBDDDirectory() async { + final dir = Directory(bddDir); + if (!await dir.exists()) { + await dir.create(); + } + } + + static Future fromConfig() async { + await ensureBDDDirectory(); + final configFile = File(configPath); + + // Start with default values + bool generateWidgetTests = true; + bool enableReporter = false; + List ignoreFeatures = []; + + if (await configFile.exists()) { + try { + final yaml = loadYaml(await configFile.readAsString()); + if (yaml != null) { + generateWidgetTests = yaml['generate_widget_tests'] as bool? ?? generateWidgetTests; + enableReporter = yaml['enable_reporter'] as bool? ?? enableReporter; + ignoreFeatures = (yaml['ignore_features'] as YamlList?)?.cast() ?? ignoreFeatures; + } + } catch (e) { + print('Warning: Failed to parse config file: $e'); + } + } + + return BDDOptions( + generateWidgetTests: generateWidgetTests, + enableReporter: enableReporter, + ignoreFeatures: ignoreFeatures, + ); + } + + static Future writeConfig(BDDOptions options) async { + await ensureBDDDirectory(); + final configFile = File(configPath); + + final config = { + 'generate_widget_tests': options.generateWidgetTests, + 'enable_reporter': options.enableReporter, + 'ignore_features': options.ignoreFeatures, + }; + + await configFile.writeAsString(config.toString()); + } } diff --git a/lib/src/feature/builder/domain/manifest.dart b/lib/src/feature/builder/domain/manifest.dart new file mode 100644 index 0000000..977402b --- /dev/null +++ b/lib/src/feature/builder/domain/manifest.dart @@ -0,0 +1,186 @@ +import 'dart:io'; + +import 'package:yaml/yaml.dart'; + +class Manifest { + final String version; + DateTime lastGenerated; + final List features; + + Manifest({ + required this.version, + required this.lastGenerated, + required this.features, + }); + + factory Manifest.initial() { + return Manifest( + version: '1.0', + lastGenerated: DateTime.now(), + features: [], + ); + } + + String toYaml() { + final buffer = StringBuffer(); + buffer.writeln('version: "$version"'); + buffer.writeln('last_generated: "${lastGenerated.toIso8601String()}"'); + buffer.writeln('features:'); + for (final feature in features) { + buffer.writeln(' - path: "${feature.path}"'); + buffer.writeln(' last_modified: "${feature.lastModified.toIso8601String()}"'); + buffer.writeln(' test_file: "${feature.testFile}"'); + buffer.writeln(' scenarios:'); + for (final scenario in feature.scenarios) { + buffer.writeln(' - name: "${scenario.name}"'); + buffer.writeln(' hash: "${scenario.hash}"'); + buffer.writeln(' line_start: ${scenario.lineStart}'); + buffer.writeln(' line_end: ${scenario.lineEnd}'); + buffer.writeln(' test_method: "${scenario.testMethod}"'); + } + } + return buffer.toString(); + } + + factory Manifest.fromYaml(Map yaml) { + return Manifest( + version: yaml['version'] as String, + lastGenerated: DateTime.parse(yaml['last_generated'] as String), + features: (yaml['features'] as List).map((f) => FeatureEntry.fromYaml(f as Map)).toList(), + ); + } +} + +class FeatureEntry { + final String path; + final DateTime lastModified; + final String testFile; + final List scenarios; + + FeatureEntry({ + required this.path, + required this.lastModified, + required this.testFile, + required this.scenarios, + }); + + Map toYaml() { + return { + 'path': path, + 'last_modified': lastModified.toIso8601String(), + 'test_file': testFile, + 'scenarios': scenarios.map((s) => s.toYaml()).toList(), + }; + } + + factory FeatureEntry.fromYaml(Map yaml) { + return FeatureEntry( + path: yaml['path'] as String, + lastModified: DateTime.parse(yaml['last_modified'] as String), + testFile: yaml['test_file'] as String, + scenarios: (yaml['scenarios'] as List).map((s) => ScenarioEntry.fromYaml(s as Map)).toList(), + ); + } +} + +class ScenarioEntry { + final String name; + final String hash; + final int lineStart; + final int lineEnd; + final String testMethod; + + ScenarioEntry({ + required this.name, + required this.hash, + required this.lineStart, + required this.lineEnd, + required this.testMethod, + }); + + Map toYaml() { + return { + 'name': name, + 'hash': hash, + 'line_start': lineStart, + 'line_end': lineEnd, + 'test_method': testMethod, + }; + } + + factory ScenarioEntry.fromYaml(Map yaml) { + return ScenarioEntry( + name: yaml['name'] as String, + hash: yaml['hash'] as String, + lineStart: yaml['line_start'] as int, + lineEnd: yaml['line_end'] as int, + testMethod: yaml['test_method'] as String, + ); + } +} + +class ManifestManager { + static const String bddDir = '.bdd_flutter'; + static const String manifestPath = '$bddDir/manifest.yaml'; + + Future ensureBDDDirectory() async { + final dir = Directory(bddDir); + if (!await dir.exists()) { + await dir.create(); + } + } + + Future readManifest() async { + await ensureBDDDirectory(); + final file = File(manifestPath); + + if (!await file.exists()) { + return Manifest.initial(); + } + + try { + final content = await file.readAsString(); + final yaml = loadYaml(content); + // Convert YamlMap to Map and handle null values + final Map yamlMap = Map.from(yaml as Map); + + // Ensure all required fields exist with default values + final features = (yamlMap['features'] as List?) ?? []; + final version = yamlMap['version'] as String? ?? '1.0'; + final lastGenerated = yamlMap['last_generated'] as String? ?? DateTime.now().toIso8601String(); + + return Manifest( + version: version, + lastGenerated: DateTime.parse(lastGenerated), + features: features.map((f) { + final featureMap = Map.from(f as Map); + return FeatureEntry( + path: featureMap['path'] as String? ?? '', + lastModified: DateTime.parse(featureMap['last_modified'] as String? ?? DateTime.now().toIso8601String()), + testFile: featureMap['test_file'] as String? ?? '', + scenarios: (featureMap['scenarios'] as List?)?.map((s) { + final scenarioMap = Map.from(s as Map); + return ScenarioEntry( + name: scenarioMap['name'] as String? ?? '', + hash: scenarioMap['hash'] as String? ?? '', + lineStart: scenarioMap['line_start'] as int? ?? 0, + lineEnd: scenarioMap['line_end'] as int? ?? 0, + testMethod: scenarioMap['test_method'] as String? ?? '', + ); + }).toList() ?? + [], + ); + }).toList(), + ); + } catch (e) { + print('Warning: Failed to parse manifest file: $e'); + return Manifest.initial(); + } + } + + Future writeManifest(Manifest manifest) async { + await ensureBDDDirectory(); + final file = File(manifestPath); + await file.writeAsString(manifest.toYaml()); + } +} diff --git a/lib/src/feature/logger/logger.dart b/lib/src/feature/logger/logger.dart new file mode 100644 index 0000000..9a9dde7 --- /dev/null +++ b/lib/src/feature/logger/logger.dart @@ -0,0 +1,52 @@ +/// Log levels for different types of messages +enum LogLevel { + info, + warning, + error, +} + +/// A class to handle logging to the terminal +class CLILogger { + static final CLILogger _instance = CLILogger._internal(); + factory CLILogger() => _instance; + CLILogger._internal(); + + /// Log a message with the specified level + void log(String message, {LogLevel level = LogLevel.info}) { + final prefix = _getPrefix(level); + print('$prefix $message'); + } + + /// Log a message about skipping a file + void logSkipping(String path, {String? reason}) { + final message = reason != null ? 'Skipping $path ($reason)' : 'Skipping $path'; + log(message); + } + + /// Log a message about processing a file + void logProcessing(String path, {String? reason}) { + final message = reason != null ? 'Processing $path ($reason)' : 'Processing $path'; + log(message); + } + + /// Log a warning message + void warning(String message) { + log(message, level: LogLevel.warning); + } + + /// Log an error message + void error(String message) { + log(message, level: LogLevel.error); + } + + String _getPrefix(LogLevel level) { + switch (level) { + case LogLevel.info: + return 'โ„น๏ธ'; + case LogLevel.warning: + return 'โš ๏ธ'; + case LogLevel.error: + return 'โŒ'; + } + } +} diff --git a/lib/src/generator/build_command.dart b/lib/src/generator/build_command.dart deleted file mode 100644 index bfeb3b1..0000000 --- a/lib/src/generator/build_command.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'dart:io'; -import 'package:bdd_flutter/src/feature/builder/bdd_builders/bdd_factory.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/bdd_options.dart'; -import 'package:yaml/yaml.dart'; - -import '../constraints/file_extenstion.dart'; - -/// Executes the build command to generate test files from feature files -Future generate(List arguments) async { - final options = parseConfig(arguments); - final factory = BDDFactory.create(options); - - // loop through all the .feature files in the test/features directory - final features = Directory('test/').listSync(recursive: true).where((file) => file.path.endsWith('.feature')).toList(); - - final ignoredFiles = options.ignoreFeatures; - - for (final feature in features) { - final featureFile = File(feature.path); - final featureContent = featureFile.readAsStringSync(); - final parsedFeature = factory.featureBuilder.parseFeature(featureContent); - if (ignoredFiles.contains(feature.path)) { - print('Skipping ${feature.path} because it is ignored'); - continue; - } else { - print('Processing ${feature.path}'); - } - - //build scenarios - final scenarios = await factory.scenarioBuilder.buildScenarioFile(parsedFeature); - final scenariosFile = File('${feature.path.replaceAll('.feature', '')}${FileExtension.generatedScenarios}'); - scenariosFile.writeAsStringSync(scenarios); - - //build test file - final testCases = await factory.testFileBuilder.buildTestFile(parsedFeature); - final testFile = File('${feature.path.replaceAll('.feature', '')}${FileExtension.generatedTest}'); - testFile.writeAsStringSync(testCases); - } -} - -/// Parse configuration from .bdd_config.yaml and command line arguments -BDDOptions parseConfig(List arguments) { - // Start with default values - bool generateWidgetTests = true; - bool enableReporter = false; - List ignoreFeatures = []; - - // Try to load config file - final configFile = File('.bdd_config.yaml'); - if (configFile.existsSync()) { - try { - final yaml = loadYaml(configFile.readAsStringSync()); - if (yaml != null) { - generateWidgetTests = yaml['generate_widget_tests'] as bool? ?? generateWidgetTests; - enableReporter = yaml['enable_reporter'] as bool? ?? enableReporter; - ignoreFeatures = (yaml['ignore_features'] as YamlList?)?.cast() ?? ignoreFeatures; - } - } catch (e) { - print('Warning: Failed to parse .bdd_config.yaml: $e'); - } - } - - // Override with command line arguments - for (var i = 0; i < arguments.length; i++) { - final arg = arguments[i]; - switch (arg) { - case '--no-widget-tests': - generateWidgetTests = false; - break; - case '--enable-reporter': - enableReporter = true; - break; - case '--ignore': - if (i + 1 < arguments.length) { - ignoreFeatures.add(arguments[i + 1]); - i++; // Skip the next argument as it's the feature to ignore - } - break; - } - } - - return BDDOptions( - generateWidgetTests: generateWidgetTests, - enableReporter: enableReporter, - ignoreFeatures: ignoreFeatures, - ); -} diff --git a/lib/src/runner/build_command.dart b/lib/src/runner/build_command.dart new file mode 100644 index 0000000..787d53b --- /dev/null +++ b/lib/src/runner/build_command.dart @@ -0,0 +1,310 @@ +import 'dart:io'; +import 'package:bdd_flutter/src/feature/builder/bdd_builders/bdd_factory.dart'; +import 'package:bdd_flutter/src/feature/builder/domain/bdd_options.dart'; +import 'package:bdd_flutter/src/feature/builder/domain/manifest.dart'; +import 'package:crypto/crypto.dart'; +import 'dart:convert'; + +import '../constraints/file_extenstion.dart'; +import '../extensions/string_x.dart'; +import '../feature/logger/logger.dart'; + +/// Executes the build command to generate test files from feature files +Future generate(List arguments) async { + final logger = CLILogger(); + final options = await _parseConfig(arguments); + final factory = BDDFactory.create(options); + final manifestManager = ManifestManager(); + final manifest = await manifestManager.readManifest(); + + // Find all .feature files + final features = Directory('test/').listSync(recursive: true).where((file) => file.path.endsWith('.feature')).toList(); + + for (final feature in features) { + _processFeature(feature: feature, options: options, factory: factory, logger: logger, manifest: manifest); + } + + // Update manifest + manifest.lastGenerated = DateTime.now(); + await manifestManager.writeManifest(manifest); +} + +void _processFeature({ + required FileSystemEntity feature, + required BDDOptions options, + required BDDFactory factory, + required CLILogger logger, + required Manifest manifest, +}) async { + final ignoredFiles = options.ignoreFeatures; + + final featureFile = File(feature.path); + if (ignoredFiles.contains(feature.path)) { + logger.logSkipping(feature.path, reason: 'ignored'); + return; + } + + final featureContent = featureFile.readAsStringSync(); + final parsedFeature = factory.featureBuilder.parseFeature(featureContent); + final lastModified = featureFile.lastModifiedSync(); + + // Get paths for generated files + final testFilePath = '${feature.path.replaceAll('.feature', '')}${FileExtension.generatedTest}'; + final scenariosFilePath = '${feature.path.replaceAll('.feature', '')}${FileExtension.generatedScenarios}'; + final testFile = File(testFilePath); + final scenariosFile = File(scenariosFilePath); + + // Check if generated files exist + final testFileExists = await testFile.exists(); + final scenariosFileExists = await scenariosFile.exists(); + final filesExist = testFileExists && scenariosFileExists; + + // Find existing feature entry + final existingFeatureIndex = manifest.features.indexWhere((f) => f.path == feature.path); + final existingFeature = existingFeatureIndex != -1 ? manifest.features[existingFeatureIndex] : null; + + if (options.force) { + // Force regenerate everything + logger.logProcessing(feature.path, reason: 'force regenerate'); + await _generateFeatureFiles(factory, parsedFeature, feature.path); + _updateManifestEntry(manifest, feature.path, lastModified, parsedFeature); + } else if (options.newOnly) { + // Only generate for new features + if (existingFeature == null) { + logger.logProcessing(feature.path, reason: 'new feature'); + await _generateFeatureFiles(factory, parsedFeature, feature.path); + _updateManifestEntry(manifest, feature.path, lastModified, parsedFeature); + } else { + logger.logSkipping(feature.path, reason: 'existing feature'); + } + } else { + // Incremental update + if (!filesExist) { + // Files don't exist, regenerate everything + logger.logProcessing(feature.path, reason: 'missing generated files'); + await _generateFeatureFiles(factory, parsedFeature, feature.path); + _updateManifestEntry(manifest, feature.path, lastModified, parsedFeature); + } else if (existingFeature == null) { + // Feature not in manifest, regenerate everything + logger.logProcessing(feature.path, reason: 'not in manifest'); + await _generateFeatureFiles(factory, parsedFeature, feature.path); + _updateManifestEntry(manifest, feature.path, lastModified, parsedFeature); + } else if (lastModified.isAfter(existingFeature.lastModified)) { + // Feature modified, check for scenario changes + logger.logProcessing(feature.path, reason: 'modified since last generation'); + await _generateFeatureFiles( + factory, + parsedFeature, + feature.path, + existingScenarios: existingFeature.scenarios, + ); + _updateManifestEntry(manifest, feature.path, lastModified, parsedFeature); + } else { + logger.logSkipping(feature.path, reason: 'unchanged'); + } + } +} + +void _updateManifestEntry( + Manifest manifest, + String featurePath, + DateTime lastModified, + dynamic parsedFeature, +) { + final logger = CLILogger(); + final scenarios = _parseScenarios(parsedFeature); + final testFile = '${featurePath.replaceAll('.feature', '')}${FileExtension.generatedTest}'; + final featureEntry = FeatureEntry( + path: featurePath, + lastModified: lastModified, + testFile: testFile, + scenarios: scenarios, + ); + + final existingIndex = manifest.features.indexWhere((f) => f.path == featurePath); + if (existingIndex != -1) { + manifest.features[existingIndex] = featureEntry; + logger.log('Updated manifest entry for: $featurePath'); + } else { + manifest.features.add(featureEntry); + logger.log('Added new manifest entry for: $featurePath'); + } +} + +List _parseScenarios(dynamic parsedFeature) { + final scenarios = []; + var lineNumber = 1; + for (final scenario in parsedFeature.scenarios) { + final scenarioContent = scenario.toString(); + final hash = md5.convert(utf8.encode(scenarioContent)).toString(); + final startLine = lineNumber; + final endLine = startLine + scenarioContent.split('\n').length - 1; + final testMethod = 'test${scenario.name.replaceAll(' ', '')}'; + + scenarios.add(ScenarioEntry( + name: scenario.name, + hash: hash, + lineStart: startLine, + lineEnd: endLine, + testMethod: testMethod, + )); + + lineNumber = endLine + 1; + } + return scenarios; +} + +Future _generateFeatureFiles(BDDFactory factory, dynamic parsedFeature, String featurePath, {List? existingScenarios}) async { + final logger = CLILogger(); + final testFilePath = '${featurePath.replaceAll('.feature', '')}${FileExtension.generatedTest}'; + final scenariosFilePath = '${featurePath.replaceAll('.feature', '')}${FileExtension.generatedScenarios}'; + + // Get current scenarios and their hashes + final currentScenarios = _parseScenarios(parsedFeature); + final changedScenarios = []; + final newScenarios = []; + + if (existingScenarios != null) { + // Find changed and new scenarios + for (final current in currentScenarios) { + final existing = existingScenarios.firstWhere( + (s) => s.name == current.name, + orElse: () => current, + ); + + if (existing.hash != current.hash) { + changedScenarios.add(current); + logger.log('Scenario changed: ${current.name}'); + } + } + + // Find new scenarios + for (final current in currentScenarios) { + if (!existingScenarios.any((s) => s.name == current.name)) { + newScenarios.add(current); + logger.log('New scenario: ${current.name}'); + } + } + } else { + // If no existing scenarios, all are new + newScenarios.addAll(currentScenarios); + } + + if (changedScenarios.isEmpty && newScenarios.isEmpty) { + logger.log('No changes detected in scenarios'); + return; + } + + // Build scenarios file + final scenarios = await factory.scenarioBuilder.buildScenarioFile(parsedFeature); + final scenariosFile = File(scenariosFilePath); + + if (await scenariosFile.exists() && existingScenarios != null) { + // Read existing file + final existingContent = await scenariosFile.readAsString(); + final lines = existingContent.split('\n'); + + // Update only changed scenarios + for (final scenario in [...changedScenarios, ...newScenarios]) { + final startLine = scenario.lineStart - 1; + final endLine = scenario.lineEnd; + + // Find the scenario class in the existing content + final scenarioClass = "class ${scenario.name.toScenarioClassName} {"; + final classStartIndex = lines.indexWhere((line) => line.contains(scenarioClass)); + + if (classStartIndex != -1) { + // Find the end of the class + var classEndIndex = classStartIndex; + while (classEndIndex < lines.length && !lines[classEndIndex].contains('}')) { + classEndIndex++; + } + + // Replace the scenario class content + final newScenarioContent = scenarios.split('\n').where((line) => line.contains(scenarioClass)).join('\n'); + + lines.removeRange(classStartIndex, classEndIndex + 1); + lines.insert(classStartIndex, newScenarioContent); + } + } + + // Write updated content + await scenariosFile.writeAsString(lines.join('\n')); + } else { + // Write new file + await scenariosFile.writeAsString(scenarios); + } + logger.log('Updated scenarios file: $scenariosFilePath'); + + // Build test file + final testFile = await factory.testFileBuilder.buildTestFile(parsedFeature); + final testFileContent = File(testFilePath); + + if (await testFileContent.exists() && existingScenarios != null) { + // Read existing file + final existingContent = await testFileContent.readAsString(); + final lines = existingContent.split('\n'); + + // Update only changed scenarios + for (final scenario in [...changedScenarios, ...newScenarios]) { + final testMethod = scenario.testMethod; + final testStart = lines.indexWhere((line) => line.contains("$testMethod('${scenario.name}'")); + + if (testStart != -1) { + // Find the end of the test + var testEnd = testStart; + while (testEnd < lines.length && !lines[testEnd].contains('});')) { + testEnd++; + } + + // Find the new test content + final newTestContent = testFile.split('\n').where((line) => line.contains("$testMethod('${scenario.name}'")).join('\n'); + + // Replace the test content + lines.removeRange(testStart, testEnd + 1); + lines.insert(testStart, newTestContent); + } + } + + // Write updated content + await testFileContent.writeAsString(lines.join('\n')); + } else { + // Write new file + await testFileContent.writeAsString(testFile); + } + logger.log('Updated test file: $testFilePath'); +} + +/// Parse configuration from .bdd_flutter/config.yaml and command line arguments +Future _parseConfig(List arguments) async { + // Start with default values from config file + var options = await BDDOptions.fromConfig(); + + // Override with command line arguments + for (var i = 0; i < arguments.length; i++) { + final arg = arguments[i]; + switch (arg) { + case '--no-widget-tests': + options = options.copyWith(generateWidgetTests: false); + break; + case '--enable-reporter': + options = options.copyWith(enableReporter: true); + break; + case '--ignore': + if (i + 1 < arguments.length) { + final newIgnores = List.from(options.ignoreFeatures)..add(arguments[i + 1]); + options = options.copyWith(ignoreFeatures: newIgnores); + i++; // Skip the next argument as it's the feature to ignore + } + break; + case '--force': + options = options.copyWith(force: true); + break; + case '--new-only': + options = options.copyWith(newOnly: true); + break; + } + } + + return options; +} diff --git a/lib/src/runner/help_command.dart b/lib/src/runner/help_command.dart new file mode 100644 index 0000000..720450b --- /dev/null +++ b/lib/src/runner/help_command.dart @@ -0,0 +1,15 @@ +import '../feature/logger/logger.dart'; + +void help() { + final logger = CLILogger(); + logger.log(''' +Usage: bdd_flutter [options] + +Commands: + build: Build the test files + clean: Clean the test files + rename: Rename the test files + help: Show the help + version: Show the version +'''); +} diff --git a/pubspec.yaml b/pubspec.yaml index 20e2ff7..2b4ad1c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,5 @@ dependencies: yaml: ^3.1.0 dev_dependencies: - flutter_test: - sdk: flutter lints: ^2.0.0 test: ^1.24.0 From cfb81323a1e524419022835b9cf867e187407b32 Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Wed, 14 May 2025 20:16:36 -0600 Subject: [PATCH 07/24] fix log --- .gitignore | 1 + CHANGELOG.md | 2 +- README.md | 10 +- analysis_options.yaml | 1 - bin/bdd_flutter.dart | 45 +- example/.bdd_flutter/manifest.yaml | 20 +- .../test/features/feature1.bdd_scenarios.dart | 17 + example/test/features/feature1.bdd_test.dart | 16 + example/test/features/feature1.feature | 6 + .../test/features/feature2.bdd_scenarios.dart | 17 + example/test/features/feature2.bdd_test.dart | 16 + example/test/features/feature2.feature | 6 + example/test/sample/sample.bdd_test.dart | 37 +- .../bdd_builders/bdd_feature_builder.dart | 5 +- .../bdd_builders/bdd_test_file_builder.dart | 2 +- .../feature/builder/domain/bdd_options.dart | 4 + lib/src/feature/builder/domain/feature.dart | 2 + lib/src/feature/logger/logger.dart | 13 + lib/src/runner/build_command.dart | 563 ++++++++++-------- lib/src/runner/command_parser.dart | 33 + lib/src/runner/domain/cmd_flag.dart | 48 ++ lib/src/runner/help_command.dart | 30 +- 22 files changed, 553 insertions(+), 341 deletions(-) create mode 100644 example/test/features/feature1.bdd_scenarios.dart create mode 100644 example/test/features/feature1.bdd_test.dart create mode 100644 example/test/features/feature1.feature create mode 100644 example/test/features/feature2.bdd_scenarios.dart create mode 100644 example/test/features/feature2.bdd_test.dart create mode 100644 example/test/features/feature2.feature create mode 100644 lib/src/runner/command_parser.dart create mode 100644 lib/src/runner/domain/cmd_flag.dart diff --git a/.gitignore b/.gitignore index eb6c05c..e579ee7 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ migrate_working_dir/ .flutter-plugins .flutter-plugins-dependencies build/ +.cursor/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8333da9..70974ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,9 @@ ## 0.3.0 +- remove `build_runner` dependency - add custom CLI - `dart run bdd remane` to remove `.g` from generated files extension - `dart run bdd build` to generate files from feature files -- [] separete command option and config options ## 0.2.0 diff --git a/README.md b/README.md index a84d346..9cdec81 100644 --- a/README.md +++ b/README.md @@ -17,16 +17,16 @@ A powerful Flutter package that simplifies Behavior Driven Development (BDD) by To migrate: 1. Remove build_runner if present in your dev_dependencies -2. If you want to keep current test files, consider add `.feature` file paths into `bdd_config.yaml` under `ignore_features` section (see [Configuration](#-configuration) for more details) +2. If you want to keep current test files, consider add `.feature` file paths into `.bdd_flutter/config.yaml` under `ignore_features` section (see [Configuration](#-configuration) for more details) ## โœจ Features - ๐Ÿ“ Parse `.feature` files written in Gherkin syntax - โšก Generate boilerplate test files automatically - ๐Ÿงช Support for both widget tests and unit tests -- โš™๏ธ Configurable test generation -- ๐Ÿ“„ Ignore specific generated files using `.bdd_config.yaml` - ๐Ÿ“„ Incremental generation to preserve user-written code +- โš™๏ธ Configurable test generation +- ๐Ÿ“„ Ignore specific generated files using `.bdd_flutter/config.yaml` - ๐Ÿ“„ Configuration in `.bdd_flutter/config.yaml` - ๐Ÿ“„ Manifest tracking in `.bdd_flutter/manifest.yaml` @@ -36,12 +36,12 @@ Add the following dependencies to your package's `pubspec.yaml` file: ```yaml dev_dependencies: - bdd_flutter: ^1.0.0 + bdd_flutter: latest ``` ## ๐Ÿš€ Quick Start -1. Create a `.feature` file in your project: +1. Create a `.feature` file in your project test folder: ```gherkin Feature: Counter diff --git a/analysis_options.yaml b/analysis_options.yaml index a5744c1..5bcd91a 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,4 +1,3 @@ include: package:flutter_lints/flutter.yaml - # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/bin/bdd_flutter.dart b/bin/bdd_flutter.dart index 594cde5..7064a5b 100644 --- a/bin/bdd_flutter.dart +++ b/bin/bdd_flutter.dart @@ -1,41 +1,12 @@ -import 'package:bdd_flutter/src/runner/build_command.dart'; -import 'package:bdd_flutter/src/runner/help_command.dart'; -import 'package:bdd_flutter/src/runner/rename.dart'; +import 'package:bdd_flutter/src/runner/command_parser.dart'; +import 'dart:io'; void main(List arguments) async { - if (arguments.isEmpty) { - await generate(arguments); - } else { - final command = BDDCommand.fromName(arguments.first); - switchCase(command, arguments.skip(1).toList()); - } -} - -void switchCase(BDDCommand command, List arguments) { - switch (command) { - case BDDCommand.rename: - rename(arguments); - break; - default: - help(); - break; - } -} - -enum BDDCommand { - build('build', 'Build the test files'), - clean('clean', 'Clean the test files'), - rename('rename', 'Rename the test files'), - help('help', 'Show the help'), - version('version', 'Show the version'), - unknown('unknown', 'Unknown command'); - - final String name; - final String description; - - const BDDCommand(this.name, this.description); - - static BDDCommand fromName(String name) { - return values.firstWhere((command) => command.name == name, orElse: () => unknown); + try { + final parser = CommandParser(); + await parser.parse(arguments); + } catch (e) { + print('Error: ${e.toString()}'); + exit(1); } } diff --git a/example/.bdd_flutter/manifest.yaml b/example/.bdd_flutter/manifest.yaml index 46902a7..1920a90 100644 --- a/example/.bdd_flutter/manifest.yaml +++ b/example/.bdd_flutter/manifest.yaml @@ -1,5 +1,5 @@ version: "1.0" -last_generated: "2025-05-11T22:00:04.872951" +last_generated: "2025-05-14T20:00:52.946692" features: - path: "test/calculator/calculator.feature" last_modified: "2025-05-11T21:39:21.000" @@ -63,3 +63,21 @@ features: line_start: 1 line_end: 1 test_method: "testIncrement" + - path: "test/features/feature2.feature" + last_modified: "2025-05-14T19:40:21.000" + test_file: "test/features/feature2.bdd_test.dart" + scenarios: + - name: "Scenario 2" + hash: "d3ee4877a2639700318fa5b1a03d6db6" + line_start: 1 + line_end: 1 + test_method: "testScenario2" + - path: "test/features/feature1.feature" + last_modified: "2025-05-14T19:42:37.000" + test_file: "test/features/feature1.bdd_test.dart" + scenarios: + - name: "Scenario 1" + hash: "6ef26f58bf820d71a2955a1e96358e04" + line_start: 1 + line_end: 1 + test_method: "testScenario1" diff --git a/example/test/features/feature1.bdd_scenarios.dart b/example/test/features/feature1.bdd_scenarios.dart new file mode 100644 index 0000000..3b3b408 --- /dev/null +++ b/example/test/features/feature1.bdd_scenarios.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; + +class Scenario1Scenario { + static Future iHaveACounterWithValue0(WidgetTester tester) async { + // TODO: Implement Given I have a counter with value 0 + } + + static Future iIncrementTheCounterBy1(WidgetTester tester) async { + // TODO: Implement When I increment the counter by 1 + } + + static Future theCounterShouldHaveValue1(WidgetTester tester) async { + // TODO: Implement Then the counter should have value 1 + } + +} + diff --git a/example/test/features/feature1.bdd_test.dart b/example/test/features/feature1.bdd_test.dart new file mode 100644 index 0000000..8779301 --- /dev/null +++ b/example/test/features/feature1.bdd_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'feature1.bdd_scenarios.dart'; + +void main() { + group('Feature 1', () { + testWidgets('Scenario 1', (tester) async { + //Scenario: Scenario 1 + // Given I have a counter with value 0 + await Scenario1Scenario.iHaveACounterWithValue0(tester); + // When I increment the counter by 1 + await Scenario1Scenario.iIncrementTheCounterBy1(tester); + // Then the counter should have value 1 + await Scenario1Scenario.theCounterShouldHaveValue1(tester); + }); + }); +} diff --git a/example/test/features/feature1.feature b/example/test/features/feature1.feature new file mode 100644 index 0000000..d871bc5 --- /dev/null +++ b/example/test/features/feature1.feature @@ -0,0 +1,6 @@ +Feature: Feature 1 + + Scenario: Scenario 1 + Given I have a counter with value 0 + When I increment the counter by 1 + Then the counter should have value 1 diff --git a/example/test/features/feature2.bdd_scenarios.dart b/example/test/features/feature2.bdd_scenarios.dart new file mode 100644 index 0000000..ddd96d2 --- /dev/null +++ b/example/test/features/feature2.bdd_scenarios.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; + +class Scenario2Scenario { + static Future iHaveACounterWithValue0(WidgetTester tester) async { + // TODO: Implement Given I have a counter with value 0 + } + + static Future iIncrementTheCounterBy1(WidgetTester tester) async { + // TODO: Implement When I increment the counter by 1 + } + + static Future theCounterShouldHaveValue1(WidgetTester tester) async { + // TODO: Implement Then the counter should have value 1 + } + +} + diff --git a/example/test/features/feature2.bdd_test.dart b/example/test/features/feature2.bdd_test.dart new file mode 100644 index 0000000..57e3e8c --- /dev/null +++ b/example/test/features/feature2.bdd_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'feature2.bdd_scenarios.dart'; + +void main() { + group('Feature 2', () { + testWidgets('Scenario 2', (tester) async { + //Scenario: Scenario 2 + // Given I have a counter with value 0 + await Scenario2Scenario.iHaveACounterWithValue0(tester); + // When I increment the counter by 1 + await Scenario2Scenario.iIncrementTheCounterBy1(tester); + // Then the counter should have value 1 + await Scenario2Scenario.theCounterShouldHaveValue1(tester); + }); + }); +} diff --git a/example/test/features/feature2.feature b/example/test/features/feature2.feature new file mode 100644 index 0000000..ee915bc --- /dev/null +++ b/example/test/features/feature2.feature @@ -0,0 +1,6 @@ +Feature: Feature 2 + + Scenario: Scenario 2 + Given I have a counter with value 0 + When I increment the counter by 1 + Then the counter should have value 1 diff --git a/example/test/sample/sample.bdd_test.dart b/example/test/sample/sample.bdd_test.dart index f209d71..b55da06 100644 --- a/example/test/sample/sample.bdd_test.dart +++ b/example/test/sample/sample.bdd_test.dart @@ -7,6 +7,7 @@ void main() { //Scenario: Sample // Given I have a sample feature await SampleScenario.iHaveASampleFeature(tester); + // When I run the sample feature await SampleScenario.iRunTheSampleFeature(tester); // Then I should see the sample feature @@ -24,33 +25,33 @@ void main() { test('Counter with examples', () async { //Scenario: Counter with examples final examples = [ - {'counter': '1',}, - {'counter': '2',}, - {'counter': '3',}, + {'counter': '1'}, + {'counter': '2'}, + {'counter': '3'}, ]; for (var example in examples) { - // Given I have a counter - await CounterWithExamplesScenario.iHaveACounter(); - // When I increment the - await CounterWithExamplesScenario.iIncrementThe( example['counter']!); - // Then I should see the counter incremented - await CounterWithExamplesScenario.iShouldSeeTheCounterIncremented(); + // Given I have a counter + await CounterWithExamplesScenario.iHaveACounter(); + // When I increment the + await CounterWithExamplesScenario.iIncrementThe(example['counter']!); + // Then I should see the counter incremented + await CounterWithExamplesScenario.iShouldSeeTheCounterIncremented(); } }); test('Counter with parameters', () async { //Scenario: Counter with parameters final examples = [ - {'counter': '1','result': '2',}, - {'counter': '2','result': '3',}, - {'counter': '3','result': '4',}, + {'counter': '1', 'result': '2'}, + {'counter': '2', 'result': '3'}, + {'counter': '3', 'result': '4'}, ]; for (var example in examples) { - // Given I have a counter - await CounterWithParametersScenario.iHaveACounter(); - // When I increment the counter - await CounterWithParametersScenario.iIncrementTheCounter( example['counter']!); - // Then I should see the result - await CounterWithParametersScenario.iShouldSeeTheResult( example['result']!); + // Given I have a counter + await CounterWithParametersScenario.iHaveACounter(); + // When I increment the counter + await CounterWithParametersScenario.iIncrementTheCounter(example['counter']!); + // Then I should see the result + await CounterWithParametersScenario.iShouldSeeTheResult(example['result']!); } }); testWidgets('Counter with widget test', (tester) async { diff --git a/lib/src/feature/builder/bdd_builders/bdd_feature_builder.dart b/lib/src/feature/builder/bdd_builders/bdd_feature_builder.dart index 2cd68b2..1f2ec00 100644 --- a/lib/src/feature/builder/bdd_builders/bdd_feature_builder.dart +++ b/lib/src/feature/builder/bdd_builders/bdd_feature_builder.dart @@ -11,7 +11,7 @@ class BDDFeatureBuilder { BDDFeatureBuilder({required this.options}); - Feature parseFeature(String featureContent) { + Feature parseFeature(String featureContent, String fileName) { final lines = featureContent.split('\n').map((line) => line.trim()).toList(); String? featureName; List scenarios = []; @@ -31,7 +31,7 @@ class BDDFeatureBuilder { featureName = line.substring('Feature:'.length).trim(); } else if (line.startsWith('@ignore')) { // If @ignore is found, return an empty feature to skip generation - return Feature('', []); + return Feature('', [], fileName: fileName); } else if (line.startsWith('Background:')) { background = Background( description: line.substring('Background:'.length).trim(), @@ -140,6 +140,7 @@ class BDDFeatureBuilder { final feature = Feature( featureName, scenarios, + fileName: fileName, background: background, decorators: featureDecorators, ); diff --git a/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart b/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart index e1a5840..18c43f6 100644 --- a/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart +++ b/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart @@ -16,7 +16,7 @@ class BDDTestFileBuilder { buffer.writeln("import 'package:bdd_flutter/bdd_flutter.dart';"); } - buffer.writeln("import '${feature.name.toSnakeCase}${FileExtension.generatedScenarios}';"); + buffer.writeln("import '${feature.fileName}${FileExtension.generatedScenarios}';"); buffer.writeln(); buffer.writeln("void main() {"); diff --git a/lib/src/feature/builder/domain/bdd_options.dart b/lib/src/feature/builder/domain/bdd_options.dart index 0c57fc7..6c5c788 100644 --- a/lib/src/feature/builder/domain/bdd_options.dart +++ b/lib/src/feature/builder/domain/bdd_options.dart @@ -7,6 +7,7 @@ class BDDOptions { final List ignoreFeatures; final bool force; final bool newOnly; + final List only; BDDOptions({ required this.generateWidgetTests, @@ -14,6 +15,7 @@ class BDDOptions { required this.ignoreFeatures, this.force = false, this.newOnly = false, + this.only = const [], }); BDDOptions copyWith({ @@ -22,6 +24,7 @@ class BDDOptions { List? ignoreFeatures, bool? force, bool? newOnly, + List? only, }) { return BDDOptions( generateWidgetTests: generateWidgetTests ?? this.generateWidgetTests, @@ -29,6 +32,7 @@ class BDDOptions { ignoreFeatures: ignoreFeatures ?? this.ignoreFeatures, force: force ?? this.force, newOnly: newOnly ?? this.newOnly, + only: only ?? this.only, ); } diff --git a/lib/src/feature/builder/domain/feature.dart b/lib/src/feature/builder/domain/feature.dart index 442e089..1240bbf 100644 --- a/lib/src/feature/builder/domain/feature.dart +++ b/lib/src/feature/builder/domain/feature.dart @@ -15,10 +15,12 @@ class Feature { /// The background of the feature final Background? background; + final String fileName; Feature( this.name, this.scenarios, { + required this.fileName, this.decorators = const {}, this.background, }); diff --git a/lib/src/feature/logger/logger.dart b/lib/src/feature/logger/logger.dart index 9a9dde7..59d9441 100644 --- a/lib/src/feature/logger/logger.dart +++ b/lib/src/feature/logger/logger.dart @@ -3,6 +3,9 @@ enum LogLevel { info, warning, error, + debug, + verbose, + lean, } /// A class to handle logging to the terminal @@ -17,6 +20,10 @@ class CLILogger { print('$prefix $message'); } + void logLean(String message) { + log(message, level: LogLevel.lean); + } + /// Log a message about skipping a file void logSkipping(String path, {String? reason}) { final message = reason != null ? 'Skipping $path ($reason)' : 'Skipping $path'; @@ -47,6 +54,12 @@ class CLILogger { return 'โš ๏ธ'; case LogLevel.error: return 'โŒ'; + case LogLevel.debug: + return '๐Ÿ›'; + case LogLevel.verbose: + return '๐Ÿ”'; + case LogLevel.lean: + return ''; } } } diff --git a/lib/src/runner/build_command.dart b/lib/src/runner/build_command.dart index 787d53b..e95f234 100644 --- a/lib/src/runner/build_command.dart +++ b/lib/src/runner/build_command.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:bdd_flutter/src/feature/builder/bdd_builders/bdd_factory.dart'; import 'package:bdd_flutter/src/feature/builder/domain/bdd_options.dart'; +import 'package:bdd_flutter/src/feature/builder/domain/feature.dart'; import 'package:bdd_flutter/src/feature/builder/domain/manifest.dart'; import 'package:crypto/crypto.dart'; import 'dart:convert'; @@ -8,303 +9,337 @@ import 'dart:convert'; import '../constraints/file_extenstion.dart'; import '../extensions/string_x.dart'; import '../feature/logger/logger.dart'; +import 'domain/cmd_flag.dart'; -/// Executes the build command to generate test files from feature files -Future generate(List arguments) async { - final logger = CLILogger(); - final options = await _parseConfig(arguments); - final factory = BDDFactory.create(options); - final manifestManager = ManifestManager(); - final manifest = await manifestManager.readManifest(); +class BuildCommand { + final CLILogger _logger; - // Find all .feature files - final features = Directory('test/').listSync(recursive: true).where((file) => file.path.endsWith('.feature')).toList(); + final ManifestManager _manifestManager; - for (final feature in features) { - _processFeature(feature: feature, options: options, factory: factory, logger: logger, manifest: manifest); - } + BuildCommand({ + CLILogger? logger, + ManifestManager? manifestManager, + }) : _logger = logger ?? CLILogger(), + _manifestManager = manifestManager ?? ManifestManager(); - // Update manifest - manifest.lastGenerated = DateTime.now(); - await manifestManager.writeManifest(manifest); -} + /// Executes the build command to generate test files from feature files + Future generate(List arguments) async { + final options = await _parseGeneratorOption(arguments); + final factory = BDDFactory.create(options); + final manifest = await _manifestManager.readManifest(); -void _processFeature({ - required FileSystemEntity feature, - required BDDOptions options, - required BDDFactory factory, - required CLILogger logger, - required Manifest manifest, -}) async { - final ignoredFiles = options.ignoreFeatures; - - final featureFile = File(feature.path); - if (ignoredFiles.contains(feature.path)) { - logger.logSkipping(feature.path, reason: 'ignored'); - return; - } + // Find all .feature files + final features = Directory('test/').listSync(recursive: true).where((file) => file.path.endsWith('.feature')).toList(); - final featureContent = featureFile.readAsStringSync(); - final parsedFeature = factory.featureBuilder.parseFeature(featureContent); - final lastModified = featureFile.lastModifiedSync(); - - // Get paths for generated files - final testFilePath = '${feature.path.replaceAll('.feature', '')}${FileExtension.generatedTest}'; - final scenariosFilePath = '${feature.path.replaceAll('.feature', '')}${FileExtension.generatedScenarios}'; - final testFile = File(testFilePath); - final scenariosFile = File(scenariosFilePath); - - // Check if generated files exist - final testFileExists = await testFile.exists(); - final scenariosFileExists = await scenariosFile.exists(); - final filesExist = testFileExists && scenariosFileExists; - - // Find existing feature entry - final existingFeatureIndex = manifest.features.indexWhere((f) => f.path == feature.path); - final existingFeature = existingFeatureIndex != -1 ? manifest.features[existingFeatureIndex] : null; - - if (options.force) { - // Force regenerate everything - logger.logProcessing(feature.path, reason: 'force regenerate'); - await _generateFeatureFiles(factory, parsedFeature, feature.path); - _updateManifestEntry(manifest, feature.path, lastModified, parsedFeature); - } else if (options.newOnly) { - // Only generate for new features - if (existingFeature == null) { - logger.logProcessing(feature.path, reason: 'new feature'); - await _generateFeatureFiles(factory, parsedFeature, feature.path); - _updateManifestEntry(manifest, feature.path, lastModified, parsedFeature); - } else { - logger.logSkipping(feature.path, reason: 'existing feature'); - } - } else { - // Incremental update - if (!filesExist) { - // Files don't exist, regenerate everything - logger.logProcessing(feature.path, reason: 'missing generated files'); - await _generateFeatureFiles(factory, parsedFeature, feature.path); - _updateManifestEntry(manifest, feature.path, lastModified, parsedFeature); - } else if (existingFeature == null) { - // Feature not in manifest, regenerate everything - logger.logProcessing(feature.path, reason: 'not in manifest'); - await _generateFeatureFiles(factory, parsedFeature, feature.path); - _updateManifestEntry(manifest, feature.path, lastModified, parsedFeature); - } else if (lastModified.isAfter(existingFeature.lastModified)) { - // Feature modified, check for scenario changes - logger.logProcessing(feature.path, reason: 'modified since last generation'); - await _generateFeatureFiles( - factory, - parsedFeature, - feature.path, - existingScenarios: existingFeature.scenarios, - ); - _updateManifestEntry(manifest, feature.path, lastModified, parsedFeature); - } else { - logger.logSkipping(feature.path, reason: 'unchanged'); + for (final feature in features) { + await _processFeature(featurePath: feature.path, options: options, factory: factory, manifest: manifest); } - } -} -void _updateManifestEntry( - Manifest manifest, - String featurePath, - DateTime lastModified, - dynamic parsedFeature, -) { - final logger = CLILogger(); - final scenarios = _parseScenarios(parsedFeature); - final testFile = '${featurePath.replaceAll('.feature', '')}${FileExtension.generatedTest}'; - final featureEntry = FeatureEntry( - path: featurePath, - lastModified: lastModified, - testFile: testFile, - scenarios: scenarios, - ); - - final existingIndex = manifest.features.indexWhere((f) => f.path == featurePath); - if (existingIndex != -1) { - manifest.features[existingIndex] = featureEntry; - logger.log('Updated manifest entry for: $featurePath'); - } else { - manifest.features.add(featureEntry); - logger.log('Added new manifest entry for: $featurePath'); + // Update manifest + manifest.lastGenerated = DateTime.now(); + await _manifestManager.writeManifest(manifest); } -} -List _parseScenarios(dynamic parsedFeature) { - final scenarios = []; - var lineNumber = 1; - for (final scenario in parsedFeature.scenarios) { - final scenarioContent = scenario.toString(); - final hash = md5.convert(utf8.encode(scenarioContent)).toString(); - final startLine = lineNumber; - final endLine = startLine + scenarioContent.split('\n').length - 1; - final testMethod = 'test${scenario.name.replaceAll(' ', '')}'; - - scenarios.add(ScenarioEntry( - name: scenario.name, - hash: hash, - lineStart: startLine, - lineEnd: endLine, - testMethod: testMethod, - )); - - lineNumber = endLine + 1; - } - return scenarios; -} + Future _processFeature({ + required String featurePath, + required BDDOptions options, + required BDDFactory factory, + required Manifest manifest, + }) async { + final ignoredFiles = options.ignoreFeatures; + + final featureFile = File(featurePath); + if (ignoredFiles.contains(featurePath)) { + _logger.logSkipping(featurePath, reason: 'ignored'); + return; + } -Future _generateFeatureFiles(BDDFactory factory, dynamic parsedFeature, String featurePath, {List? existingScenarios}) async { - final logger = CLILogger(); - final testFilePath = '${featurePath.replaceAll('.feature', '')}${FileExtension.generatedTest}'; - final scenariosFilePath = '${featurePath.replaceAll('.feature', '')}${FileExtension.generatedScenarios}'; - - // Get current scenarios and their hashes - final currentScenarios = _parseScenarios(parsedFeature); - final changedScenarios = []; - final newScenarios = []; - - if (existingScenarios != null) { - // Find changed and new scenarios - for (final current in currentScenarios) { - final existing = existingScenarios.firstWhere( - (s) => s.name == current.name, - orElse: () => current, - ); - - if (existing.hash != current.hash) { - changedScenarios.add(current); - logger.log('Scenario changed: ${current.name}'); + final featureFileName = featurePath.split('/').last.replaceAll('.feature', ''); + + final featureContent = featureFile.readAsStringSync(); + final parsedFeature = factory.featureBuilder.parseFeature(featureContent, featureFileName); + final lastModified = featureFile.lastModifiedSync(); + + // Get paths for generated files + final testFilePath = '${featurePath.replaceAll('.feature', '')}${FileExtension.generatedTest}'; + final scenariosFilePath = '${featurePath.replaceAll('.feature', '')}${FileExtension.generatedScenarios}'; + final testFile = File(testFilePath); + final scenariosFile = File(scenariosFilePath); + + // Check if generated files exist + final testFileExists = await testFile.exists(); + final scenariosFileExists = await scenariosFile.exists(); + final filesExist = testFileExists && scenariosFileExists; + + // Find existing feature entry + final existingFeatureIndex = manifest.features.indexWhere((f) => f.path == featurePath); + final existingFeature = existingFeatureIndex != -1 ? manifest.features[existingFeatureIndex] : null; + + if (options.force) { + // Force regenerate everything + _logger.logProcessing(featurePath, reason: 'force regenerate'); + await _generateFeatureFiles(factory, parsedFeature, featurePath); + _updateManifestEntry(manifest, featurePath, lastModified, parsedFeature); + } else if (options.newOnly) { + // Only generate for new features + if (existingFeature == null) { + _logger.logProcessing(featurePath, reason: 'new feature'); + await _generateFeatureFiles(factory, parsedFeature, featurePath); + _updateManifestEntry(manifest, featurePath, lastModified, parsedFeature); + } else { + _logger.logSkipping(featurePath, reason: 'existing feature'); + } + } else { + // Incremental update + if (!filesExist) { + // Files don't exist, regenerate everything + _logger.logProcessing(featurePath, reason: 'missing generated files'); + await _generateFeatureFiles(factory, parsedFeature, featurePath); + _updateManifestEntry(manifest, featurePath, lastModified, parsedFeature); + } else if (existingFeature == null) { + // Feature not in manifest, regenerate everything + _logger.logProcessing(featurePath, reason: 'not in manifest'); + await _generateFeatureFiles(factory, parsedFeature, featurePath); + _updateManifestEntry(manifest, featurePath, lastModified, parsedFeature); + } else if (lastModified.isAfter(existingFeature.lastModified)) { + // Feature modified, check for scenario changes + _logger.logProcessing(featurePath, reason: 'modified since last generation'); + await _generateFeatureFiles( + factory, + parsedFeature, + featurePath, + existingScenarios: existingFeature.scenarios, + ); + _updateManifestEntry(manifest, featurePath, lastModified, parsedFeature); + } else { + _logger.logSkipping(featurePath, reason: 'unchanged'); } } + } - // Find new scenarios - for (final current in currentScenarios) { - if (!existingScenarios.any((s) => s.name == current.name)) { - newScenarios.add(current); - logger.log('New scenario: ${current.name}'); - } + void _updateManifestEntry( + Manifest manifest, + String featurePath, + DateTime lastModified, + Feature parsedFeature, + ) { + final scenarios = _parseScenarios(parsedFeature); + final testFile = '${featurePath.replaceAll('.feature', '')}${FileExtension.generatedTest}'; + + final featureEntry = FeatureEntry( + path: featurePath, + lastModified: lastModified, + testFile: testFile, + scenarios: scenarios, + ); + + final existingIndex = manifest.features.indexWhere((f) => f.path == featurePath); + if (existingIndex != -1) { + manifest.features[existingIndex] = featureEntry; + _logger.log('Updated manifest entry for: $featurePath'); + } else { + manifest.features.add(featureEntry); + _logger.log('Added new manifest entry for: $featurePath'); } - } else { - // If no existing scenarios, all are new - newScenarios.addAll(currentScenarios); } - if (changedScenarios.isEmpty && newScenarios.isEmpty) { - logger.log('No changes detected in scenarios'); - return; + List _parseScenarios(Feature parsedFeature) { + final scenarios = []; + var lineNumber = 1; + for (final scenario in parsedFeature.scenarios) { + final scenarioContent = scenario.toString(); + final hash = md5.convert(utf8.encode(scenarioContent)).toString(); + final startLine = lineNumber; + final endLine = startLine + scenarioContent.split('\n').length - 1; + final testMethod = 'test${scenario.name.replaceAll(' ', '')}'; + + scenarios.add(ScenarioEntry( + name: scenario.name, + hash: hash, + lineStart: startLine, + lineEnd: endLine, + testMethod: testMethod, + )); + + lineNumber = endLine + 1; + } + return scenarios; } - // Build scenarios file - final scenarios = await factory.scenarioBuilder.buildScenarioFile(parsedFeature); - final scenariosFile = File(scenariosFilePath); - - if (await scenariosFile.exists() && existingScenarios != null) { - // Read existing file - final existingContent = await scenariosFile.readAsString(); - final lines = existingContent.split('\n'); - - // Update only changed scenarios - for (final scenario in [...changedScenarios, ...newScenarios]) { - final startLine = scenario.lineStart - 1; - final endLine = scenario.lineEnd; - - // Find the scenario class in the existing content - final scenarioClass = "class ${scenario.name.toScenarioClassName} {"; - final classStartIndex = lines.indexWhere((line) => line.contains(scenarioClass)); - - if (classStartIndex != -1) { - // Find the end of the class - var classEndIndex = classStartIndex; - while (classEndIndex < lines.length && !lines[classEndIndex].contains('}')) { - classEndIndex++; + Future _generateFeatureFiles( + BDDFactory factory, + Feature parsedFeature, + String featurePath, { + List? existingScenarios, + }) async { + final testFilePath = '${featurePath.replaceAll('.feature', '')}${FileExtension.generatedTest}'; + final scenariosFilePath = '${featurePath.replaceAll('.feature', '')}${FileExtension.generatedScenarios}'; + + // Get current scenarios and their hashes + final currentScenarios = _parseScenarios(parsedFeature); + final changedScenarios = []; + final newScenarios = []; + + if (existingScenarios != null) { + // Find changed and new scenarios + for (final current in currentScenarios) { + final existing = existingScenarios.firstWhere( + (s) => s.name == current.name, + orElse: () => current, + ); + + if (existing.hash != current.hash) { + changedScenarios.add(current); + _logger.log('Scenario changed: ${current.name}'); } + } - // Replace the scenario class content - final newScenarioContent = scenarios.split('\n').where((line) => line.contains(scenarioClass)).join('\n'); - - lines.removeRange(classStartIndex, classEndIndex + 1); - lines.insert(classStartIndex, newScenarioContent); + // Find new scenarios + for (final current in currentScenarios) { + if (!existingScenarios.any((s) => s.name == current.name)) { + newScenarios.add(current); + _logger.log('New scenario: ${current.name}'); + } } + } else { + // If no existing scenarios, all are new + newScenarios.addAll(currentScenarios); } - // Write updated content - await scenariosFile.writeAsString(lines.join('\n')); - } else { - // Write new file - await scenariosFile.writeAsString(scenarios); - } - logger.log('Updated scenarios file: $scenariosFilePath'); - - // Build test file - final testFile = await factory.testFileBuilder.buildTestFile(parsedFeature); - final testFileContent = File(testFilePath); - - if (await testFileContent.exists() && existingScenarios != null) { - // Read existing file - final existingContent = await testFileContent.readAsString(); - final lines = existingContent.split('\n'); - - // Update only changed scenarios - for (final scenario in [...changedScenarios, ...newScenarios]) { - final testMethod = scenario.testMethod; - final testStart = lines.indexWhere((line) => line.contains("$testMethod('${scenario.name}'")); - - if (testStart != -1) { - // Find the end of the test - var testEnd = testStart; - while (testEnd < lines.length && !lines[testEnd].contains('});')) { - testEnd++; - } + if (changedScenarios.isEmpty && newScenarios.isEmpty) { + _logger.log('No changes detected in scenarios'); + return; + } - // Find the new test content - final newTestContent = testFile.split('\n').where((line) => line.contains("$testMethod('${scenario.name}'")).join('\n'); + await _updateScenariosFile(factory, parsedFeature, scenariosFilePath, existingScenarios, changedScenarios, newScenarios); + await _updateTestFile(factory, parsedFeature, testFilePath, existingScenarios, changedScenarios, newScenarios); + } - // Replace the test content - lines.removeRange(testStart, testEnd + 1); - lines.insert(testStart, newTestContent); + Future _updateScenariosFile( + BDDFactory factory, + Feature parsedFeature, + String scenariosFilePath, + List? existingScenarios, + List changedScenarios, + List newScenarios, + ) async { + // Build scenarios file + final scenarios = await factory.scenarioBuilder.buildScenarioFile(parsedFeature); + final scenariosFile = File(scenariosFilePath); + + if (await scenariosFile.exists() && existingScenarios != null) { + // Read existing file + final existingContent = await scenariosFile.readAsString(); + final lines = existingContent.split('\n'); + + // Update only changed scenarios + for (final scenario in [...changedScenarios, ...newScenarios]) { + // final startLine = scenario.lineStart - 1; + // final endLine = scenario.lineEnd; + + // Find the scenario class in the existing content + final scenarioClass = "class ${scenario.name.toScenarioClassName} {"; + final classStartIndex = lines.indexWhere((line) => line.contains(scenarioClass)); + + if (classStartIndex != -1) { + // Find the end of the class + var classEndIndex = classStartIndex; + while (classEndIndex < lines.length && !lines[classEndIndex].contains('}')) { + classEndIndex++; + } + + // Replace the scenario class content + final newScenarioContent = scenarios.split('\n').where((line) => line.contains(scenarioClass)).join('\n'); + + lines.removeRange(classStartIndex, classEndIndex + 1); + lines.insert(classStartIndex, newScenarioContent); + } } + + // Write updated content + await scenariosFile.writeAsString(lines.join('\n')); + } else { + // Write new file + await scenariosFile.writeAsString(scenarios); } - // Write updated content - await testFileContent.writeAsString(lines.join('\n')); - } else { - // Write new file - await testFileContent.writeAsString(testFile); + _logger.log('Updated scenarios file: $scenariosFilePath'); } - logger.log('Updated test file: $testFilePath'); -} -/// Parse configuration from .bdd_flutter/config.yaml and command line arguments -Future _parseConfig(List arguments) async { - // Start with default values from config file - var options = await BDDOptions.fromConfig(); - - // Override with command line arguments - for (var i = 0; i < arguments.length; i++) { - final arg = arguments[i]; - switch (arg) { - case '--no-widget-tests': - options = options.copyWith(generateWidgetTests: false); - break; - case '--enable-reporter': - options = options.copyWith(enableReporter: true); - break; - case '--ignore': - if (i + 1 < arguments.length) { - final newIgnores = List.from(options.ignoreFeatures)..add(arguments[i + 1]); - options = options.copyWith(ignoreFeatures: newIgnores); - i++; // Skip the next argument as it's the feature to ignore + Future _updateTestFile( + BDDFactory factory, + Feature parsedFeature, + String testFilePath, + List? existingScenarios, + List changedScenarios, + List newScenarios, + ) async { + // Build test file + final testFile = await factory.testFileBuilder.buildTestFile(parsedFeature); + final testFileContent = File(testFilePath); + + if (await testFileContent.exists() && existingScenarios != null) { + // Read existing file + final existingContent = await testFileContent.readAsString(); + final lines = existingContent.split('\n'); + + // Update only changed scenarios + for (final scenario in [...changedScenarios, ...newScenarios]) { + final testMethod = scenario.testMethod; + final testStart = lines.indexWhere((line) => line.contains("$testMethod('${scenario.name}'")); + + if (testStart != -1) { + // Find the end of the test + var testEnd = testStart; + while (testEnd < lines.length && !lines[testEnd].contains('});')) { + testEnd++; + } + + // Find the new test content + final newTestContent = testFile.split('\n').where((line) => line.contains("$testMethod('${scenario.name}'")).join('\n'); + + // Replace the test content + lines.removeRange(testStart, testEnd + 1); + lines.insert(testStart, newTestContent); } - break; - case '--force': - options = options.copyWith(force: true); - break; - case '--new-only': - options = options.copyWith(newOnly: true); - break; + } + + // Write updated content + await testFileContent.writeAsString(lines.join('\n')); + } else { + // Write new file + await testFileContent.writeAsString(testFile); } + _logger.log('Updated test file: $testFilePath'); } - return options; + Future _parseGeneratorOption(List arguments) async { + // Start with default values from config file + var options = await BDDOptions.fromConfig(); + // get all flags from arguments + final flags = arguments.map((e) => CmdFlag.fromString(e)).where((e) => e != CmdFlag.invalid).toList(); + + // Override with command line arguments + for (final flag in flags) { + switch (flag) { + case CmdFlag.widgetTests: + options = options.copyWith(generateWidgetTests: flag.value == 'true'); + break; + case CmdFlag.reporter: + options = options.copyWith(enableReporter: flag.value == 'true'); + break; + case CmdFlag.force: + options = options.copyWith(force: true); + break; + case CmdFlag.newOnly: + options = options.copyWith(newOnly: true); + break; + default: + _logger.error('Invalid flag: ${flag.text}'); + break; + } + } + + return options; + } } diff --git a/lib/src/runner/command_parser.dart b/lib/src/runner/command_parser.dart new file mode 100644 index 0000000..3c0c8fb --- /dev/null +++ b/lib/src/runner/command_parser.dart @@ -0,0 +1,33 @@ +import 'build_command.dart'; +import '../feature/logger/logger.dart'; +import 'help_command.dart'; + +class CommandParser { + final BuildCommand _buildCommand; + final CLILogger _logger; + + CommandParser({BuildCommand? buildCommand, CLILogger? logger}) + : _buildCommand = buildCommand ?? BuildCommand(), + _logger = logger ?? CLILogger(); + + Future parse(List arguments) async { + if (arguments.isEmpty) { + // Default to build command if no arguments provided + await _buildCommand.generate(arguments); + return; + } + + final command = arguments[0].toLowerCase(); + final remainingArgs = arguments.sublist(1); + + switch (command) { + case 'build': + await _buildCommand.generate(remainingArgs); + break; + + default: + _logger.log('Unknown command: $command', level: LogLevel.error); + help(_logger); + } + } +} diff --git a/lib/src/runner/domain/cmd_flag.dart b/lib/src/runner/domain/cmd_flag.dart new file mode 100644 index 0000000..5833fa5 --- /dev/null +++ b/lib/src/runner/domain/cmd_flag.dart @@ -0,0 +1,48 @@ +class CmdFlag { + static const widgetTests = CmdFlag('--widget-test', '-w', 'Generate widget tests instead of unit tests, default is true', 'true'); + static const reporter = CmdFlag('--reporter', '-r', 'Enable reporter, default is false', 'false'); + static const ignore = CmdFlag('--ignore', '-i', 'Ignore features', ''); + static const force = CmdFlag('--force', '-f', 'Force generation', 'false'); + static const newOnly = CmdFlag('--new-only', '-n', 'Only generate new features', 'false'); + static const invalid = CmdFlag('--invalid', '', '', ''); + + final String text; + final String shortText; + final String description; + final String value; + + const CmdFlag(this.text, this.shortText, this.description, this.value); + + CmdFlag copyWith({String? text, String? shortText, String? description, String? value}) { + return CmdFlag( + text ?? this.text, + shortText ?? this.shortText, + description ?? this.description, + value ?? this.value, + ); + } + + @override + String toString() { + return '$text, $shortText, $description'; + } + + static List get values => [widgetTests, reporter, ignore, force, newOnly, invalid]; + + static String getHelpText() { + return values.where((flag) => flag != invalid).map((flag) => '${flag.text} (${flag.shortText}): ${flag.description}').join('\n'); + } + + static CmdFlag fromString(String text) { + final parts = text.split(','); + final flagText = parts.first; + final value = parts.lastOrNull ?? ''; + + return values + .firstWhere( + (flag) => flag.text == flagText || flag.shortText == flagText, + orElse: () => invalid, + ) + .copyWith(value: value); + } +} diff --git a/lib/src/runner/help_command.dart b/lib/src/runner/help_command.dart index 720450b..3cf001b 100644 --- a/lib/src/runner/help_command.dart +++ b/lib/src/runner/help_command.dart @@ -1,15 +1,23 @@ import '../feature/logger/logger.dart'; -void help() { - final logger = CLILogger(); - logger.log(''' -Usage: bdd_flutter [options] - -Commands: - build: Build the test files - clean: Clean the test files - rename: Rename the test files - help: Show the help - version: Show the version +void help(CLILogger logger) { + logger.logLean(''' +Usage: dart run bdd_flutter [options] + +Options: + --help Show this help message + --force Force regenerate all feature files + --new Only generate new feature files + --widget Generate widget tests (default: false) + --reporter Enable test reporter (default: false) + +Examples: + dart run bdd_flutter + dart run bdd_flutter --force + dart run bdd_flutter --new + dart run bdd_flutter --widget + dart run bdd_flutter --reporter + +For more information, visit: [GitHub repository URL] '''); } From c2a3beaf6ea0587bd01c7ebf449db68d92a3e575 Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Wed, 14 May 2025 21:06:18 -0600 Subject: [PATCH 08/24] update flag handling --- bin/bdd_flutter.dart | 10 +--- example/.bdd_flutter/manifest.yaml | 11 +++- .../test/features/feature3.bdd_scenarios.dart | 17 ++++++ example/test/features/feature3.bdd_test.dart | 16 +++++ example/test/features/feature3.feature | 6 ++ example/test/sample/sample.bdd_test.dart | 37 ++++++------ .../feature/builder/domain/bdd_options.dart | 38 ++++++------ lib/src/runner/build_command.dart | 19 +++--- lib/src/runner/command_parser.dart | 46 +++++++++++---- lib/src/runner/domain/cmd_flag.dart | 59 ++++++++++--------- lib/src/runner/help_command.dart | 14 ++--- lib/src/runner/rename.dart | 52 ---------------- 12 files changed, 169 insertions(+), 156 deletions(-) create mode 100644 example/test/features/feature3.bdd_scenarios.dart create mode 100644 example/test/features/feature3.bdd_test.dart create mode 100644 example/test/features/feature3.feature delete mode 100644 lib/src/runner/rename.dart diff --git a/bin/bdd_flutter.dart b/bin/bdd_flutter.dart index 7064a5b..cb81dd2 100644 --- a/bin/bdd_flutter.dart +++ b/bin/bdd_flutter.dart @@ -1,12 +1,6 @@ import 'package:bdd_flutter/src/runner/command_parser.dart'; -import 'dart:io'; void main(List arguments) async { - try { - final parser = CommandParser(); - await parser.parse(arguments); - } catch (e) { - print('Error: ${e.toString()}'); - exit(1); - } + final parser = CommandParser(); + await parser.parse(arguments); } diff --git a/example/.bdd_flutter/manifest.yaml b/example/.bdd_flutter/manifest.yaml index 1920a90..0d33413 100644 --- a/example/.bdd_flutter/manifest.yaml +++ b/example/.bdd_flutter/manifest.yaml @@ -1,5 +1,5 @@ version: "1.0" -last_generated: "2025-05-14T20:00:52.946692" +last_generated: "2025-05-14T21:05:50.868129" features: - path: "test/calculator/calculator.feature" last_modified: "2025-05-11T21:39:21.000" @@ -81,3 +81,12 @@ features: line_start: 1 line_end: 1 test_method: "testScenario1" + - path: "test/features/feature3.feature" + last_modified: "2025-05-14T21:04:20.000" + test_file: "test/features/feature3.bdd_test.dart" + scenarios: + - name: "Scenario 3" + hash: "8c18f46a7758127ba8c8cb90ab8839d0" + line_start: 1 + line_end: 1 + test_method: "testScenario3" diff --git a/example/test/features/feature3.bdd_scenarios.dart b/example/test/features/feature3.bdd_scenarios.dart new file mode 100644 index 0000000..c1523fe --- /dev/null +++ b/example/test/features/feature3.bdd_scenarios.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; + +class Scenario3Scenario { + static Future iHaveACounterWithValue0(WidgetTester tester) async { + // TODO: Implement Given I have a counter with value 0 + } + + static Future iIncrementTheCounterBy1(WidgetTester tester) async { + // TODO: Implement When I increment the counter by 1 + } + + static Future theCounterShouldHaveValue1(WidgetTester tester) async { + // TODO: Implement Then the counter should have value 1 + } + +} + diff --git a/example/test/features/feature3.bdd_test.dart b/example/test/features/feature3.bdd_test.dart new file mode 100644 index 0000000..c2a9a27 --- /dev/null +++ b/example/test/features/feature3.bdd_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'feature3.bdd_scenarios.dart'; + +void main() { + group('Feature 3', () { + testWidgets('Scenario 3', (tester) async { + //Scenario: Scenario 3 + // Given I have a counter with value 0 + await Scenario3Scenario.iHaveACounterWithValue0(tester); + // When I increment the counter by 1 + await Scenario3Scenario.iIncrementTheCounterBy1(tester); + // Then the counter should have value 1 + await Scenario3Scenario.theCounterShouldHaveValue1(tester); + }); + }); +} diff --git a/example/test/features/feature3.feature b/example/test/features/feature3.feature new file mode 100644 index 0000000..2202938 --- /dev/null +++ b/example/test/features/feature3.feature @@ -0,0 +1,6 @@ +Feature: Feature 3 + + Scenario: Scenario 3 + Given I have a counter with value 0 + When I increment the counter by 1 + Then the counter should have value 1 diff --git a/example/test/sample/sample.bdd_test.dart b/example/test/sample/sample.bdd_test.dart index b55da06..f209d71 100644 --- a/example/test/sample/sample.bdd_test.dart +++ b/example/test/sample/sample.bdd_test.dart @@ -7,7 +7,6 @@ void main() { //Scenario: Sample // Given I have a sample feature await SampleScenario.iHaveASampleFeature(tester); - // When I run the sample feature await SampleScenario.iRunTheSampleFeature(tester); // Then I should see the sample feature @@ -25,33 +24,33 @@ void main() { test('Counter with examples', () async { //Scenario: Counter with examples final examples = [ - {'counter': '1'}, - {'counter': '2'}, - {'counter': '3'}, + {'counter': '1',}, + {'counter': '2',}, + {'counter': '3',}, ]; for (var example in examples) { - // Given I have a counter - await CounterWithExamplesScenario.iHaveACounter(); - // When I increment the - await CounterWithExamplesScenario.iIncrementThe(example['counter']!); - // Then I should see the counter incremented - await CounterWithExamplesScenario.iShouldSeeTheCounterIncremented(); + // Given I have a counter + await CounterWithExamplesScenario.iHaveACounter(); + // When I increment the + await CounterWithExamplesScenario.iIncrementThe( example['counter']!); + // Then I should see the counter incremented + await CounterWithExamplesScenario.iShouldSeeTheCounterIncremented(); } }); test('Counter with parameters', () async { //Scenario: Counter with parameters final examples = [ - {'counter': '1', 'result': '2'}, - {'counter': '2', 'result': '3'}, - {'counter': '3', 'result': '4'}, + {'counter': '1','result': '2',}, + {'counter': '2','result': '3',}, + {'counter': '3','result': '4',}, ]; for (var example in examples) { - // Given I have a counter - await CounterWithParametersScenario.iHaveACounter(); - // When I increment the counter - await CounterWithParametersScenario.iIncrementTheCounter(example['counter']!); - // Then I should see the result - await CounterWithParametersScenario.iShouldSeeTheResult(example['result']!); + // Given I have a counter + await CounterWithParametersScenario.iHaveACounter(); + // When I increment the counter + await CounterWithParametersScenario.iIncrementTheCounter( example['counter']!); + // Then I should see the result + await CounterWithParametersScenario.iShouldSeeTheResult( example['result']!); } }); testWidgets('Counter with widget test', (tester) async { diff --git a/lib/src/feature/builder/domain/bdd_options.dart b/lib/src/feature/builder/domain/bdd_options.dart index 6c5c788..e3e6714 100644 --- a/lib/src/feature/builder/domain/bdd_options.dart +++ b/lib/src/feature/builder/domain/bdd_options.dart @@ -18,6 +18,8 @@ class BDDOptions { this.only = const [], }); + factory BDDOptions.defaultOptions() => BDDOptions(enableReporter: false, generateWidgetTests: true, ignoreFeatures: []); + BDDOptions copyWith({ bool? generateWidgetTests, bool? enableReporter, @@ -39,16 +41,18 @@ class BDDOptions { static const String bddDir = '.bdd_flutter'; static const String configPath = '$bddDir/config.yaml'; - static Future ensureBDDDirectory() async { - final dir = Directory(bddDir); - if (!await dir.exists()) { - await dir.create(); - } - } + // static Future ensureBDDDirectory() async { + // final dir = Directory(bddDir); + // if (!await dir.exists()) { + // await dir.create(); + // } + // } static Future fromConfig() async { - await ensureBDDDirectory(); final configFile = File(configPath); + if (!await configFile.exists()) { + return BDDOptions.defaultOptions(); + } // Start with default values bool generateWidgetTests = true; @@ -75,16 +79,16 @@ class BDDOptions { ); } - static Future writeConfig(BDDOptions options) async { - await ensureBDDDirectory(); - final configFile = File(configPath); + // static Future writeConfig(BDDOptions options) async { + // await ensureBDDDirectory(); + // final configFile = File(configPath); - final config = { - 'generate_widget_tests': options.generateWidgetTests, - 'enable_reporter': options.enableReporter, - 'ignore_features': options.ignoreFeatures, - }; + // final config = { + // 'generate_widget_tests': options.generateWidgetTests, + // 'enable_reporter': options.enableReporter, + // 'ignore_features': options.ignoreFeatures, + // }; - await configFile.writeAsString(config.toString()); - } + // await configFile.writeAsString(config.toString()); + // } } diff --git a/lib/src/runner/build_command.dart b/lib/src/runner/build_command.dart index e95f234..bed3df9 100644 --- a/lib/src/runner/build_command.dart +++ b/lib/src/runner/build_command.dart @@ -5,6 +5,7 @@ import 'package:bdd_flutter/src/feature/builder/domain/feature.dart'; import 'package:bdd_flutter/src/feature/builder/domain/manifest.dart'; import 'package:crypto/crypto.dart'; import 'dart:convert'; +import 'package:yaml/yaml.dart'; import '../constraints/file_extenstion.dart'; import '../extensions/string_x.dart'; @@ -23,8 +24,8 @@ class BuildCommand { _manifestManager = manifestManager ?? ManifestManager(); /// Executes the build command to generate test files from feature files - Future generate(List arguments) async { - final options = await _parseGeneratorOption(arguments); + Future generate(List flags) async { + final options = await _parseGeneratorOption(flags); final factory = BDDFactory.create(options); final manifest = await _manifestManager.readManifest(); @@ -313,20 +314,16 @@ class BuildCommand { _logger.log('Updated test file: $testFilePath'); } - Future _parseGeneratorOption(List arguments) async { - // Start with default values from config file + Future _parseGeneratorOption(List flags) async { var options = await BDDOptions.fromConfig(); - // get all flags from arguments - final flags = arguments.map((e) => CmdFlag.fromString(e)).where((e) => e != CmdFlag.invalid).toList(); - // Override with command line arguments for (final flag in flags) { switch (flag) { - case CmdFlag.widgetTests: - options = options.copyWith(generateWidgetTests: flag.value == 'true'); + case CmdFlag.unitTest: + options = options.copyWith(generateWidgetTests: false); break; case CmdFlag.reporter: - options = options.copyWith(enableReporter: flag.value == 'true'); + options = options.copyWith(enableReporter: true); break; case CmdFlag.force: options = options.copyWith(force: true); @@ -335,7 +332,7 @@ class BuildCommand { options = options.copyWith(newOnly: true); break; default: - _logger.error('Invalid flag: ${flag.text}'); + _logger.error('Invalid flag: ${flag.longForm}'); break; } } diff --git a/lib/src/runner/command_parser.dart b/lib/src/runner/command_parser.dart index 3c0c8fb..dec0246 100644 --- a/lib/src/runner/command_parser.dart +++ b/lib/src/runner/command_parser.dart @@ -1,5 +1,6 @@ -import 'build_command.dart'; import '../feature/logger/logger.dart'; +import 'build_command.dart'; +import 'domain/cmd_flag.dart'; import 'help_command.dart'; class CommandParser { @@ -11,23 +12,44 @@ class CommandParser { _logger = logger ?? CLILogger(); Future parse(List arguments) async { + print('arguments: $arguments'); if (arguments.isEmpty) { // Default to build command if no arguments provided - await _buildCommand.generate(arguments); + await _buildCommand.generate([]); return; } - final command = arguments[0].toLowerCase(); - final remainingArgs = arguments.sublist(1); + final flags = arguments.map((argument) => CmdFlag.fromString(argument)).toList(); + print('flags: $flags'); + final error = CmdFlagValidator.validate(flags); - switch (command) { - case 'build': - await _buildCommand.generate(remainingArgs); - break; - - default: - _logger.log('Unknown command: $command', level: LogLevel.error); - help(_logger); + if (error != null) { + _logger.log(error, level: LogLevel.error); + help(_logger); + return; + } else if (flags.contains(CmdFlag.help)) { + help(_logger); + return; + } else { + await _buildCommand.generate(flags); } + + // final command = arguments[0].toLowerCase(); + + // if (arguments.contains('--help') || arguments.contains('-h')) { + // help(_logger); + // } else if (_commands.contains(command)) { + // // Simple validation for mutually exclusive flags + // if ((arguments.contains('--force') || arguments.contains('-f')) && (arguments.contains('--new') || arguments.contains('-n'))) { + // _logger.log('Error: Cannot use --force with --new', level: LogLevel.error); + // help(_logger); + // return; + // } else { + // await _buildCommand.generate(arguments); + // } + // } else { + // _logger.log('Unknown command: $command', level: LogLevel.error); + // help(_logger); + // } } } diff --git a/lib/src/runner/domain/cmd_flag.dart b/lib/src/runner/domain/cmd_flag.dart index 5833fa5..84d5705 100644 --- a/lib/src/runner/domain/cmd_flag.dart +++ b/lib/src/runner/domain/cmd_flag.dart @@ -1,48 +1,49 @@ class CmdFlag { - static const widgetTests = CmdFlag('--widget-test', '-w', 'Generate widget tests instead of unit tests, default is true', 'true'); - static const reporter = CmdFlag('--reporter', '-r', 'Enable reporter, default is false', 'false'); - static const ignore = CmdFlag('--ignore', '-i', 'Ignore features', ''); - static const force = CmdFlag('--force', '-f', 'Force generation', 'false'); - static const newOnly = CmdFlag('--new-only', '-n', 'Only generate new features', 'false'); - static const invalid = CmdFlag('--invalid', '', '', ''); - - final String text; - final String shortText; + static const help = CmdFlag('--help', '-h', 'Show help'); + static const unitTest = CmdFlag('--unit-test', '-u', 'Generate unit tests instead of widget tests, Widget tests are generated by default'); + static const reporter = CmdFlag('--reporter', '-r', 'Enable reporter, disable by default'); + static const force = CmdFlag('--force', '-f', 'Force generation, all feature files are overwritten'); + static const newOnly = CmdFlag('--new', '-n', 'Only generate new features, modified feature files are not generated'); + + final String longForm; + final String shortForm; final String description; - final String value; - const CmdFlag(this.text, this.shortText, this.description, this.value); + const CmdFlag(this.longForm, this.shortForm, this.description); CmdFlag copyWith({String? text, String? shortText, String? description, String? value}) { return CmdFlag( - text ?? this.text, - shortText ?? this.shortText, + text ?? this.longForm, + shortText ?? this.shortForm, description ?? this.description, - value ?? this.value, ); } @override String toString() { - return '$text, $shortText, $description'; + return '$longForm, $shortForm, $description'; } - static List get values => [widgetTests, reporter, ignore, force, newOnly, invalid]; - - static String getHelpText() { - return values.where((flag) => flag != invalid).map((flag) => '${flag.text} (${flag.shortText}): ${flag.description}').join('\n'); - } + static List get values => [unitTest, reporter, force, newOnly, help]; static CmdFlag fromString(String text) { - final parts = text.split(','); - final flagText = parts.first; - final value = parts.lastOrNull ?? ''; + return values.firstWhere( + (flag) => flag.longForm == text || flag.shortForm == text, + orElse: () => CmdFlag(text, text, 'Invalid flag'), + ); + } +} - return values - .firstWhere( - (flag) => flag.text == flagText || flag.shortText == flagText, - orElse: () => invalid, - ) - .copyWith(value: value); +class CmdFlagValidator { + static String? validate(List flags) { + for (final flag in flags) { + if (!CmdFlag.values.contains(flag)) { + return 'Invalid flag: ${flag.longForm} ${flag.description}'; + } + } + if (flags.contains(CmdFlag.newOnly) && flags.contains(CmdFlag.force)) { + return 'Cannot use --new with --force'; + } + return null; } } diff --git a/lib/src/runner/help_command.dart b/lib/src/runner/help_command.dart index 3cf001b..c4f4a7d 100644 --- a/lib/src/runner/help_command.dart +++ b/lib/src/runner/help_command.dart @@ -5,19 +5,19 @@ void help(CLILogger logger) { Usage: dart run bdd_flutter [options] Options: - --help Show this help message - --force Force regenerate all feature files - --new Only generate new feature files - --widget Generate widget tests (default: false) - --reporter Enable test reporter (default: false) + --help, -h Show this help message + --force, -f Force regenerate all feature files + --new, -n Only generate new feature files + --unit-test, -u Generate unit tests (default: false) + --reporter, -r Enable test reporter (default: false) Examples: dart run bdd_flutter dart run bdd_flutter --force dart run bdd_flutter --new - dart run bdd_flutter --widget + dart run bdd_flutter --unit-test dart run bdd_flutter --reporter -For more information, visit: [GitHub repository URL] +For more information, visit: https://github.com/samderlust/bdd_flutter '''); } diff --git a/lib/src/runner/rename.dart b/lib/src/runner/rename.dart deleted file mode 100644 index a254b69..0000000 --- a/lib/src/runner/rename.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'dart:io'; - -import '../constraints/file_extenstion.dart'; - -/// this will remove `.g` from the file name -/// -/// suggested to run after writing test, this will prevent the file from being overwritten -/// the next time the build command is run -void rename(List arguments) { - // arg can be list of feature name, if not provided, all features will be renamed - // if provided, only the features in the list will be renamed - - if (arguments.isEmpty) { - print('No feature name provided, all features will be renamed'); - } else { - print('Renaming features: ${arguments.join(', ')}'); - } - - final testDir = Directory('test'); - if (!testDir.existsSync()) { - print('No test directory found'); - return; - } - - final files = testDir - .listSync(recursive: true) - .where((file) => file.path.endsWith(FileExtension.generatedTest) || file.path.endsWith(FileExtension.generatedScenarios)) - .map((file) => File(file.path)) - .where((file) { - if (arguments.isEmpty) return true; - final featureName = file.path.split('/').last.split('.').first; - return arguments.any((arg) => featureName.contains(arg)); - }).toList(); - - if (files.isEmpty) { - print('No generated files found to rename'); - return; - } - - for (final file in files) { - final newName = file.path.replaceAll('.bdd.', '.bdd_'); - print('Renaming ${file.path} to $newName'); - - // Update import statements in the file - final content = file.readAsStringSync(); - final updatedContent = content.replaceAll('.bdd.', '.bdd_'); - file.writeAsStringSync(updatedContent); - - // Rename the file - file.renameSync(newName); - } -} From bd4e40161b2d5056daa11a09a03da6b41aef705e Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Thu, 15 May 2025 22:13:50 -0600 Subject: [PATCH 09/24] add testcases --- example/.bdd_flutter/manifest.yaml | 12 +- .../calculator/calculator.bdd_scenarios.dart | 25 +- .../test/calculator/calculator.bdd_test.dart | 12 +- .../test/counter/counter.bdd_scenarios.dart | 4 +- example/test/counter/counter.bdd_test.dart | 4 +- example/test/sample/sample.bdd_scenarios.dart | 6 +- example/test/sample/sample.bdd_test.dart | 6 +- lib/src/extensions/string_x.dart | 2 +- .../bdd_builders/bdd_feature_builder.dart | 7 +- .../bdd_builders/bdd_test_file_builder.dart | 4 +- lib/src/feature/builder/domain/feature.dart | 7 +- lib/src/runner/build_command.dart | 8 +- pubspec.yaml | 2 + test/feature_builder/bdd_flutter_test.dart | 69 ------ .../build_scenarios_file_test.dart | 142 ++++++++++++ .../feature_builder/build_test_file_test.dart | 217 ++++++++++++++++++ .../nested_decorators_test.dart | 86 ------- test/feature_builder/parse_feature_test.dart | 192 ++++++++++++++++ test/runner/flag_parser_test.dart | 93 ++++++++ 19 files changed, 706 insertions(+), 192 deletions(-) delete mode 100644 test/feature_builder/bdd_flutter_test.dart create mode 100644 test/feature_builder/build_scenarios_file_test.dart create mode 100644 test/feature_builder/build_test_file_test.dart delete mode 100644 test/feature_builder/nested_decorators_test.dart create mode 100644 test/feature_builder/parse_feature_test.dart create mode 100644 test/runner/flag_parser_test.dart diff --git a/example/.bdd_flutter/manifest.yaml b/example/.bdd_flutter/manifest.yaml index 0d33413..7866558 100644 --- a/example/.bdd_flutter/manifest.yaml +++ b/example/.bdd_flutter/manifest.yaml @@ -1,27 +1,27 @@ version: "1.0" -last_generated: "2025-05-14T21:05:50.868129" +last_generated: "2025-05-15T22:06:39.936688" features: - path: "test/calculator/calculator.feature" last_modified: "2025-05-11T21:39:21.000" test_file: "test/calculator/calculator.bdd_test.dart" scenarios: - name: "Add two numbers" - hash: "5a4c3bc6392ebc55660998e6f458beeb" + hash: "0e3d2533780a57a166a592e39877ec38" line_start: 1 line_end: 1 test_method: "testAddtwonumbers" - name: "Subtract two numbers" - hash: "4f81e69f94bf143662cdbfda5792b5b2" + hash: "89507263e188decdf877f5c18a9b0e00" line_start: 2 line_end: 2 test_method: "testSubtracttwonumbers" - name: "Multiply two numbers" - hash: "49fa6c2c733e007e6778ecf0b8667f06" + hash: "886e5f97f76b21df777b4dd82a31e340" line_start: 3 line_end: 3 test_method: "testMultiplytwonumbers" - name: "Divide two numbers" - hash: "056b8dd29e61014e9c2ac7891139811a" + hash: "01e179c7dc6a9dc309976f05d3e9cde0" line_start: 4 line_end: 4 test_method: "testDividetwonumbers" @@ -73,7 +73,7 @@ features: line_end: 1 test_method: "testScenario2" - path: "test/features/feature1.feature" - last_modified: "2025-05-14T19:42:37.000" + last_modified: "2025-05-15T21:50:07.000" test_file: "test/features/feature1.bdd_test.dart" scenarios: - name: "Scenario 1" diff --git a/example/test/calculator/calculator.bdd_scenarios.dart b/example/test/calculator/calculator.bdd_scenarios.dart index d044245..9f67870 100644 --- a/example/test/calculator/calculator.bdd_scenarios.dart +++ b/example/test/calculator/calculator.bdd_scenarios.dart @@ -5,6 +5,10 @@ class AddTwoNumbersScenario { // TODO: Implement Given I have the number 1 } + static Future iHaveTheNumber2(WidgetTester tester) async { + // TODO: Implement And I have the number 2 + } + static Future iAddThemTogether(WidgetTester tester) async { // TODO: Implement When I add them together } @@ -12,7 +16,6 @@ class AddTwoNumbersScenario { static Future theResultShouldBe3(WidgetTester tester) async { // TODO: Implement Then the result should be 3 } - } class Subtract { @@ -20,6 +23,10 @@ class Subtract { // TODO: Implement Given I have the number 5 } + static Future iHaveTheNumber3(WidgetTester tester) async { + // TODO: Implement And I have the number 3 + } + static Future iSubtractThem(WidgetTester tester) async { // TODO: Implement When I subtract them } @@ -27,7 +34,6 @@ class Subtract { static Future theResultShouldBe2(WidgetTester tester) async { // TODO: Implement Then the result should be 2 } - } class MultiplyTwoNumbersScenario { @@ -35,6 +41,10 @@ class MultiplyTwoNumbersScenario { // TODO: Implement Given I have the number 2 } + static Future iHaveTheNumber3(WidgetTester tester) async { + // TODO: Implement And I have the number 3 + } + static Future iMultiplyThem(WidgetTester tester) async { // TODO: Implement When I multiply them } @@ -42,21 +52,22 @@ class MultiplyTwoNumbersScenario { static Future theResultShouldBe6(WidgetTester tester) async { // TODO: Implement Then the result should be 6 } - } class DivideTwoNumbersScenario { - static Future iHaveTheNumber(WidgetTester tester, String number1) async { + static Future iHaveTheNumberNumber1(WidgetTester tester, String number1) async { // TODO: Implement Given I have the number } + static Future iHaveTheNumberNumber2(WidgetTester tester, String number2) async { + // TODO: Implement And I have the number + } + static Future iDivideThem(WidgetTester tester) async { // TODO: Implement When I divide them } - static Future theResultShouldBe(WidgetTester tester, String result) async { + static Future theResultShouldBeResult(WidgetTester tester, String result) async { // TODO: Implement Then the result should be } - } - diff --git a/example/test/calculator/calculator.bdd_test.dart b/example/test/calculator/calculator.bdd_test.dart index c4a9b2c..85bf95e 100644 --- a/example/test/calculator/calculator.bdd_test.dart +++ b/example/test/calculator/calculator.bdd_test.dart @@ -7,6 +7,8 @@ void main() { //Scenario: Add two numbers // Given I have the number 1 await AddTwoNumbersScenario.iHaveTheNumber1(tester); + // And I have the number 2 + await AddTwoNumbersScenario.iHaveTheNumber2(tester); // When I add them together await AddTwoNumbersScenario.iAddThemTogether(tester); // Then the result should be 3 @@ -16,6 +18,8 @@ void main() { //Scenario: Subtract two numbers // Given I have the number 5 await Subtract.iHaveTheNumber5(tester); + // And I have the number 3 + await Subtract.iHaveTheNumber3(tester); // When I subtract them await Subtract.iSubtractThem(tester); // Then the result should be 2 @@ -25,6 +29,8 @@ void main() { //Scenario: Multiply two numbers // Given I have the number 2 await MultiplyTwoNumbersScenario.iHaveTheNumber2(tester); + // And I have the number 3 + await MultiplyTwoNumbersScenario.iHaveTheNumber3(tester); // When I multiply them await MultiplyTwoNumbersScenario.iMultiplyThem(tester); // Then the result should be 6 @@ -39,11 +45,13 @@ void main() { ]; for (var example in examples) { // Given I have the number - await DivideTwoNumbersScenario.iHaveTheNumber(tester, example['number1']!); + await DivideTwoNumbersScenario.iHaveTheNumberNumber1(tester, example['number1']!); + // And I have the number + await DivideTwoNumbersScenario.iHaveTheNumberNumber2(tester, example['number2']!); // When I divide them await DivideTwoNumbersScenario.iDivideThem(tester); // Then the result should be - await DivideTwoNumbersScenario.theResultShouldBe(tester, example['result']!); + await DivideTwoNumbersScenario.theResultShouldBeResult(tester, example['result']!); } }); }); diff --git a/example/test/counter/counter.bdd_scenarios.dart b/example/test/counter/counter.bdd_scenarios.dart index eaeabd3..e9d1ff4 100644 --- a/example/test/counter/counter.bdd_scenarios.dart +++ b/example/test/counter/counter.bdd_scenarios.dart @@ -8,11 +8,11 @@ class CounterBackground { } class IncrementScenario { - static Future iIncrementTheCounterBy(WidgetTester tester, String value) async { + static Future iIncrementTheCounterByValue(WidgetTester tester, String value) async { // TODO: Implement When I increment the counter by } - static Future theCounterShouldHaveValue(WidgetTester tester, String expectedvalue) async { + static Future theCounterShouldHaveValueExpectedvalue(WidgetTester tester, String expectedvalue) async { // TODO: Implement Then the counter should have value } diff --git a/example/test/counter/counter.bdd_test.dart b/example/test/counter/counter.bdd_test.dart index fe19468..8684287 100644 --- a/example/test/counter/counter.bdd_test.dart +++ b/example/test/counter/counter.bdd_test.dart @@ -14,9 +14,9 @@ void main() { ]; for (var example in examples) { // When I increment the counter by - await IncrementScenario.iIncrementTheCounterBy(tester, example['value']!); + await IncrementScenario.iIncrementTheCounterByValue(tester, example['value']!); // Then the counter should have value - await IncrementScenario.theCounterShouldHaveValue(tester, example['expected_value']!); + await IncrementScenario.theCounterShouldHaveValueExpectedvalue(tester, example['expectedvalue']!); } }); }); diff --git a/example/test/sample/sample.bdd_scenarios.dart b/example/test/sample/sample.bdd_scenarios.dart index 06bd806..0c058ad 100644 --- a/example/test/sample/sample.bdd_scenarios.dart +++ b/example/test/sample/sample.bdd_scenarios.dart @@ -35,7 +35,7 @@ class CounterWithExamplesScenario { // TODO: Implement Given I have a counter } - static Future iIncrementThe(String counter) async { + static Future iIncrementTheCounter(String counter) async { // TODO: Implement When I increment the } @@ -50,11 +50,11 @@ class CounterWithParametersScenario { // TODO: Implement Given I have a counter } - static Future iIncrementTheCounter(String counter) async { + static Future iIncrementTheCounterCounter(String counter) async { // TODO: Implement When I increment the counter } - static Future iShouldSeeTheResult(String result) async { + static Future iShouldSeeTheResultResult(String result) async { // TODO: Implement Then I should see the result } diff --git a/example/test/sample/sample.bdd_test.dart b/example/test/sample/sample.bdd_test.dart index f209d71..c43c9f0 100644 --- a/example/test/sample/sample.bdd_test.dart +++ b/example/test/sample/sample.bdd_test.dart @@ -32,7 +32,7 @@ void main() { // Given I have a counter await CounterWithExamplesScenario.iHaveACounter(); // When I increment the - await CounterWithExamplesScenario.iIncrementThe( example['counter']!); + await CounterWithExamplesScenario.iIncrementTheCounter( example['counter']!); // Then I should see the counter incremented await CounterWithExamplesScenario.iShouldSeeTheCounterIncremented(); } @@ -48,9 +48,9 @@ void main() { // Given I have a counter await CounterWithParametersScenario.iHaveACounter(); // When I increment the counter - await CounterWithParametersScenario.iIncrementTheCounter( example['counter']!); + await CounterWithParametersScenario.iIncrementTheCounterCounter( example['counter']!); // Then I should see the result - await CounterWithParametersScenario.iShouldSeeTheResult( example['result']!); + await CounterWithParametersScenario.iShouldSeeTheResultResult( example['result']!); } }); testWidgets('Counter with widget test', (tester) async { diff --git a/lib/src/extensions/string_x.dart b/lib/src/extensions/string_x.dart index 4de0f71..c3eafa1 100644 --- a/lib/src/extensions/string_x.dart +++ b/lib/src/extensions/string_x.dart @@ -1,6 +1,6 @@ extension StringX on String { String get toMethodName { - final words = replaceAll(RegExp(r'<[^>]+>'), '').split(' '); + final words = replaceAll(RegExp(r'[^a-zA-Z0-9\s]'), '').split(' '); if (words.isEmpty) return ''; return words[0].toLowerCase() + diff --git a/lib/src/feature/builder/bdd_builders/bdd_feature_builder.dart b/lib/src/feature/builder/bdd_builders/bdd_feature_builder.dart index 1f2ec00..4a93fa0 100644 --- a/lib/src/feature/builder/bdd_builders/bdd_feature_builder.dart +++ b/lib/src/feature/builder/bdd_builders/bdd_feature_builder.dart @@ -11,7 +11,7 @@ class BDDFeatureBuilder { BDDFeatureBuilder({required this.options}); - Feature parseFeature(String featureContent, String fileName) { + Feature parseFeature(String featureContent) { final lines = featureContent.split('\n').map((line) => line.trim()).toList(); String? featureName; List scenarios = []; @@ -31,7 +31,7 @@ class BDDFeatureBuilder { featureName = line.substring('Feature:'.length).trim(); } else if (line.startsWith('@ignore')) { // If @ignore is found, return an empty feature to skip generation - return Feature('', [], fileName: fileName); + return Feature('', []); } else if (line.startsWith('Background:')) { background = Background( description: line.substring('Background:'.length).trim(), @@ -93,7 +93,7 @@ class BDDFeatureBuilder { currentScenarioName = line.substring('Scenario:'.length).trim(); currentExamples = null; exampleHeaders = null; - } else if (line.startsWith('Given') || line.startsWith('When') || line.startsWith('Then')) { + } else if (line.startsWith('Given') || line.startsWith('When') || line.startsWith('Then') || line.startsWith('And') || line.startsWith('But')) { final keyword = line.split(' ')[0]; final text = line.substring(keyword.length).trim(); currentSteps.add(Step(keyword, text)); @@ -140,7 +140,6 @@ class BDDFeatureBuilder { final feature = Feature( featureName, scenarios, - fileName: fileName, background: background, decorators: featureDecorators, ); diff --git a/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart b/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart index 18c43f6..e51a56a 100644 --- a/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart +++ b/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart @@ -16,7 +16,7 @@ class BDDTestFileBuilder { buffer.writeln("import 'package:bdd_flutter/bdd_flutter.dart';"); } - buffer.writeln("import '${feature.fileName}${FileExtension.generatedScenarios}';"); + buffer.writeln("import '${feature.fileName ?? feature.name.toSnakeCase}${FileExtension.generatedScenarios}';"); buffer.writeln(); buffer.writeln("void main() {"); @@ -83,7 +83,7 @@ class BDDTestFileBuilder { final params = []; for (var key in exampleKeys) { if (step.text.contains('<$key>')) { - params.add("example['$key']!"); + params.add("example['${key.snakeCaseToCamelCase}']!"); } } diff --git a/lib/src/feature/builder/domain/feature.dart b/lib/src/feature/builder/domain/feature.dart index 1240bbf..d14d2fe 100644 --- a/lib/src/feature/builder/domain/feature.dart +++ b/lib/src/feature/builder/domain/feature.dart @@ -15,13 +15,16 @@ class Feature { /// The background of the feature final Background? background; - final String fileName; + String? fileName; Feature( this.name, this.scenarios, { - required this.fileName, + this.fileName, this.decorators = const {}, this.background, }); + void setFileName(String value) { + this.fileName = value; + } } diff --git a/lib/src/runner/build_command.dart b/lib/src/runner/build_command.dart index bed3df9..0e4675c 100644 --- a/lib/src/runner/build_command.dart +++ b/lib/src/runner/build_command.dart @@ -1,11 +1,11 @@ +import 'dart:convert'; import 'dart:io'; + import 'package:bdd_flutter/src/feature/builder/bdd_builders/bdd_factory.dart'; import 'package:bdd_flutter/src/feature/builder/domain/bdd_options.dart'; import 'package:bdd_flutter/src/feature/builder/domain/feature.dart'; import 'package:bdd_flutter/src/feature/builder/domain/manifest.dart'; import 'package:crypto/crypto.dart'; -import 'dart:convert'; -import 'package:yaml/yaml.dart'; import '../constraints/file_extenstion.dart'; import '../extensions/string_x.dart'; @@ -58,7 +58,9 @@ class BuildCommand { final featureFileName = featurePath.split('/').last.replaceAll('.feature', ''); final featureContent = featureFile.readAsStringSync(); - final parsedFeature = factory.featureBuilder.parseFeature(featureContent, featureFileName); + final parsedFeature = await factory.featureBuilder.parseFeature(featureContent) + ..setFileName(featureFileName); + final lastModified = featureFile.lastModifiedSync(); // Get paths for generated files diff --git a/pubspec.yaml b/pubspec.yaml index 2b4ad1c..fd713db 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,3 +19,5 @@ dependencies: dev_dependencies: lints: ^2.0.0 test: ^1.24.0 + flutter_test: + sdk: flutter diff --git a/test/feature_builder/bdd_flutter_test.dart b/test/feature_builder/bdd_flutter_test.dart deleted file mode 100644 index 8af323c..0000000 --- a/test/feature_builder/bdd_flutter_test.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:bdd_flutter/src/feature/builder/bdd_builders/bdd_feature_builder.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/bdd_options.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/decorator.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/scenario.dart'; -import 'package:test/test.dart'; - -void main() { - final featureBuilder = BDDFeatureBuilder( - options: BDDOptions(generateWidgetTests: true), - ); - group('Parse feature file with default options', () { - test('widget test by default', () { - const featureContent = ''' -Feature: Comms Permissions - Scenario: Create a direct channel with permission - Given a user "comms:channel:direct:create" permission - When user attempts to create a direct channel - Then - Examples: - | has_permission | expected_result | - | true | shows the create new DM chat button | - | false | hides the create new DM chat button | - '''; - final feature = featureBuilder.parseFeature(featureContent); - expect(feature.name, equals('Comms Permissions')); - expect(feature.scenarios.length, equals(1)); - final scenario = feature.scenarios.first; - expect(scenario.isWidgetTest, isTrue); - - expect(scenario.steps.length, equals(3)); - expect(scenario.examples?.length, equals(2)); - }); - - test('feature decorator override default', () { - const featureContent = ''' -@unitTest -Feature: Comms Permissions - Scenario: Create a direct channel with permission - Given a user "comms:channel:direct:create" permission - When user attempts to create a direct channel - Then - '''; - final feature = featureBuilder.parseFeature(featureContent); - expect(feature.name, equals('Comms Permissions')); - expect(feature.scenarios.length, equals(1)); - expect(feature.decorators, contains(BDDDecorator.unitTest())); - expect(feature.scenarios[0].decorators, contains(BDDDecorator.unitTest())); - }); - test('scenario decorator override feature decorator', () { - const featureContent = ''' -@unitTest -Feature: Comms Permissions - @widgetTest - Scenario: Create a direct channel with permission - Given a user "comms:channel:direct:create" permission - When user attempts to create a direct channel - Then - '''; - final feature = featureBuilder.parseFeature(featureContent); - expect(feature.name, equals('Comms Permissions')); - expect(feature.scenarios.length, equals(1)); - expect(feature.decorators, contains(BDDDecorator.unitTest())); - expect( - feature.scenarios.first.decorators, - contains(BDDDecorator.widgetTest()), - ); - }); - }); -} diff --git a/test/feature_builder/build_scenarios_file_test.dart b/test/feature_builder/build_scenarios_file_test.dart new file mode 100644 index 0000000..6a3292e --- /dev/null +++ b/test/feature_builder/build_scenarios_file_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:bdd_flutter/src/feature/builder/bdd_builders/scenario_file_builder.dart'; +import 'package:bdd_flutter/src/feature/builder/domain/feature.dart'; +import 'package:bdd_flutter/src/feature/builder/domain/scenario.dart'; +import 'package:bdd_flutter/src/feature/builder/domain/step.dart'; +import 'package:bdd_flutter/src/feature/builder/domain/background.dart'; +import 'package:bdd_flutter/src/feature/builder/domain/decorator.dart'; + +void main() { + group('ScenariosFileBuilder', () { + late ScenariosFileBuilder builder; + + setUp(() { + builder = ScenariosFileBuilder(); + }); + + test('builds scenario file with basic scenario', () async { + final feature = Feature( + 'Test Feature', + [ + Scenario( + 'Basic', + [ + Step('Given', 'I have a counter'), + Step('When', 'I increment the counter'), + Step('Then', 'I should see the counter incremented'), + ], + ), + ], + ); + + final result = await builder.buildScenarioFile(feature); + + expect(result, contains("import 'package:flutter_test/flutter_test.dart';")); + expect(result, contains('class BasicScenario {')); + expect(result, contains('static Future iHaveACounter(WidgetTester tester) async {')); + expect(result, contains('static Future iIncrementTheCounter(WidgetTester tester) async {')); + expect(result, contains('static Future iShouldSeeTheCounterIncremented(WidgetTester tester) async {')); + }); + + test('builds scenario file with background', () async { + final feature = Feature( + 'Test', + [ + Scenario( + 'Basic', + [ + Step('Given', 'I have a counter'), + Step('When', 'I increment the counter'), + Step('Then', 'I should see the counter incremented'), + ], + ), + ], + background: Background( + description: 'Background steps', + steps: [ + Step('Given', 'I am on the home page'), + Step('And', 'I am logged in'), + ], + ), + ); + + final result = await builder.buildScenarioFile(feature); + + expect(result, contains('class TestBackground {')); + expect(result, contains('static Future iAmOnTheHomePage() async {')); + expect(result, contains('static Future iAmLoggedIn() async {')); + }); + + test('builds scenario file with unit test scenario', () async { + final feature = Feature( + 'Test Feature', + [ + Scenario( + 'Unit Test', + [ + Step('Given', 'I have a counter'), + Step('When', 'I increment the counter'), + Step('Then', 'I should see the counter incremented'), + ], + decorators: {BDDDecorator.unitTest()}, + ), + ], + ); + + final result = await builder.buildScenarioFile(feature); + + expect(result, contains('class UnitTestScenario {')); + expect(result, contains('static Future iHaveACounter() async {')); + expect(result, contains('static Future iIncrementTheCounter() async {')); + expect(result, contains('static Future iShouldSeeTheCounterIncremented() async {')); + }); + + test('builds scenario file with scenario containing parameters', () async { + final feature = Feature( + 'Test', + [ + Scenario( + 'Parameter', + [ + Step('Given', 'I have a counter'), + Step('When', 'I increment the counter by '), + Step('Then', 'I should see the counter at '), + ], + ), + ], + ); + + final result = await builder.buildScenarioFile(feature); + + expect(result, contains('class ParameterScenario {')); + expect(result, contains('static Future iHaveACounter(WidgetTester tester) async {')); + expect(result, contains('static Future iIncrementTheCounterBy(WidgetTester tester, String amount) async {')); + expect(result, contains('static Future iShouldSeeTheCounterAt(WidgetTester tester, String result) async {')); + }); + + test('builds scenario file with numeric parameters', () async { + final feature = Feature( + 'Test', + [ + Scenario( + 'Numeric Parameters', + [ + Step('Given', 'I have the number '), + Step('And', 'I have the number '), + Step('When', 'I divide them'), + Step('Then', 'the result should be '), + ], + ), + ], + ); + + final result = await builder.buildScenarioFile(feature); + + expect(result, contains('class NumericParametersScenario {')); + expect(result, contains('static Future iHaveTheNumberNumber1(WidgetTester tester, String number1) async {')); + expect(result, contains('static Future iHaveTheNumberNumber2(WidgetTester tester, String number2) async {')); + expect(result, contains('static Future iDivideThem(WidgetTester tester) async {')); + expect(result, contains('static Future theResultShouldBeResult(WidgetTester tester, String result) async {')); + }); + }); +} diff --git a/test/feature_builder/build_test_file_test.dart b/test/feature_builder/build_test_file_test.dart new file mode 100644 index 0000000..bdb47ce --- /dev/null +++ b/test/feature_builder/build_test_file_test.dart @@ -0,0 +1,217 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:bdd_flutter/src/feature/builder/bdd_builders/bdd_test_file_builder.dart'; +import 'package:bdd_flutter/src/feature/builder/domain/feature.dart'; +import 'package:bdd_flutter/src/feature/builder/domain/scenario.dart'; +import 'package:bdd_flutter/src/feature/builder/domain/step.dart'; +import 'package:bdd_flutter/src/feature/builder/domain/background.dart'; +import 'package:bdd_flutter/src/feature/builder/domain/decorator.dart'; + +void main() { + group('BDDTestFileBuilder', () { + late BDDTestFileBuilder builder; + + setUp(() { + builder = BDDTestFileBuilder(); + }); + + test('builds test file with basic scenario', () async { + final feature = Feature( + 'Test', + fileName: 'test', + [ + Scenario( + 'Basic', + [ + Step('Given', 'I have a counter'), + Step('When', 'I increment the counter'), + Step('Then', 'I should see the counter incremented'), + ], + ), + ], + ); + + final result = await builder.buildTestFile(feature); + + expect(result, contains("import 'package:flutter_test/flutter_test.dart';")); + expect(result, contains("import 'test.bdd_scenarios.dart';")); + expect(result, contains("void main() {")); + expect(result, contains("group('Test', () {")); + expect(result, contains("testWidgets('Basic', (tester) async {")); + expect(result, contains("BasicScenario.iHaveACounter(tester);")); + expect(result, contains("BasicScenario.iIncrementTheCounter(tester);")); + expect(result, contains("BasicScenario.iShouldSeeTheCounterIncremented(tester);")); + }); + + test('builds test file with background', () async { + final feature = Feature( + 'Test', + [ + Scenario( + 'Basic', + [ + Step('Given', 'I have a counter'), + Step('When', 'I increment the counter'), + Step('Then', 'I should see the counter incremented'), + ], + ), + ], + background: Background( + description: 'Background steps', + steps: [ + Step('Given', 'I am on the home page'), + Step('And', 'I am logged in'), + ], + ), + ); + + final result = await builder.buildTestFile(feature); + + expect(result, contains("//Background: Background steps")); + expect(result, contains("TestBackground.iAmOnTheHomePage();")); + expect(result, contains("TestBackground.iAmLoggedIn();")); + }); + + test('builds test file with unit test scenario', () async { + final feature = Feature( + 'Test', + [ + Scenario( + 'Unit Test', + [ + Step('Given', 'I have a counter'), + Step('When', 'I increment the counter'), + Step('Then', 'I should see the counter incremented'), + ], + decorators: {BDDDecorator.unitTest()}, + ), + ], + ); + + final result = await builder.buildTestFile(feature); + + expect(result, contains("test('Unit Test', () async {")); + expect(result, contains("UnitTestScenario.iHaveACounter();")); + expect(result, contains("UnitTestScenario.iIncrementTheCounter();")); + expect(result, contains("UnitTestScenario.iShouldSeeTheCounterIncremented();")); + }); + + test('builds test file with scenario containing examples', () async { + final feature = Feature( + 'Test', + [ + Scenario( + 'Parameterized', + [ + Step('Given', 'I have a counter'), + Step('When', 'I increment the counter by '), + Step('Then', 'I should see the counter at '), + ], + examples: [ + {'amount': '1', 'result': '1'}, + {'amount': '2', 'result': '2'}, + ], + ), + ], + ); + + final result = await builder.buildTestFile(feature); + + expect(result, contains("final examples = [")); + expect(result, contains("for (var example in examples) {")); + expect(result, contains("ParameterizedScenario.iIncrementTheCounterBy(tester, example['amount']!);")); + expect(result, contains("ParameterizedScenario.iShouldSeeTheCounterAt(tester, example['result']!);")); + }); + + test('builds test file with reporter enabled', () async { + final feature = Feature( + 'Test Feature', + [ + Scenario( + 'Basic', + [ + Step('Given', 'I have a counter'), + Step('When', 'I increment the counter'), + Step('Then', 'I should see the counter incremented'), + ], + ), + ], + decorators: {BDDDecorator.enableReporter()}, + ); + + final result = await builder.buildTestFile(feature); + + expect(result, contains("import 'package:bdd_flutter/bdd_flutter.dart';")); + expect(result, contains("final reporter = BDDTestReporter(featureName: 'Test Feature');")); + expect(result, contains("setUpAll(() {")); + expect(result, contains("reporter.testStarted(); // start recording")); + expect(result, contains("tearDownAll(() {")); + expect(result, contains("reporter.testFinished(); // stop recording")); + expect(result, contains("reporter.printReport(); // print report")); + expect(result, contains("reporter.startScenario('Basic');")); + }); + + test('builds test file with multiple scenarios', () async { + final feature = Feature( + 'Test', + [ + Scenario( + 'First', + [ + Step('Given', 'I have a counter'), + Step('When', 'I increment the counter'), + Step('Then', 'I should see the counter incremented'), + ], + ), + Scenario( + 'Second', + [ + Step('Given', 'I have a counter'), + Step('When', 'I decrement the counter'), + Step('Then', 'I should see the counter decremented'), + ], + ), + ], + ); + + final result = await builder.buildTestFile(feature); + + expect(result, contains("testWidgets('First', (tester) async {")); + expect(result, contains("testWidgets('Second', (tester) async {")); + expect(result, contains("FirstScenario.iHaveACounter(tester);")); + expect(result, contains("SecondScenario.iHaveACounter(tester);")); + expect(result, contains("SecondScenario.iDecrementTheCounter(tester);")); + expect(result, contains("SecondScenario.iShouldSeeTheCounterDecremented(tester);")); + }); + + test('builds test file with numeric parameters', () async { + final feature = Feature( + 'Test', + [ + Scenario( + 'Numeric Parameters', + [ + Step('Given', 'I have the number '), + Step('And', 'I have the number '), + Step('When', 'I divide them'), + Step('Then', 'the result should be '), + ], + examples: [ + {'number1': '10', 'number2': '2', 'result': '5'}, + {'number1': '20', 'number2': '4', 'result': '5'}, + ], + ), + ], + ); + + final result = await builder.buildTestFile(feature); + + expect(result, contains("testWidgets('Numeric Parameters', (tester) async {")); + expect(result, contains("final examples = [")); + expect(result, contains("for (var example in examples) {")); + expect(result, contains("NumericParametersScenario.iHaveTheNumberNumber1(tester, example['number1']!);")); + expect(result, contains("NumericParametersScenario.iHaveTheNumberNumber2(tester, example['number2']!);")); + expect(result, contains("NumericParametersScenario.iDivideThem(tester);")); + expect(result, contains("NumericParametersScenario.theResultShouldBeResult(tester, example['result']!);")); + }); + }); +} diff --git a/test/feature_builder/nested_decorators_test.dart b/test/feature_builder/nested_decorators_test.dart deleted file mode 100644 index 7c352da..0000000 --- a/test/feature_builder/nested_decorators_test.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:bdd_flutter/src/feature/builder/bdd_builders/bdd_feature_builder.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/bdd_options.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/decorator.dart'; -import 'package:test/test.dart'; - -void main() { - test('Builder disabled reporter', () { - final feature1Text = ''' -Feature: Feature 1 - Scenario: Scenario 1 - Given I have a feature 1 - When I do something - Then I should see a feature 1 - Scenario: Scenario 2 - Given I have a feature 2 - When I do something - Then I should see a feature 2 -'''; - - final feature2Text = ''' -@enableReporter -Feature: Feature 2 - Scenario: Scenario 2 - Given I have a feature 2 - When I do something - Then I should see a feature 2 - Scenario: Scenario 3 - Given I have a feature 3 - When I do something - Then I should see a feature 3 -'''; - final featureBuilder = BDDFeatureBuilder( - options: BDDOptions(generateWidgetTests: true), - ); - final feature1 = featureBuilder.parseFeature(feature1Text)!; - final feature2 = featureBuilder.parseFeature(feature2Text)!; - expect(feature1.name, 'Feature 1'); - expect(feature1.scenarios.length, 2); - expect(feature1.decorators.hasEnableReporter, false); - - expect(feature2.name, 'Feature 2'); - expect(feature2.decorators.hasEnableReporter, true); - expect(feature2.scenarios.length, 2); - }); - test('Builder enabled reporter', () { - final feature1Text = ''' -Feature: Feature 1 - Scenario: Scenario 1 - Given I have a feature 1 - When I do something - Then I should see a feature 1 - Scenario: Scenario 2 - Given I have a feature 2 - When I do something - Then I should see a feature 2 -'''; - - final feature2Text = ''' -@disableReporter -Feature: Feature 2 - Scenario: Scenario 2 - Given I have a feature 2 - When I do something - Then I should see a feature 2 - Scenario: Scenario 3 - Given I have a feature 3 - When I do something - Then I should see a feature 3 -'''; - final featureBuilder = BDDFeatureBuilder( - options: BDDOptions( - generateWidgetTests: true, - enableReporter: true, - ), - ); - final feature1 = featureBuilder.parseFeature(feature1Text)!; - final feature2 = featureBuilder.parseFeature(feature2Text)!; - expect(feature1.name, 'Feature 1'); - expect(feature1.scenarios.length, 2); - expect(feature1.decorators.hasEnableReporter, true); - - expect(feature2.name, 'Feature 2'); - expect(feature2.decorators.hasEnableReporter, false); - expect(feature2.scenarios.length, 2); - }); -} diff --git a/test/feature_builder/parse_feature_test.dart b/test/feature_builder/parse_feature_test.dart new file mode 100644 index 0000000..02206b2 --- /dev/null +++ b/test/feature_builder/parse_feature_test.dart @@ -0,0 +1,192 @@ +import 'package:bdd_flutter/src/feature/builder/bdd_builders/bdd_feature_builder.dart'; +import 'package:bdd_flutter/src/feature/builder/domain/bdd_options.dart'; +import 'package:bdd_flutter/src/feature/builder/domain/decorator.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late BDDFeatureBuilder featureBuilder; + + setUp(() { + featureBuilder = BDDFeatureBuilder(options: BDDOptions.defaultOptions()); + }); + + group('parseFeature', () { + test('should parse basic feature with single scenario', () { + const featureContent = ''' +Feature: Login functionality + Scenario: Successful login + Given I am on the login page + When I enter valid credentials + Then I should be logged in successfully +'''; + + final feature = featureBuilder.parseFeature(featureContent); + + expect(feature.name, equals('Login functionality')); + expect(feature.scenarios.length, equals(1)); + expect(feature.scenarios[0].name, equals('Successful login')); + expect(feature.scenarios[0].steps.length, equals(3)); + expect(feature.scenarios[0].steps[0].keyword, equals('Given')); + expect(feature.scenarios[0].steps[0].text, equals('I am on the login page')); + }); + + test('should parse feature with decorators', () { + const featureContent = ''' +@enableReporter +Feature: User registration + @unitTest + Scenario: Register with valid data + Given I am on the registration page + When I fill in valid registration data + Then I should be registered successfully +'''; + + final feature = featureBuilder.parseFeature(featureContent); + + expect(feature.name, equals('User registration')); + expect(feature.decorators.length, equals(1)); + expect(feature.decorators.contains(BDDDecorator.enableReporter()), isTrue); + expect(feature.scenarios[0].decorators.contains(BDDDecorator.unitTest()), isTrue); + }); + + test('should parse feature with examples', () { + const featureContent = ''' +Feature: Calculator + Scenario: Add two numbers + Given I have entered into the calculator + And I have entered into the calculator + When I press add + Then the result should be + + Examples: + | number1 | number2 | result | + | 1 | 2 | 3 | + | 5 | 5 | 10 | +'''; + + final feature = featureBuilder.parseFeature(featureContent); + + expect(feature.name, equals('Calculator')); + expect(feature.scenarios.length, equals(1)); + expect(feature.scenarios[0].examples, isNotNull); + expect(feature.scenarios[0].examples!.length, equals(2)); + expect(feature.scenarios[0].examples![0]['number1'], equals('1')); + expect(feature.scenarios[0].examples![0]['result'], equals('3')); + }); + + test('should parse feature with multiple scenarios', () { + const featureContent = ''' +Feature: Search functionality + Scenario: Search with valid keyword + Given I am on the search page + When I enter a valid search term + Then I should see relevant results + + Scenario: Search with empty keyword + Given I am on the search page + When I enter an empty search term + Then I should see an error message +'''; + + final feature = featureBuilder.parseFeature(featureContent); + + expect(feature.name, equals('Search functionality')); + expect(feature.scenarios.length, equals(2)); + expect(feature.scenarios[0].name, equals('Search with valid keyword')); + expect(feature.scenarios[1].name, equals('Search with empty keyword')); + }); + + test('should throw exception when no feature is defined', () { + const featureContent = ''' +Scenario: Invalid feature file + Given some precondition + When some action + Then some result +'''; + + expect( + () => featureBuilder.parseFeature(featureContent), + throwsException, + ); + }); + + test('should handle @ignore decorator', () { + const featureContent = ''' +@ignore +Feature: Ignored feature + Scenario: This should be ignored + Given some precondition + When some action + Then some result +'''; + + final feature = featureBuilder.parseFeature(featureContent); + expect(feature.name, isEmpty); + expect(feature.scenarios, isEmpty); + }); + + test('should handle empty feature content', () { + const featureContent = ''; + + expect( + () => featureBuilder.parseFeature(featureContent), + throwsException, + ); + }); + + test('should handle feature with only whitespace', () { + const featureContent = ' \n \t '; + + expect( + () => featureBuilder.parseFeature(featureContent), + throwsException, + ); + }); + + test('should handle scenario with And/But steps', () { + const featureContent = ''' +Feature: Complex steps + Scenario: Using And/But steps + Given I am on the page + And I am logged in + When I click the button + But I wait for 2 seconds + Then I should see the result + And the result should be correct +'''; + + final feature = featureBuilder.parseFeature(featureContent); + expect(feature.scenarios[0].steps.length, equals(6)); + expect(feature.scenarios[0].steps[1].keyword, equals('And')); + expect(feature.scenarios[0].steps[3].keyword, equals('But')); + }); + + test('should handle scenario with empty steps', () { + const featureContent = ''' +Feature: Empty steps + Scenario: Empty step scenario + Given + When + Then +'''; + + final feature = featureBuilder.parseFeature(featureContent); + expect(feature.scenarios[0].steps.length, equals(3)); + expect(feature.scenarios[0].steps[0].text, isEmpty); + }); + + test('should handle feature with special characters in names', () { + const featureContent = ''' +Feature: Special @#\$%^&*() characters + Scenario: Test with special chars !@#\$%^&*() + Given I have special chars + When I process them + Then they should be handled correctly +'''; + + final feature = featureBuilder.parseFeature(featureContent); + expect(feature.name, equals('Special @#\$%^&*() characters')); + expect(feature.scenarios[0].name, equals('Test with special chars !@#\$%^&*()')); + }); + }); +} diff --git a/test/runner/flag_parser_test.dart b/test/runner/flag_parser_test.dart new file mode 100644 index 0000000..cd24245 --- /dev/null +++ b/test/runner/flag_parser_test.dart @@ -0,0 +1,93 @@ +import 'package:bdd_flutter/src/runner/domain/cmd_flag.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('CmdFlag', () { + test('should create flag with correct properties', () { + const flag = CmdFlag('--test', '-t', 'Test flag'); + expect(flag.longForm, equals('--test')); + expect(flag.shortForm, equals('-t')); + expect(flag.description, equals('Test flag')); + }); + + test('should create copy with modified properties', () { + const original = CmdFlag('--test', '-t', 'Test flag'); + final copy = original.copyWith( + text: '--new', + shortText: '-n', + description: 'New flag', + ); + expect(copy.longForm, equals('--new')); + expect(copy.shortForm, equals('-n')); + expect(copy.description, equals('New flag')); + }); + + test('should maintain original properties when copying with null values', () { + const original = CmdFlag('--test', '-t', 'Test flag'); + final copy = original.copyWith(); + expect(copy.longForm, equals(original.longForm)); + expect(copy.shortForm, equals(original.shortForm)); + expect(copy.description, equals(original.description)); + }); + + test('should convert to string correctly', () { + const flag = CmdFlag('--test', '-t', 'Test flag'); + expect(flag.toString(), equals('--test, -t, Test flag')); + }); + }); + + group('CmdFlag.fromString', () { + test('should parse valid long form flag', () { + final flag = CmdFlag.fromString('--help'); + expect(flag, equals(CmdFlag.help)); + }); + + test('should parse valid short form flag', () { + final flag = CmdFlag.fromString('-h'); + expect(flag, equals(CmdFlag.help)); + }); + + test('should return invalid flag for unknown input', () { + final flag = CmdFlag.fromString('--unknown'); + expect(flag.longForm, equals('--unknown')); + expect(flag.shortForm, equals('--unknown')); + expect(flag.description, equals('Invalid flag')); + }); + + test('should parse all predefined flags', () { + for (final flag in CmdFlag.values) { + expect(CmdFlag.fromString(flag.longForm), equals(flag)); + expect(CmdFlag.fromString(flag.shortForm), equals(flag)); + } + }); + }); + + group('CmdFlagValidator', () { + test('should validate valid flags', () { + final flags = [CmdFlag.help, CmdFlag.unitTest]; + expect(CmdFlagValidator.validate(flags), isNull); + }); + + test('should detect invalid flag', () { + final flags = [CmdFlag.help, CmdFlag('--invalid', '-i', 'Invalid flag')]; + final error = CmdFlagValidator.validate(flags); + expect(error, isNotNull); + expect(error, contains('Invalid flag')); + }); + + test('should detect mutually exclusive flags', () { + final flags = [CmdFlag.newOnly, CmdFlag.force]; + final error = CmdFlagValidator.validate(flags); + expect(error, isNotNull); + expect(error, contains('Cannot use --new with --force')); + }); + + test('should validate empty flag list', () { + expect(CmdFlagValidator.validate([]), isNull); + }); + + test('should validate single valid flag', () { + expect(CmdFlagValidator.validate([CmdFlag.help]), isNull); + }); + }); +} From 2fcd52206b035324185af7e250391f666a6312c8 Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Mon, 19 May 2025 16:19:23 -0600 Subject: [PATCH 10/24] fix manifest builder --- example/.bdd_flutter/manifest.yaml | 109 +++++++-------- .../calculator/calculator.bdd_scenarios.dart | 39 ++++++ .../test/calculator/calculator.bdd_test.dart | 27 ++++ example/test/calculator/calculator.feature | 17 +++ .../test/counter/counter.bdd_scenarios.dart | 2 +- example/test/counter/counter.bdd_test.dart | 2 +- .../test/features/feature2.bdd_scenarios.dart | 15 +++ example/test/features/feature2.bdd_test.dart | 9 ++ example/test/features/feature2.feature | 6 + lib/src/extensions/list_x.dart | 8 ++ lib/src/extensions/string_x.dart | 14 -- .../bdd_builders/bdd_test_file_builder.dart | 4 +- .../bdd_builders/scenario_file_builder.dart | 6 +- lib/src/feature/builder/domain/manifest.dart | 16 +++ lib/src/feature/builder/domain/step.dart | 29 ++++ lib/src/runner/build_command.dart | 125 +++++++++++++----- lib/src/runner/command_parser.dart | 1 - lib/src/runner/update_manifest_cmd.dart | 1 + 18 files changed, 314 insertions(+), 116 deletions(-) create mode 100644 lib/src/extensions/list_x.dart create mode 100644 lib/src/runner/update_manifest_cmd.dart diff --git a/example/.bdd_flutter/manifest.yaml b/example/.bdd_flutter/manifest.yaml index 7866558..9cff370 100644 --- a/example/.bdd_flutter/manifest.yaml +++ b/example/.bdd_flutter/manifest.yaml @@ -1,92 +1,77 @@ version: "1.0" -last_generated: "2025-05-15T22:06:39.936688" +last_generated: "2025-05-19T16:18:21.590816" features: - path: "test/calculator/calculator.feature" - last_modified: "2025-05-11T21:39:21.000" + last_modified: "2025-05-16T22:11:21.000" test_file: "test/calculator/calculator.bdd_test.dart" scenarios: - name: "Add two numbers" - hash: "0e3d2533780a57a166a592e39877ec38" - line_start: 1 - line_end: 1 + hash: "0a0e99eea6929a8ae27c1d95a88b293d" + line_start: 2 + line_end: 8 test_method: "testAddtwonumbers" - name: "Subtract two numbers" - hash: "89507263e188decdf877f5c18a9b0e00" - line_start: 2 - line_end: 2 + hash: "ccc195782104eafe6ae0987cb7435146" + line_start: 9 + line_end: 14 test_method: "testSubtracttwonumbers" + - name: "Subtract two numbers2" + hash: "58ce85c7cb7cc7c2453e862ebb12e20b" + line_start: 15 + line_end: 20 + test_method: "testSubtracttwonumbers2" - name: "Multiply two numbers" - hash: "886e5f97f76b21df777b4dd82a31e340" - line_start: 3 - line_end: 3 + hash: "560cbb5eedd961771237ba5d9ae3e7a3" + line_start: 21 + line_end: 26 test_method: "testMultiplytwonumbers" - name: "Divide two numbers" - hash: "01e179c7dc6a9dc309976f05d3e9cde0" - line_start: 4 - line_end: 4 + hash: "e85196f20fc91d407521ee37fcad2320" + line_start: 27 + line_end: 38 test_method: "testDividetwonumbers" + - path: "test/features/feature2.feature" + last_modified: "2025-05-19T14:28:03.000" + test_file: "test/features/feature2.bdd_test.dart" + scenarios: + - name: "Scenario 2" + hash: "5084b8f7e6886a111ed0bbc7e953f6fa" + line_start: 2 + line_end: 6 + test_method: "testScenario2" + - path: "test/features/feature3.feature" + last_modified: "2025-05-14T21:04:20.000" + test_file: "test/features/feature3.bdd_test.dart" + scenarios: + - path: "test/features/feature1.feature" + last_modified: "2025-05-15T21:50:07.000" + test_file: "test/features/feature1.bdd_test.dart" + scenarios: - path: "test/sample/sample.feature" last_modified: "2025-05-11T21:52:29.000" test_file: "test/sample/sample.bdd_test.dart" scenarios: - name: "Sample" - hash: "fa45830159f6abbf09247347da169137" + hash: "56586753878cd78f2da7966b21643465" line_start: 1 - line_end: 1 + line_end: 6 test_method: "testSample" - name: "Counter" - hash: "d90c903ae242400fad736462bcadb23e" - line_start: 2 - line_end: 2 + hash: "c5ecf1c5f3ca86df90f11ed5108056d6" + line_start: 7 + line_end: 12 test_method: "testCounter" - name: "Counter with examples" - hash: "1a64a7fa506fc48ca40b619a629baea2" - line_start: 3 - line_end: 3 + hash: "cce46f3313000a10062f248e0bf771e3" + line_start: 13 + line_end: 23 test_method: "testCounterwithexamples" - name: "Counter with parameters" - hash: "ecfa8df8c75b022f1d8802511b2ab8ff" - line_start: 4 - line_end: 4 + hash: "3bb4729b114ac6188f9f3993f4049793" + line_start: 24 + line_end: 33 test_method: "testCounterwithparameters" - - name: "Counter with widget test" - hash: "b04f089730bac2859f0c344456448471" - line_start: 5 - line_end: 5 - test_method: "testCounterwithwidgettest" - path: "test/counter/counter.feature" last_modified: "2025-05-04T10:27:15.000" test_file: "test/counter/counter.bdd_test.dart" scenarios: - - name: "Increment" - hash: "f71a054d6eca6e81f1147356ab2a7ea8" - line_start: 1 - line_end: 1 - test_method: "testIncrement" - - path: "test/features/feature2.feature" - last_modified: "2025-05-14T19:40:21.000" - test_file: "test/features/feature2.bdd_test.dart" - scenarios: - - name: "Scenario 2" - hash: "d3ee4877a2639700318fa5b1a03d6db6" - line_start: 1 - line_end: 1 - test_method: "testScenario2" - - path: "test/features/feature1.feature" - last_modified: "2025-05-15T21:50:07.000" - test_file: "test/features/feature1.bdd_test.dart" - scenarios: - - name: "Scenario 1" - hash: "6ef26f58bf820d71a2955a1e96358e04" - line_start: 1 - line_end: 1 - test_method: "testScenario1" - - path: "test/features/feature3.feature" - last_modified: "2025-05-14T21:04:20.000" - test_file: "test/features/feature3.bdd_test.dart" - scenarios: - - name: "Scenario 3" - hash: "8c18f46a7758127ba8c8cb90ab8839d0" - line_start: 1 - line_end: 1 - test_method: "testScenario3" diff --git a/example/test/calculator/calculator.bdd_scenarios.dart b/example/test/calculator/calculator.bdd_scenarios.dart index 9f67870..7b012b4 100644 --- a/example/test/calculator/calculator.bdd_scenarios.dart +++ b/example/test/calculator/calculator.bdd_scenarios.dart @@ -16,6 +16,7 @@ class AddTwoNumbersScenario { static Future theResultShouldBe3(WidgetTester tester) async { // TODO: Implement Then the result should be 3 } + } class Subtract { @@ -34,6 +35,26 @@ class Subtract { static Future theResultShouldBe2(WidgetTester tester) async { // TODO: Implement Then the result should be 2 } + +} + +class SubtractTwoNumbers2Scenario { + static Future iHaveTheNumber6(WidgetTester tester) async { + // TODO: Implement Given I have the number 6 + } + + static Future iHaveTheNumber8(WidgetTester tester) async { + // TODO: Implement And I have the number 8 + } + + static Future iSubtractThem(WidgetTester tester) async { + // TODO: Implement When I subtract them + } + + static Future theResultShouldBe2(WidgetTester tester) async { + // TODO: Implement Then the result should be -2 + } + } class MultiplyTwoNumbersScenario { @@ -52,6 +73,7 @@ class MultiplyTwoNumbersScenario { static Future theResultShouldBe6(WidgetTester tester) async { // TODO: Implement Then the result should be 6 } + } class DivideTwoNumbersScenario { @@ -70,4 +92,21 @@ class DivideTwoNumbersScenario { static Future theResultShouldBeResult(WidgetTester tester, String result) async { // TODO: Implement Then the result should be } + +} + +class DivideTwoNumbers2Scenario { + static Future iHaveNumber1AndNumber2(WidgetTester tester, String number1, String number2) async { + // TODO: Implement Given I have and + } + + static Future iDivideThemToEachOther(WidgetTester tester) async { + // TODO: Implement When I divide them to each other + } + + static Future theResultShouldBeResult(WidgetTester tester, String result) async { + // TODO: Implement Then the result should be + } + } + diff --git a/example/test/calculator/calculator.bdd_test.dart b/example/test/calculator/calculator.bdd_test.dart index 85bf95e..4f1515e 100644 --- a/example/test/calculator/calculator.bdd_test.dart +++ b/example/test/calculator/calculator.bdd_test.dart @@ -25,6 +25,17 @@ void main() { // Then the result should be 2 await Subtract.theResultShouldBe2(tester); }); + testWidgets('Subtract two numbers2', (tester) async { + //Scenario: Subtract two numbers2 + // Given I have the number 6 + await SubtractTwoNumbers2Scenario.iHaveTheNumber6(tester); + // And I have the number 8 + await SubtractTwoNumbers2Scenario.iHaveTheNumber8(tester); + // When I subtract them + await SubtractTwoNumbers2Scenario.iSubtractThem(tester); + // Then the result should be -2 + await SubtractTwoNumbers2Scenario.theResultShouldBe2(tester); + }); testWidgets('Multiply two numbers', (tester) async { //Scenario: Multiply two numbers // Given I have the number 2 @@ -54,5 +65,21 @@ void main() { await DivideTwoNumbersScenario.theResultShouldBeResult(tester, example['result']!); } }); + testWidgets('Divide two numbers2', (tester) async { + //Scenario: Divide two numbers2 + final examples = [ + {'number1': '10','number2': '2','result': '5',}, + {'number1': '10','number2': '1','result': '10',}, + {'number1': '10','number2': '10','result': '1',}, + ]; + for (var example in examples) { + // Given I have and + await DivideTwoNumbers2Scenario.iHaveNumber1AndNumber2(tester, example['number1']!, example['number2']!); + // When I divide them to each other + await DivideTwoNumbers2Scenario.iDivideThemToEachOther(tester); + // Then the result should be + await DivideTwoNumbers2Scenario.theResultShouldBeResult(tester, example['result']!); + } + }); }); } diff --git a/example/test/calculator/calculator.feature b/example/test/calculator/calculator.feature index 83d3341..9a9610b 100644 --- a/example/test/calculator/calculator.feature +++ b/example/test/calculator/calculator.feature @@ -13,6 +13,12 @@ Feature: Calculator When I subtract them Then the result should be 2 + Scenario: Subtract two numbers2 + Given I have the number 6 + And I have the number 8 + When I subtract them + Then the result should be -2 + Scenario: Multiply two numbers Given I have the number 2 And I have the number 3 @@ -31,6 +37,17 @@ Feature: Calculator | 10 | 1 | 10 | | 10 | 10 | 1 | + Scenario: Divide two numbers2 + Given I have and + When I divide them to each other + Then the result should be + + Examples: + | number1 | number2 | result | + | 10 | 2 | 5 | + | 10 | 1 | 10 | + | 10 | 10 | 1 | + diff --git a/example/test/counter/counter.bdd_scenarios.dart b/example/test/counter/counter.bdd_scenarios.dart index e9d1ff4..bf23acf 100644 --- a/example/test/counter/counter.bdd_scenarios.dart +++ b/example/test/counter/counter.bdd_scenarios.dart @@ -12,7 +12,7 @@ class IncrementScenario { // TODO: Implement When I increment the counter by } - static Future theCounterShouldHaveValueExpectedvalue(WidgetTester tester, String expectedvalue) async { + static Future theCounterShouldHaveValueExpectedValue(WidgetTester tester, String expectedvalue) async { // TODO: Implement Then the counter should have value } diff --git a/example/test/counter/counter.bdd_test.dart b/example/test/counter/counter.bdd_test.dart index 8684287..6b06e8a 100644 --- a/example/test/counter/counter.bdd_test.dart +++ b/example/test/counter/counter.bdd_test.dart @@ -16,7 +16,7 @@ void main() { // When I increment the counter by await IncrementScenario.iIncrementTheCounterByValue(tester, example['value']!); // Then the counter should have value - await IncrementScenario.theCounterShouldHaveValueExpectedvalue(tester, example['expectedvalue']!); + await IncrementScenario.theCounterShouldHaveValueExpectedValue(tester, example['expectedvalue']!); } }); }); diff --git a/example/test/features/feature2.bdd_scenarios.dart b/example/test/features/feature2.bdd_scenarios.dart index ddd96d2..20a2351 100644 --- a/example/test/features/feature2.bdd_scenarios.dart +++ b/example/test/features/feature2.bdd_scenarios.dart @@ -15,3 +15,18 @@ class Scenario2Scenario { } +class Scenario3Scenario { + static Future iHaveACounterWithValue0(WidgetTester tester) async { + // TODO: Implement Given I have a counter with value 0 + } + + static Future iIncrementTheCounterBy1(WidgetTester tester) async { + // TODO: Implement When I increment the counter by 1 + } + + static Future theCounterShouldHaveValue1(WidgetTester tester) async { + // TODO: Implement Then the counter should have value 1 + } + +} + diff --git a/example/test/features/feature2.bdd_test.dart b/example/test/features/feature2.bdd_test.dart index 57e3e8c..4a1da93 100644 --- a/example/test/features/feature2.bdd_test.dart +++ b/example/test/features/feature2.bdd_test.dart @@ -12,5 +12,14 @@ void main() { // Then the counter should have value 1 await Scenario2Scenario.theCounterShouldHaveValue1(tester); }); + testWidgets('Scenario 3', (tester) async { + //Scenario: Scenario 3 + // Given I have a counter with value 0 + await Scenario3Scenario.iHaveACounterWithValue0(tester); + // When I increment the counter by 1 + await Scenario3Scenario.iIncrementTheCounterBy1(tester); + // Then the counter should have value 1 + await Scenario3Scenario.theCounterShouldHaveValue1(tester); + }); }); } diff --git a/example/test/features/feature2.feature b/example/test/features/feature2.feature index ee915bc..7d0456a 100644 --- a/example/test/features/feature2.feature +++ b/example/test/features/feature2.feature @@ -4,3 +4,9 @@ Feature: Feature 2 Given I have a counter with value 0 When I increment the counter by 1 Then the counter should have value 1 + + Scenario: Scenario 3 + Given I have a counter with value 0 + When I increment the counter by 1 + Then the counter should have value 1 + diff --git a/lib/src/extensions/list_x.dart b/lib/src/extensions/list_x.dart new file mode 100644 index 0000000..a7fb961 --- /dev/null +++ b/lib/src/extensions/list_x.dart @@ -0,0 +1,8 @@ +extension ListX on List { + T? firstWhereOrNull(bool Function(T) test) { + for (var element in this) { + if (test(element)) return element; + } + return null; + } +} diff --git a/lib/src/extensions/string_x.dart b/lib/src/extensions/string_x.dart index c3eafa1..ab6db4f 100644 --- a/lib/src/extensions/string_x.dart +++ b/lib/src/extensions/string_x.dart @@ -1,18 +1,4 @@ extension StringX on String { - String get toMethodName { - final words = replaceAll(RegExp(r'[^a-zA-Z0-9\s]'), '').split(' '); - if (words.isEmpty) return ''; - - return words[0].toLowerCase() + - words - .skip(1) - .where((word) => word.isNotEmpty) - .map( - (word) => word[0].toUpperCase() + word.substring(1).toLowerCase(), - ) - .join(''); - } - String get name { return split(' ').where((word) => word.isNotEmpty).map((word) => word[0].toUpperCase() + word.substring(1).toLowerCase()).join(''); } diff --git a/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart b/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart index e51a56a..cd8d45b 100644 --- a/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart +++ b/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart @@ -38,7 +38,7 @@ class BDDTestFileBuilder { if (feature.background != null) { buffer.writeln(" //Background: ${feature.background!.description}"); for (var step in feature.background!.steps) { - final methodName = step.text.toMethodName; + final methodName = step.methodName; buffer.writeln(" ${feature.name}Background.$methodName();"); } } @@ -134,7 +134,7 @@ String _generateTestFunction( bool isUnitTest, List params, ) { - final methodName = step.text.toMethodName; + final methodName = step.methodName; if (withReporter) { return ''' await reporter.guard( diff --git a/lib/src/feature/builder/bdd_builders/scenario_file_builder.dart b/lib/src/feature/builder/bdd_builders/scenario_file_builder.dart index b7fa9ce..77a1715 100644 --- a/lib/src/feature/builder/bdd_builders/scenario_file_builder.dart +++ b/lib/src/feature/builder/bdd_builders/scenario_file_builder.dart @@ -1,3 +1,5 @@ +import '../domain/step.dart'; + import '../domain/feature.dart'; import '../domain/scenario.dart'; import '../../../extensions/string_x.dart'; @@ -11,7 +13,7 @@ class ScenariosFileBuilder { if (feature.background != null) { buffer.writeln("class ${feature.name}Background {"); for (var step in feature.background!.steps) { - final methodName = step.text.toMethodName; + final methodName = step.methodName; final params = _extractMethodParams(step.text); buffer.writeln( " static Future $methodName(${params.isNotEmpty ? params : ''}) async {", @@ -31,7 +33,7 @@ class ScenariosFileBuilder { // Create static methods for each step in the scenario for (var step in scenario.steps) { - final methodName = step.text.toMethodName; + final methodName = step.methodName; final params = _extractMethodParams(step.text); if (!isUnitTest) { diff --git a/lib/src/feature/builder/domain/manifest.dart b/lib/src/feature/builder/domain/manifest.dart index 977402b..c4d38b2 100644 --- a/lib/src/feature/builder/domain/manifest.dart +++ b/lib/src/feature/builder/domain/manifest.dart @@ -98,6 +98,22 @@ class ScenarioEntry { required this.testMethod, }); + ScenarioEntry copyWith({ + String? name, + String? hash, + int? lineStart, + int? lineEnd, + String? testMethod, + }) { + return ScenarioEntry( + name: name ?? this.name, + hash: hash ?? this.hash, + lineStart: lineStart ?? this.lineStart, + lineEnd: lineEnd ?? this.lineEnd, + testMethod: testMethod ?? this.testMethod, + ); + } + Map toYaml() { return { 'name': name, diff --git a/lib/src/feature/builder/domain/step.dart b/lib/src/feature/builder/domain/step.dart index 6181065..3b7075a 100644 --- a/lib/src/feature/builder/domain/step.dart +++ b/lib/src/feature/builder/domain/step.dart @@ -7,8 +7,37 @@ class Step { final String text; Step(this.keyword, this.text); + + @override + String toString() { + return '$keyword $text'; + } } extension StepX on Step { String get message => '$keyword $text'; + String get methodName { + // First, replace parameters with their names + var processedText = text; + final paramRegex = RegExp(r'<(\w+)>'); + final paramMatches = paramRegex.allMatches(text); + for (var match in paramMatches) { + final paramName = match.group(1)!; + processedText = processedText.replaceAll(match.group(0)!, paramName); + } + + // Split into words and process + final words = processedText.replaceAll(RegExp(r'[^a-zA-Z0-9\s]'), ' ').split(' ').where((word) => word.isNotEmpty).toList(); + + if (words.isEmpty) return ''; + + // Convert to camelCase + return words[0].toLowerCase() + + words + .skip(1) + .map( + (word) => word[0].toUpperCase() + word.substring(1).toLowerCase(), + ) + .join(''); + } } diff --git a/lib/src/runner/build_command.dart b/lib/src/runner/build_command.dart index 0e4675c..3ee6fe3 100644 --- a/lib/src/runner/build_command.dart +++ b/lib/src/runner/build_command.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:bdd_flutter/src/extensions/list_x.dart'; import 'package:bdd_flutter/src/feature/builder/bdd_builders/bdd_factory.dart'; import 'package:bdd_flutter/src/feature/builder/domain/bdd_options.dart'; import 'package:bdd_flutter/src/feature/builder/domain/feature.dart'; @@ -126,7 +127,10 @@ class BuildCommand { DateTime lastModified, Feature parsedFeature, ) { - final scenarios = _parseScenarios(parsedFeature); + final scenarios = _parseManifestScenarios( + parsedFeature: parsedFeature, + featureFilePath: featurePath, + ); final testFile = '${featurePath.replaceAll('.feature', '')}${FileExtension.generatedTest}'; final featureEntry = FeatureEntry( @@ -146,26 +150,81 @@ class BuildCommand { } } - List _parseScenarios(Feature parsedFeature) { + List _parseManifestScenarios({ + required Feature parsedFeature, + required String featureFilePath, + }) { final scenarios = []; - var lineNumber = 1; - for (final scenario in parsedFeature.scenarios) { - final scenarioContent = scenario.toString(); - final hash = md5.convert(utf8.encode(scenarioContent)).toString(); - final startLine = lineNumber; - final endLine = startLine + scenarioContent.split('\n').length - 1; - final testMethod = 'test${scenario.name.replaceAll(' ', '')}'; - - scenarios.add(ScenarioEntry( - name: scenario.name, - hash: hash, - lineStart: startLine, - lineEnd: endLine, - testMethod: testMethod, - )); - - lineNumber = endLine + 1; + final lines = File(featureFilePath).readAsStringSync().split('\n'); + + ScenarioEntry? tempScenario; + + for (var i = 0; i < lines.length; i++) { + final line = lines[i].trim(); + if (line.startsWith('Scenario:')) { + if (tempScenario != null) { + scenarios.add(tempScenario.copyWith(lineEnd: i - 1)); + tempScenario = null; + } + + final scenarioName = line.substring('Scenario:'.length).trim(); + final parsedScenario = parsedFeature.scenarios.firstWhereOrNull((s) => s.name == scenarioName); + if (parsedScenario == null) { + _logger.error('Scenario not found: $scenarioName'); + continue; + } + + final hash = md5.convert(utf8.encode(parsedScenario.toString())).toString(); + tempScenario = ScenarioEntry( + name: scenarioName, + hash: hash, + lineStart: i, + lineEnd: i, + testMethod: 'test${scenarioName.replaceAll(' ', '')}', + ); + } } + + return scenarios; + + // /// + // var currentLine = 1; + + // for (final scenario in parsedFeature.scenarios) { + // final scenarioContent = scenario.toString(); + + // final hash = md5.convert(utf8.encode(scenarioContent)).toString(); + + // // // Find the start line of this scenario in the feature file + // // while (currentLine <= lines.length && !lines[currentLine - 1].trim().startsWith('Scenario:')) { + // // currentLine++; + // // } + // final lineIndex = lines.indexWhere((line) => line.trim().startsWith('Scenario:') && line.trim().contains(scenario.name)); + + // print('lineIndex: $lineIndex'); + // print('Scenario: ${scenario.name}'); + + // final startLine = lineIndex; + + // // Count lines until we find the next scenario or end of file + // var endLine = startLine; + // while (endLine < lines.length && !lines[endLine].trim().startsWith('Scenario:') && !lines[endLine].trim().startsWith('Feature:')) { + // endLine++; + // } + // endLine--; // Adjust to the last line of the current scenario + + // final testMethod = 'test${scenario.name.replaceAll(' ', '')}'; + + // scenarios.add(ScenarioEntry( + // name: scenario.name, + // hash: hash, + // lineStart: startLine, + // lineEnd: endLine, + // testMethod: testMethod, + // )); + + // currentLine = endLine + 1; + // } return scenarios; } @@ -179,43 +238,46 @@ class BuildCommand { final scenariosFilePath = '${featurePath.replaceAll('.feature', '')}${FileExtension.generatedScenarios}'; // Get current scenarios and their hashes - final currentScenarios = _parseScenarios(parsedFeature); - final changedScenarios = []; - final newScenarios = []; + final currentScenarioEntries = _parseManifestScenarios( + parsedFeature: parsedFeature, + featureFilePath: featurePath, + ); + final changedScenarioEntries = []; + final newScenarioEntries = []; if (existingScenarios != null) { // Find changed and new scenarios - for (final current in currentScenarios) { + for (final current in currentScenarioEntries) { final existing = existingScenarios.firstWhere( (s) => s.name == current.name, orElse: () => current, ); if (existing.hash != current.hash) { - changedScenarios.add(current); + changedScenarioEntries.add(current); _logger.log('Scenario changed: ${current.name}'); } } // Find new scenarios - for (final current in currentScenarios) { + for (final current in currentScenarioEntries) { if (!existingScenarios.any((s) => s.name == current.name)) { - newScenarios.add(current); + newScenarioEntries.add(current); _logger.log('New scenario: ${current.name}'); } } } else { // If no existing scenarios, all are new - newScenarios.addAll(currentScenarios); + newScenarioEntries.addAll(currentScenarioEntries); } - if (changedScenarios.isEmpty && newScenarios.isEmpty) { + if (changedScenarioEntries.isEmpty && newScenarioEntries.isEmpty) { _logger.log('No changes detected in scenarios'); return; } - await _updateScenariosFile(factory, parsedFeature, scenariosFilePath, existingScenarios, changedScenarios, newScenarios); - await _updateTestFile(factory, parsedFeature, testFilePath, existingScenarios, changedScenarios, newScenarios); + await _updateScenariosFile(factory, parsedFeature, scenariosFilePath, existingScenarios, changedScenarioEntries, newScenarioEntries); + await _updateTestFile(factory, parsedFeature, testFilePath, existingScenarios, changedScenarioEntries, newScenarioEntries); } Future _updateScenariosFile( @@ -237,9 +299,6 @@ class BuildCommand { // Update only changed scenarios for (final scenario in [...changedScenarios, ...newScenarios]) { - // final startLine = scenario.lineStart - 1; - // final endLine = scenario.lineEnd; - // Find the scenario class in the existing content final scenarioClass = "class ${scenario.name.toScenarioClassName} {"; final classStartIndex = lines.indexWhere((line) => line.contains(scenarioClass)); diff --git a/lib/src/runner/command_parser.dart b/lib/src/runner/command_parser.dart index dec0246..3812bbb 100644 --- a/lib/src/runner/command_parser.dart +++ b/lib/src/runner/command_parser.dart @@ -12,7 +12,6 @@ class CommandParser { _logger = logger ?? CLILogger(); Future parse(List arguments) async { - print('arguments: $arguments'); if (arguments.isEmpty) { // Default to build command if no arguments provided await _buildCommand.generate([]); diff --git a/lib/src/runner/update_manifest_cmd.dart b/lib/src/runner/update_manifest_cmd.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/src/runner/update_manifest_cmd.dart @@ -0,0 +1 @@ + From fa7963bde45aed58ce30c6acdef4689001f339cf Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Mon, 19 May 2025 18:16:03 -0600 Subject: [PATCH 11/24] refactor manifest generating --- example/.bdd_flutter/manifest.yaml | 10 ++-- lib/src/runner/build_command.dart | 85 ++++++++++-------------------- 2 files changed, 33 insertions(+), 62 deletions(-) diff --git a/example/.bdd_flutter/manifest.yaml b/example/.bdd_flutter/manifest.yaml index 9cff370..f0beabb 100644 --- a/example/.bdd_flutter/manifest.yaml +++ b/example/.bdd_flutter/manifest.yaml @@ -1,5 +1,5 @@ version: "1.0" -last_generated: "2025-05-19T16:18:21.590816" +last_generated: "2025-05-19T18:12:11.160212" features: - path: "test/calculator/calculator.feature" last_modified: "2025-05-16T22:11:21.000" @@ -8,7 +8,7 @@ features: - name: "Add two numbers" hash: "0a0e99eea6929a8ae27c1d95a88b293d" line_start: 2 - line_end: 8 + line_end: 7 test_method: "testAddtwonumbers" - name: "Subtract two numbers" hash: "ccc195782104eafe6ae0987cb7435146" @@ -54,17 +54,17 @@ features: - name: "Sample" hash: "56586753878cd78f2da7966b21643465" line_start: 1 - line_end: 6 + line_end: 5 test_method: "testSample" - name: "Counter" hash: "c5ecf1c5f3ca86df90f11ed5108056d6" line_start: 7 - line_end: 12 + line_end: 11 test_method: "testCounter" - name: "Counter with examples" hash: "cce46f3313000a10062f248e0bf771e3" line_start: 13 - line_end: 23 + line_end: 22 test_method: "testCounterwithexamples" - name: "Counter with parameters" hash: "3bb4729b114ac6188f9f3993f4049793" diff --git a/lib/src/runner/build_command.dart b/lib/src/runner/build_command.dart index 3ee6fe3..f21aa9a 100644 --- a/lib/src/runner/build_command.dart +++ b/lib/src/runner/build_command.dart @@ -82,13 +82,13 @@ class BuildCommand { if (options.force) { // Force regenerate everything _logger.logProcessing(featurePath, reason: 'force regenerate'); - await _generateFeatureFiles(factory, parsedFeature, featurePath); + await _generateScenarioAndTestFile(factory, parsedFeature, featurePath); _updateManifestEntry(manifest, featurePath, lastModified, parsedFeature); } else if (options.newOnly) { // Only generate for new features if (existingFeature == null) { _logger.logProcessing(featurePath, reason: 'new feature'); - await _generateFeatureFiles(factory, parsedFeature, featurePath); + await _generateScenarioAndTestFile(factory, parsedFeature, featurePath); _updateManifestEntry(manifest, featurePath, lastModified, parsedFeature); } else { _logger.logSkipping(featurePath, reason: 'existing feature'); @@ -98,17 +98,17 @@ class BuildCommand { if (!filesExist) { // Files don't exist, regenerate everything _logger.logProcessing(featurePath, reason: 'missing generated files'); - await _generateFeatureFiles(factory, parsedFeature, featurePath); + await _generateScenarioAndTestFile(factory, parsedFeature, featurePath); _updateManifestEntry(manifest, featurePath, lastModified, parsedFeature); } else if (existingFeature == null) { // Feature not in manifest, regenerate everything _logger.logProcessing(featurePath, reason: 'not in manifest'); - await _generateFeatureFiles(factory, parsedFeature, featurePath); + await _generateScenarioAndTestFile(factory, parsedFeature, featurePath); _updateManifestEntry(manifest, featurePath, lastModified, parsedFeature); } else if (lastModified.isAfter(existingFeature.lastModified)) { // Feature modified, check for scenario changes _logger.logProcessing(featurePath, reason: 'modified since last generation'); - await _generateFeatureFiles( + await _generateScenarioAndTestFile( factory, parsedFeature, featurePath, @@ -161,12 +161,17 @@ class BuildCommand { for (var i = 0; i < lines.length; i++) { final line = lines[i].trim(); - if (line.startsWith('Scenario:')) { + // check for end of scenario + // if there is a temp scenario, add it to the list + // and reset the temp scenario + if (line.startsWith('Scenario:') || line.startsWith('@')) { if (tempScenario != null) { scenarios.add(tempScenario.copyWith(lineEnd: i - 1)); tempScenario = null; } + } + if (line.startsWith('Scenario:')) { final scenarioName = line.substring('Scenario:'.length).trim(); final parsedScenario = parsedFeature.scenarios.firstWhereOrNull((s) => s.name == scenarioName); if (parsedScenario == null) { @@ -186,49 +191,13 @@ class BuildCommand { } return scenarios; - - // /// - // var currentLine = 1; - - // for (final scenario in parsedFeature.scenarios) { - // final scenarioContent = scenario.toString(); - - // final hash = md5.convert(utf8.encode(scenarioContent)).toString(); - - // // // Find the start line of this scenario in the feature file - // // while (currentLine <= lines.length && !lines[currentLine - 1].trim().startsWith('Scenario:')) { - // // currentLine++; - // // } - // final lineIndex = lines.indexWhere((line) => line.trim().startsWith('Scenario:') && line.trim().contains(scenario.name)); - - // print('lineIndex: $lineIndex'); - // print('Scenario: ${scenario.name}'); - - // final startLine = lineIndex; - - // // Count lines until we find the next scenario or end of file - // var endLine = startLine; - // while (endLine < lines.length && !lines[endLine].trim().startsWith('Scenario:') && !lines[endLine].trim().startsWith('Feature:')) { - // endLine++; - // } - // endLine--; // Adjust to the last line of the current scenario - - // final testMethod = 'test${scenario.name.replaceAll(' ', '')}'; - - // scenarios.add(ScenarioEntry( - // name: scenario.name, - // hash: hash, - // lineStart: startLine, - // lineEnd: endLine, - // testMethod: testMethod, - // )); - - // currentLine = endLine + 1; - // } - return scenarios; } - Future _generateFeatureFiles( + /// Generates the scenario and test files for a feature + /// + /// If [existingScenarios] is provided, it will only generate the scenarios and test files for the scenarios that have changed + /// + Future _generateScenarioAndTestFile( BDDFactory factory, Feature parsedFeature, String featurePath, { @@ -242,18 +211,20 @@ class BuildCommand { parsedFeature: parsedFeature, featureFilePath: featurePath, ); + final changedScenarioEntries = []; final newScenarioEntries = []; if (existingScenarios != null) { // Find changed and new scenarios for (final current in currentScenarioEntries) { - final existing = existingScenarios.firstWhere( - (s) => s.name == current.name, - orElse: () => current, - ); + final existing = existingScenarios.firstWhereOrNull((s) => s.name == current.name); - if (existing.hash != current.hash) { + if (existing == null) { + newScenarioEntries.add(current); + _logger.log('New scenario: ${current.name}'); + continue; + } else if (existing.hash != current.hash) { changedScenarioEntries.add(current); _logger.log('Scenario changed: ${current.name}'); } @@ -284,21 +255,21 @@ class BuildCommand { BDDFactory factory, Feature parsedFeature, String scenariosFilePath, - List? existingScenarios, - List changedScenarios, - List newScenarios, + List? existingScenarioEntries, + List changedScenarioEntries, + List newScenarioEntries, ) async { // Build scenarios file final scenarios = await factory.scenarioBuilder.buildScenarioFile(parsedFeature); final scenariosFile = File(scenariosFilePath); - if (await scenariosFile.exists() && existingScenarios != null) { + if (await scenariosFile.exists() && existingScenarioEntries != null) { // Read existing file final existingContent = await scenariosFile.readAsString(); final lines = existingContent.split('\n'); // Update only changed scenarios - for (final scenario in [...changedScenarios, ...newScenarios]) { + for (final scenario in [...changedScenarioEntries, ...newScenarioEntries]) { // Find the scenario class in the existing content final scenarioClass = "class ${scenario.name.toScenarioClassName} {"; final classStartIndex = lines.indexWhere((line) => line.contains(scenarioClass)); From a83e1c7fc100b8bcdef1ced0f898bba0d00f66b7 Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Tue, 31 Mar 2026 20:23:58 -0600 Subject: [PATCH 12/24] refacto phase 1 --- .vscode/launch.json | 12 + CLAUDE.md | 84 ++++ README.md | 14 + bin/bdd_flutter.dart | 6 +- example/.bdd_flutter/manifest.yaml | 54 +-- example/pubspec.lock | 70 ++-- .../calculator/calculator.bdd_scenarios.dart | 48 +-- .../test/calculator/calculator.bdd_test.dart | 52 +-- .../test/counter/counter.bdd_scenarios.dart | 6 +- example/test/counter/counter.bdd_test.dart | 16 +- .../test/features/feature1.bdd_scenarios.dart | 66 +++- example/test/features/feature1.bdd_test.dart | 47 ++- example/test/features/feature1.feature | 21 + .../test/features/feature2.bdd_scenarios.dart | 12 +- example/test/features/feature2.bdd_test.dart | 14 +- .../test/features/feature3.bdd_scenarios.dart | 6 +- example/test/features/feature3.bdd_test.dart | 7 +- example/test/sample/sample.bdd_scenarios.dart | 32 +- example/test/sample/sample.bdd_test.dart | 35 +- lib/bdd_flutter.dart | 2 +- ...e_extenstion.dart => file_constraint.dart} | 2 +- lib/src/domain/background.dart | 8 + lib/src/domain/decorator.dart | 37 ++ lib/src/domain/feature.dart | 35 ++ .../builder => }/domain/scenario.dart | 35 +- .../{feature/builder => }/domain/step.dart | 0 lib/src/extensions/string_x.dart | 5 +- .../builder/bdd_builders/bdd_factory.dart | 24 -- .../bdd_builders/bdd_feature_builder.dart | 181 --------- .../feature/builder/domain/background.dart | 8 - .../feature/builder/domain/bdd_options.dart | 94 ----- lib/src/feature/builder/domain/decorator.dart | 68 ---- lib/src/feature/builder/domain/feature.dart | 30 -- lib/src/feature/builder/domain/manifest.dart | 202 ---------- .../validators/decorators_validator.dart | 79 ---- lib/src/feature/logger/logger.dart | 65 --- .../builders}/scenario_file_builder.dart | 42 +- .../builders/test_file_builder.dart} | 62 ++- .../parsers/feature_parser.dart | 165 ++++++++ lib/src/presentation/cli/bbd_cli.dart | 35 ++ .../controllers/bdd_controller.dart | 50 +++ .../reporter}/test_reporter.dart | 0 lib/src/runner/build_command.dart | 374 ------------------ lib/src/runner/command_parser.dart | 54 --- lib/src/runner/domain/cmd_flag.dart | 49 --- lib/src/runner/help_command.dart | 23 -- lib/src/runner/update_manifest_cmd.dart | 1 - overview.md | 100 +++++ plan/refactor.md | 219 ++++++++++ pubspec.yaml | 2 +- test/builders/scenario_file_builder_test.dart | 128 ++++++ test/builders/test_file_builder_test.dart | 184 +++++++++ .../build_scenarios_file_test.dart | 142 ------- .../feature_builder/build_test_file_test.dart | 217 ---------- test/feature_builder/parse_feature_test.dart | 192 --------- test/parsers/feature_parser_test.dart | 201 ++++++++++ test/runner/flag_parser_test.dart | 93 ----- 57 files changed, 1667 insertions(+), 2143 deletions(-) create mode 100644 CLAUDE.md rename lib/src/constraints/{file_extenstion.dart => file_constraint.dart} (92%) create mode 100644 lib/src/domain/background.dart create mode 100644 lib/src/domain/decorator.dart create mode 100644 lib/src/domain/feature.dart rename lib/src/{feature/builder => }/domain/scenario.dart (53%) rename lib/src/{feature/builder => }/domain/step.dart (100%) delete mode 100644 lib/src/feature/builder/bdd_builders/bdd_factory.dart delete mode 100644 lib/src/feature/builder/bdd_builders/bdd_feature_builder.dart delete mode 100644 lib/src/feature/builder/domain/background.dart delete mode 100644 lib/src/feature/builder/domain/bdd_options.dart delete mode 100644 lib/src/feature/builder/domain/decorator.dart delete mode 100644 lib/src/feature/builder/domain/feature.dart delete mode 100644 lib/src/feature/builder/domain/manifest.dart delete mode 100644 lib/src/feature/builder/domain/validators/decorators_validator.dart delete mode 100644 lib/src/feature/logger/logger.dart rename lib/src/{feature/builder/bdd_builders => infrastructure/builders}/scenario_file_builder.dart (57%) rename lib/src/{feature/builder/bdd_builders/bdd_test_file_builder.dart => infrastructure/builders/test_file_builder.dart} (73%) create mode 100644 lib/src/infrastructure/parsers/feature_parser.dart create mode 100644 lib/src/presentation/cli/bbd_cli.dart create mode 100644 lib/src/presentation/controllers/bdd_controller.dart rename lib/src/{feature/report => presentation/reporter}/test_reporter.dart (100%) delete mode 100644 lib/src/runner/build_command.dart delete mode 100644 lib/src/runner/command_parser.dart delete mode 100644 lib/src/runner/domain/cmd_flag.dart delete mode 100644 lib/src/runner/help_command.dart delete mode 100644 lib/src/runner/update_manifest_cmd.dart create mode 100644 overview.md create mode 100644 plan/refactor.md create mode 100644 test/builders/scenario_file_builder_test.dart create mode 100644 test/builders/test_file_builder_test.dart delete mode 100644 test/feature_builder/build_scenarios_file_test.dart delete mode 100644 test/feature_builder/build_test_file_test.dart delete mode 100644 test/feature_builder/parse_feature_test.dart create mode 100644 test/parsers/feature_parser_test.dart delete mode 100644 test/runner/flag_parser_test.dart diff --git a/.vscode/launch.json b/.vscode/launch.json index 505622d..7165f42 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,6 +12,18 @@ "--delete-conflicting-outputs" ], "cwd": "${workspaceFolder}/example" + }, + { + "name": "Debug BDD CLI", + "request": "launch", + "type": "dart", + "program": "${workspaceFolder}/bin/bdd_flutter.dart", + "args": [ + "build", + "--verbose", + "--delete-conflicting-outputs" + ], + "cwd": "${workspaceFolder}/example" } ] } \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9911e1b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,84 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Project Is + +bdd_flutter is a Dart CLI tool that generates Flutter/Dart test files from Gherkin `.feature` files. Users write BDD scenarios in `.feature` files, run `dart run bdd_flutter build`, and get `.bdd_scenarios.dart` (step stubs) and `.bdd_test.dart` (runnable tests) files generated alongside the feature file. + +## Common Commands + +```bash +# Run all tests +flutter test + +# Run a single test file +flutter test test/file_procesors/feature_parser_test.dart + +# Run the CLI locally against the example project +dart run bin/bdd_flutter.dart build + +# Analyze code +dart analyze + +# Get dependencies +dart pub get +``` + +## Architecture + +Clean Architecture in `lib/src2/`. The CLI entry point is `bin/bdd_flutter.dart`. + +### Code Generation Pipeline + +``` +BDDCLI.run(args) + โ†’ BDDController.generateFeatureTestCases() + โ†’ FeatureParser.parseFeature(filePath) # .feature โ†’ Feature model + โ†’ ScenariosFileBuilder.buildScenarioFile() # Feature โ†’ .bdd_scenarios.dart + โ†’ TestFileBuilder.buildTestFile() # Feature โ†’ .bdd_test.dart +``` + +### Layers + +- **`domain/`** โ€” Core models: Feature, Scenario, Step, Decorator, Background +- **`infrastructure/parsers/`** โ€” FeatureParser (reads `.feature` files into domain models) +- **`infrastructure/builders/`** โ€” ScenariosFileBuilder, TestFileBuilder (domain models โ†’ Dart code) +- **`presentation/cli/`** โ€” BDDCLI entry point +- **`presentation/controllers/`** โ€” BDDController orchestrates parsing and building + +### Domain Models + +- **Feature** โ€” name, path, scenarios, decorators, optional background +- **Scenario** โ€” name, steps, optional examples table, decorators +- **Step** โ€” keyword (Given/When/Then/And) + text with `` placeholders +- **Decorator** โ€” enum: `unitTest`, `widgetTest`, `enableReporter`, `ignore` +- **Background** โ€” shared setup steps applied to all scenarios in a feature + +### Generated File Conventions + +- `.bdd_scenarios.dart` โ€” Static classes with step method stubs +- `.bdd_test.dart` โ€” Executable test file using `test()` or `testWidgets()` +- `.feature` โ€” Gherkin source files +- Generated files are placed alongside the `.feature` file + +### CLI Flags + +- `--widget-test` โ€” Generate widget tests (uses `testWidgets` + `WidgetTester`) +- `--reporter` โ€” Enable BDD test reporter +- `--force` โ€” Regenerate all files regardless of changes +- `--new-only` โ€” Only generate for new feature files + +### Config File + +User projects store config at `.bdd_flutter/config.yaml` with options: `generate_widget_tests`, `enable_reporter`, `ignore_features`. + +## Coding Conventions + +- Use PascalCase for classes, camelCase for variables/functions, underscore_case for files +- Always declare explicit types; avoid `dynamic` +- One export per file +- Functions should be <20 lines with a single purpose +- Prefer composition over inheritance +- Use early returns to avoid deep nesting +- Follow Arrange-Act-Assert for tests diff --git a/README.md b/README.md index 9cdec81..b89bdf9 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ A powerful Flutter package that simplifies Behavior Driven Development (BDD) by automatically generating test files from Gherkin feature files. Write expressive tests in plain English using Given/When/Then scenarios and let BDD Flutter handle the boilerplate code generation. +> **Note**: This package is currently in active development. While it's stable for production use, new features and improvements are being added regularly. Feel free to submit issues or feature requests on GitHub. + ## ๐Ÿšจ Breaking Changes in v1.0.0 - The package no longer uses `build_runner`. Instead, it now uses a simpler CLI approach: @@ -277,6 +279,18 @@ dart run bdd_flutter build --new-only - Follow Gherkin syntax guidelines 3. **Test Files** + - Don't modify generated test files directly - Add your implementation in the provided methods - Use the incremental update mode to preserve changes + +4. **Incremental Generation Limitations** + - The default incremental generation mode has some limitations when working with existing `.feature` files: + - Adding new features at the end of the file works fine + - Modifying existing scenarios may cause issues with scenario and test file generation + - Adding new scenarios in the middle of the file can cause generation problems + - When making significant changes to existing feature files, consider using the force regenerate mode: + ```bash + dart run bdd_flutter build --force + ``` + - Always backup your implemented test code before force regenerating diff --git a/bin/bdd_flutter.dart b/bin/bdd_flutter.dart index cb81dd2..6f54556 100644 --- a/bin/bdd_flutter.dart +++ b/bin/bdd_flutter.dart @@ -1,6 +1,6 @@ -import 'package:bdd_flutter/src/runner/command_parser.dart'; +import 'package:bdd_flutter/src/presentation/cli/bbd_cli.dart'; void main(List arguments) async { - final parser = CommandParser(); - await parser.parse(arguments); + final cli = BDDCLI(); + await cli.run(arguments); } diff --git a/example/.bdd_flutter/manifest.yaml b/example/.bdd_flutter/manifest.yaml index f0beabb..707a43c 100644 --- a/example/.bdd_flutter/manifest.yaml +++ b/example/.bdd_flutter/manifest.yaml @@ -1,5 +1,5 @@ version: "1.0" -last_generated: "2025-05-19T18:12:11.160212" +last_generated: "2025-05-25T17:03:13.083276" features: - path: "test/calculator/calculator.feature" last_modified: "2025-05-16T22:11:21.000" @@ -7,71 +7,81 @@ features: scenarios: - name: "Add two numbers" hash: "0a0e99eea6929a8ae27c1d95a88b293d" - line_start: 2 - line_end: 7 test_method: "testAddtwonumbers" - name: "Subtract two numbers" hash: "ccc195782104eafe6ae0987cb7435146" - line_start: 9 - line_end: 14 test_method: "testSubtracttwonumbers" - name: "Subtract two numbers2" hash: "58ce85c7cb7cc7c2453e862ebb12e20b" - line_start: 15 - line_end: 20 test_method: "testSubtracttwonumbers2" - name: "Multiply two numbers" hash: "560cbb5eedd961771237ba5d9ae3e7a3" - line_start: 21 - line_end: 26 test_method: "testMultiplytwonumbers" - name: "Divide two numbers" hash: "e85196f20fc91d407521ee37fcad2320" - line_start: 27 - line_end: 38 test_method: "testDividetwonumbers" + - name: "Divide two numbers2" + hash: "19716544b83f2724d31ac3204a997498" + test_method: "testDividetwonumbers2" - path: "test/features/feature2.feature" last_modified: "2025-05-19T14:28:03.000" test_file: "test/features/feature2.bdd_test.dart" scenarios: - name: "Scenario 2" hash: "5084b8f7e6886a111ed0bbc7e953f6fa" - line_start: 2 - line_end: 6 test_method: "testScenario2" + - name: "Scenario 3" + hash: "17288edb6b3917af4162d920d37e8b8a" + test_method: "testScenario3" - path: "test/features/feature3.feature" last_modified: "2025-05-14T21:04:20.000" test_file: "test/features/feature3.bdd_test.dart" scenarios: + - name: "Scenario 3" + hash: "17288edb6b3917af4162d920d37e8b8a" + test_method: "testScenario3" - path: "test/features/feature1.feature" - last_modified: "2025-05-15T21:50:07.000" + last_modified: "2025-05-20T22:28:54.000" test_file: "test/features/feature1.bdd_test.dart" scenarios: + - name: "Scenario 1" + hash: "37b9806aac30d8368e7d7ffb3d741dd2" + test_method: "testScenario1" + - name: "Scenario 2" + hash: "5084b8f7e6886a111ed0bbc7e953f6fa" + test_method: "testScenario2" + - name: "Scenario 3" + hash: "17288edb6b3917af4162d920d37e8b8a" + test_method: "testScenario3" + - name: "Scenario 4" + hash: "4ec1439f905b1bfa508cf050d8601517" + test_method: "testScenario4" + - name: "Scenario 5" + hash: "18b76868f1cce6a18fb13439ea85b987" + test_method: "testScenario5" - path: "test/sample/sample.feature" last_modified: "2025-05-11T21:52:29.000" test_file: "test/sample/sample.bdd_test.dart" scenarios: - name: "Sample" hash: "56586753878cd78f2da7966b21643465" - line_start: 1 - line_end: 5 test_method: "testSample" - name: "Counter" hash: "c5ecf1c5f3ca86df90f11ed5108056d6" - line_start: 7 - line_end: 11 test_method: "testCounter" - name: "Counter with examples" hash: "cce46f3313000a10062f248e0bf771e3" - line_start: 13 - line_end: 22 test_method: "testCounterwithexamples" - name: "Counter with parameters" hash: "3bb4729b114ac6188f9f3993f4049793" - line_start: 24 - line_end: 33 test_method: "testCounterwithparameters" + - name: "Counter with widget test" + hash: "96cc5ec24c542bae66e92c1fe5d5815f" + test_method: "testCounterwithwidgettest" - path: "test/counter/counter.feature" last_modified: "2025-05-04T10:27:15.000" test_file: "test/counter/counter.bdd_test.dart" scenarios: + - name: "Increment" + hash: "d2c48c31dce2f0456a25420d893856a0" + test_method: "testIncrement" diff --git a/example/pubspec.lock b/example/pubspec.lock index ef59f99..cc6e774 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" url: "https://pub.dev" source: hosted - version: "82.0.0" + version: "93.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b url: "https://pub.dev" source: hosted - version: "7.4.5" + version: "10.0.1" args: dependency: transitive description: @@ -52,10 +52,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" cli_config: dependency: transitive description: @@ -92,10 +92,10 @@ packages: dependency: transitive description: name: coverage - sha256: "802bd084fb82e55df091ec8ad1553a7331b61c08251eef19a508b6f3f3a9858d" + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" url: "https://pub.dev" source: hosted - version: "1.13.1" + version: "1.15.0" crypto: dependency: transitive description: @@ -108,10 +108,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" file: dependency: transitive description: @@ -178,38 +178,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" - source: hosted - version: "0.7.2" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.8" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -230,26 +222,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -395,26 +387,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.25.15" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.8" + version: "0.6.16" typed_data: dependency: transitive description: @@ -427,10 +419,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -488,5 +480,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.7.0 <4.0.0" + dart: ">=3.9.0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/example/test/calculator/calculator.bdd_scenarios.dart b/example/test/calculator/calculator.bdd_scenarios.dart index 7b012b4..dec73f9 100644 --- a/example/test/calculator/calculator.bdd_scenarios.dart +++ b/example/test/calculator/calculator.bdd_scenarios.dart @@ -1,110 +1,110 @@ import 'package:flutter_test/flutter_test.dart'; class AddTwoNumbersScenario { - static Future iHaveTheNumber1(WidgetTester tester) async { + Future iHaveTheNumber1(WidgetTester tester) async { // TODO: Implement Given I have the number 1 } - static Future iHaveTheNumber2(WidgetTester tester) async { + Future iHaveTheNumber2(WidgetTester tester) async { // TODO: Implement And I have the number 2 } - static Future iAddThemTogether(WidgetTester tester) async { + Future iAddThemTogether(WidgetTester tester) async { // TODO: Implement When I add them together } - static Future theResultShouldBe3(WidgetTester tester) async { + Future theResultShouldBe3(WidgetTester tester) async { // TODO: Implement Then the result should be 3 } } -class Subtract { - static Future iHaveTheNumber5(WidgetTester tester) async { +class SubtractTwoNumbersScenario { + Future iHaveTheNumber5(WidgetTester tester) async { // TODO: Implement Given I have the number 5 } - static Future iHaveTheNumber3(WidgetTester tester) async { + Future iHaveTheNumber3(WidgetTester tester) async { // TODO: Implement And I have the number 3 } - static Future iSubtractThem(WidgetTester tester) async { + Future iSubtractThem(WidgetTester tester) async { // TODO: Implement When I subtract them } - static Future theResultShouldBe2(WidgetTester tester) async { + Future theResultShouldBe2(WidgetTester tester) async { // TODO: Implement Then the result should be 2 } } class SubtractTwoNumbers2Scenario { - static Future iHaveTheNumber6(WidgetTester tester) async { + Future iHaveTheNumber6(WidgetTester tester) async { // TODO: Implement Given I have the number 6 } - static Future iHaveTheNumber8(WidgetTester tester) async { + Future iHaveTheNumber8(WidgetTester tester) async { // TODO: Implement And I have the number 8 } - static Future iSubtractThem(WidgetTester tester) async { + Future iSubtractThem(WidgetTester tester) async { // TODO: Implement When I subtract them } - static Future theResultShouldBe2(WidgetTester tester) async { + Future theResultShouldBe2(WidgetTester tester) async { // TODO: Implement Then the result should be -2 } } class MultiplyTwoNumbersScenario { - static Future iHaveTheNumber2(WidgetTester tester) async { + Future iHaveTheNumber2(WidgetTester tester) async { // TODO: Implement Given I have the number 2 } - static Future iHaveTheNumber3(WidgetTester tester) async { + Future iHaveTheNumber3(WidgetTester tester) async { // TODO: Implement And I have the number 3 } - static Future iMultiplyThem(WidgetTester tester) async { + Future iMultiplyThem(WidgetTester tester) async { // TODO: Implement When I multiply them } - static Future theResultShouldBe6(WidgetTester tester) async { + Future theResultShouldBe6(WidgetTester tester) async { // TODO: Implement Then the result should be 6 } } class DivideTwoNumbersScenario { - static Future iHaveTheNumberNumber1(WidgetTester tester, String number1) async { + Future iHaveTheNumberNumber1(WidgetTester tester, String number1) async { // TODO: Implement Given I have the number } - static Future iHaveTheNumberNumber2(WidgetTester tester, String number2) async { + Future iHaveTheNumberNumber2(WidgetTester tester, String number2) async { // TODO: Implement And I have the number } - static Future iDivideThem(WidgetTester tester) async { + Future iDivideThem(WidgetTester tester) async { // TODO: Implement When I divide them } - static Future theResultShouldBeResult(WidgetTester tester, String result) async { + Future theResultShouldBeResult(WidgetTester tester, String result) async { // TODO: Implement Then the result should be } } class DivideTwoNumbers2Scenario { - static Future iHaveNumber1AndNumber2(WidgetTester tester, String number1, String number2) async { + Future iHaveNumber1AndNumber2(WidgetTester tester, String number1, String number2) async { // TODO: Implement Given I have and } - static Future iDivideThemToEachOther(WidgetTester tester) async { + Future iDivideThemToEachOther(WidgetTester tester) async { // TODO: Implement When I divide them to each other } - static Future theResultShouldBeResult(WidgetTester tester, String result) async { + Future theResultShouldBeResult(WidgetTester tester, String result) async { // TODO: Implement Then the result should be } diff --git a/example/test/calculator/calculator.bdd_test.dart b/example/test/calculator/calculator.bdd_test.dart index 4f1515e..2c69927 100644 --- a/example/test/calculator/calculator.bdd_test.dart +++ b/example/test/calculator/calculator.bdd_test.dart @@ -4,50 +4,55 @@ import 'calculator.bdd_scenarios.dart'; void main() { group('Calculator', () { testWidgets('Add two numbers', (tester) async { + final scenario = AddTwoNumbersScenario(); //Scenario: Add two numbers // Given I have the number 1 - await AddTwoNumbersScenario.iHaveTheNumber1(tester); + await scenario.iHaveTheNumber1(tester); // And I have the number 2 - await AddTwoNumbersScenario.iHaveTheNumber2(tester); + await scenario.iHaveTheNumber2(tester); // When I add them together - await AddTwoNumbersScenario.iAddThemTogether(tester); + await scenario.iAddThemTogether(tester); // Then the result should be 3 - await AddTwoNumbersScenario.theResultShouldBe3(tester); + await scenario.theResultShouldBe3(tester); }); testWidgets('Subtract two numbers', (tester) async { + final scenario = SubtractTwoNumbersScenario(); //Scenario: Subtract two numbers // Given I have the number 5 - await Subtract.iHaveTheNumber5(tester); + await scenario.iHaveTheNumber5(tester); // And I have the number 3 - await Subtract.iHaveTheNumber3(tester); + await scenario.iHaveTheNumber3(tester); // When I subtract them - await Subtract.iSubtractThem(tester); + await scenario.iSubtractThem(tester); // Then the result should be 2 - await Subtract.theResultShouldBe2(tester); + await scenario.theResultShouldBe2(tester); }); testWidgets('Subtract two numbers2', (tester) async { + final scenario = SubtractTwoNumbers2Scenario(); //Scenario: Subtract two numbers2 // Given I have the number 6 - await SubtractTwoNumbers2Scenario.iHaveTheNumber6(tester); + await scenario.iHaveTheNumber6(tester); // And I have the number 8 - await SubtractTwoNumbers2Scenario.iHaveTheNumber8(tester); + await scenario.iHaveTheNumber8(tester); // When I subtract them - await SubtractTwoNumbers2Scenario.iSubtractThem(tester); + await scenario.iSubtractThem(tester); // Then the result should be -2 - await SubtractTwoNumbers2Scenario.theResultShouldBe2(tester); + await scenario.theResultShouldBe2(tester); }); testWidgets('Multiply two numbers', (tester) async { + final scenario = MultiplyTwoNumbersScenario(); //Scenario: Multiply two numbers // Given I have the number 2 - await MultiplyTwoNumbersScenario.iHaveTheNumber2(tester); + await scenario.iHaveTheNumber2(tester); // And I have the number 3 - await MultiplyTwoNumbersScenario.iHaveTheNumber3(tester); + await scenario.iHaveTheNumber3(tester); // When I multiply them - await MultiplyTwoNumbersScenario.iMultiplyThem(tester); + await scenario.iMultiplyThem(tester); // Then the result should be 6 - await MultiplyTwoNumbersScenario.theResultShouldBe6(tester); + await scenario.theResultShouldBe6(tester); }); testWidgets('Divide two numbers', (tester) async { + final scenario = DivideTwoNumbersScenario(); //Scenario: Divide two numbers final examples = [ {'number1': '10','number2': '2','result': '5',}, @@ -56,16 +61,17 @@ void main() { ]; for (var example in examples) { // Given I have the number - await DivideTwoNumbersScenario.iHaveTheNumberNumber1(tester, example['number1']!); + await scenario.iHaveTheNumberNumber1(tester, example['number1']!); // And I have the number - await DivideTwoNumbersScenario.iHaveTheNumberNumber2(tester, example['number2']!); + await scenario.iHaveTheNumberNumber2(tester, example['number2']!); // When I divide them - await DivideTwoNumbersScenario.iDivideThem(tester); + await scenario.iDivideThem(tester); // Then the result should be - await DivideTwoNumbersScenario.theResultShouldBeResult(tester, example['result']!); + await scenario.theResultShouldBeResult(tester, example['result']!); } }); testWidgets('Divide two numbers2', (tester) async { + final scenario = DivideTwoNumbers2Scenario(); //Scenario: Divide two numbers2 final examples = [ {'number1': '10','number2': '2','result': '5',}, @@ -74,11 +80,11 @@ void main() { ]; for (var example in examples) { // Given I have and - await DivideTwoNumbers2Scenario.iHaveNumber1AndNumber2(tester, example['number1']!, example['number2']!); + await scenario.iHaveNumber1AndNumber2(tester, example['number1']!, example['number2']!); // When I divide them to each other - await DivideTwoNumbers2Scenario.iDivideThemToEachOther(tester); + await scenario.iDivideThemToEachOther(tester); // Then the result should be - await DivideTwoNumbers2Scenario.theResultShouldBeResult(tester, example['result']!); + await scenario.theResultShouldBeResult(tester, example['result']!); } }); }); diff --git a/example/test/counter/counter.bdd_scenarios.dart b/example/test/counter/counter.bdd_scenarios.dart index bf23acf..eb3d4eb 100644 --- a/example/test/counter/counter.bdd_scenarios.dart +++ b/example/test/counter/counter.bdd_scenarios.dart @@ -1,18 +1,18 @@ import 'package:flutter_test/flutter_test.dart'; class CounterBackground { - static Future iHaveACounterWithValue0() async { + Future iHaveACounterWithValue0() async { // TODO: Implement Given I have a counter with value 0 } } class IncrementScenario { - static Future iIncrementTheCounterByValue(WidgetTester tester, String value) async { + Future iIncrementTheCounterByValue(WidgetTester tester, String value) async { // TODO: Implement When I increment the counter by } - static Future theCounterShouldHaveValueExpectedValue(WidgetTester tester, String expectedvalue) async { + Future theCounterShouldHaveValueExpectedValue(WidgetTester tester, String expectedValue) async { // TODO: Implement Then the counter should have value } diff --git a/example/test/counter/counter.bdd_test.dart b/example/test/counter/counter.bdd_test.dart index 6b06e8a..9885517 100644 --- a/example/test/counter/counter.bdd_test.dart +++ b/example/test/counter/counter.bdd_test.dart @@ -3,20 +3,22 @@ import 'counter.bdd_scenarios.dart'; void main() { group('Counter', () { - //Background: I have a counter with value 0 - CounterBackground.iHaveACounterWithValue0(); testWidgets('Increment', (tester) async { + final scenario = IncrementScenario(); + final background = CounterBackground(); + //Background: I have a counter with value 0 + await background.iHaveACounterWithValue0(); //Scenario: Increment final examples = [ - {'value': '1','expectedvalue': '1',}, - {'value': '2','expectedvalue': '2',}, - {'value': '3','expectedvalue': '3',}, + {'value': '1','expectedValue': '1',}, + {'value': '2','expectedValue': '2',}, + {'value': '3','expectedValue': '3',}, ]; for (var example in examples) { // When I increment the counter by - await IncrementScenario.iIncrementTheCounterByValue(tester, example['value']!); + await scenario.iIncrementTheCounterByValue(tester, example['value']!); // Then the counter should have value - await IncrementScenario.theCounterShouldHaveValueExpectedValue(tester, example['expectedvalue']!); + await scenario.theCounterShouldHaveValueExpectedValue(tester, example['expectedValue']!); } }); }); diff --git a/example/test/features/feature1.bdd_scenarios.dart b/example/test/features/feature1.bdd_scenarios.dart index 3b3b408..0c15c3c 100644 --- a/example/test/features/feature1.bdd_scenarios.dart +++ b/example/test/features/feature1.bdd_scenarios.dart @@ -1,15 +1,75 @@ import 'package:flutter_test/flutter_test.dart'; class Scenario1Scenario { - static Future iHaveACounterWithValue0(WidgetTester tester) async { + Future iHaveACounterWithValue0(WidgetTester tester) async { // TODO: Implement Given I have a counter with value 0 } - static Future iIncrementTheCounterBy1(WidgetTester tester) async { + Future iIncrementTheCounterBy1(WidgetTester tester) async { // TODO: Implement When I increment the counter by 1 } - static Future theCounterShouldHaveValue1(WidgetTester tester) async { + Future theCounterShouldHaveValue1(WidgetTester tester) async { + // TODO: Implement Then the counter should have value 1 + } + +} + +class Scenario2Scenario { + Future iHaveACounterWithValue0(WidgetTester tester) async { + // TODO: Implement Given I have a counter with value 0 + } + + Future iIncrementTheCounterBy1(WidgetTester tester) async { + // TODO: Implement When I increment the counter by 1 + } + + Future theCounterShouldHaveValue1(WidgetTester tester) async { + // TODO: Implement Then the counter should have value 1 + } + +} + +class Scenario3Scenario { + Future iHaveACounterWithValue0(WidgetTester tester) async { + // TODO: Implement Given I have a counter with value 0 + } + + Future iIncrementTheCounterBy1(WidgetTester tester) async { + // TODO: Implement When I increment the counter by 1 + } + + Future theCounterShouldHaveValue1(WidgetTester tester) async { + // TODO: Implement Then the counter should have value 1 + } + +} + +class Scenario4Scenario { + Future iHaveACounterWithValue0(WidgetTester tester) async { + // TODO: Implement Given I have a counter with value 0 + } + + Future iIncrementTheCounterBy1(WidgetTester tester) async { + // TODO: Implement When I increment the counter by 1 + } + + Future theCounterShouldHaveValue1(WidgetTester tester) async { + // TODO: Implement Then the counter should have value 1 + } + +} + +class Scenario5Scenario { + Future iHaveACounterWithValue0(WidgetTester tester) async { + // TODO: Implement Given I have a counter with value 0 + } + + Future iIncrementTheCounterBy1(WidgetTester tester) async { + // TODO: Implement When I increment the counter by 1 + } + + Future theCounterShouldHaveValue1(WidgetTester tester) async { // TODO: Implement Then the counter should have value 1 } diff --git a/example/test/features/feature1.bdd_test.dart b/example/test/features/feature1.bdd_test.dart index 8779301..079c613 100644 --- a/example/test/features/feature1.bdd_test.dart +++ b/example/test/features/feature1.bdd_test.dart @@ -4,13 +4,54 @@ import 'feature1.bdd_scenarios.dart'; void main() { group('Feature 1', () { testWidgets('Scenario 1', (tester) async { + final scenario = Scenario1Scenario(); //Scenario: Scenario 1 // Given I have a counter with value 0 - await Scenario1Scenario.iHaveACounterWithValue0(tester); + await scenario.iHaveACounterWithValue0(tester); // When I increment the counter by 1 - await Scenario1Scenario.iIncrementTheCounterBy1(tester); + await scenario.iIncrementTheCounterBy1(tester); // Then the counter should have value 1 - await Scenario1Scenario.theCounterShouldHaveValue1(tester); + await scenario.theCounterShouldHaveValue1(tester); + }); + testWidgets('Scenario 2', (tester) async { + final scenario = Scenario2Scenario(); + //Scenario: Scenario 2 + // Given I have a counter with value 0 + await scenario.iHaveACounterWithValue0(tester); + // When I increment the counter by 1 + await scenario.iIncrementTheCounterBy1(tester); + // Then the counter should have value 1 + await scenario.theCounterShouldHaveValue1(tester); + }); + testWidgets('Scenario 3', (tester) async { + final scenario = Scenario3Scenario(); + //Scenario: Scenario 3 + // Given I have a counter with value 0 + await scenario.iHaveACounterWithValue0(tester); + // When I increment the counter by 1 + await scenario.iIncrementTheCounterBy1(tester); + // Then the counter should have value 1 + await scenario.theCounterShouldHaveValue1(tester); + }); + testWidgets('Scenario 4', (tester) async { + final scenario = Scenario4Scenario(); + //Scenario: Scenario 4 + // Given I have a counter with value 0 + await scenario.iHaveACounterWithValue0(tester); + // When I increment the counter by 1 + await scenario.iIncrementTheCounterBy1(tester); + // Then the counter should have value 1 + await scenario.theCounterShouldHaveValue1(tester); + }); + testWidgets('Scenario 5', (tester) async { + final scenario = Scenario5Scenario(); + //Scenario: Scenario 5 + // Given I have a counter with value 0 + await scenario.iHaveACounterWithValue0(tester); + // When I increment the counter by 1 + await scenario.iIncrementTheCounterBy1(tester); + // Then the counter should have value 1 + await scenario.theCounterShouldHaveValue1(tester); }); }); } diff --git a/example/test/features/feature1.feature b/example/test/features/feature1.feature index d871bc5..68adb7f 100644 --- a/example/test/features/feature1.feature +++ b/example/test/features/feature1.feature @@ -4,3 +4,24 @@ Feature: Feature 1 Given I have a counter with value 0 When I increment the counter by 1 Then the counter should have value 1 + + Scenario: Scenario 2 + Given I have a counter with value 0 + When I increment the counter by 1 + Then the counter should have value 1 + + Scenario: Scenario 3 + Given I have a counter with value 0 + When I increment the counter by 1 + Then the counter should have value 1 + + Scenario: Scenario 4 + Given I have a counter with value 0 + When I increment the counter by 1 + Then the counter should have value 1 + + + Scenario: Scenario 5 + Given I have a counter with value 0 + When I increment the counter by 1 + Then the counter should have value 1 \ No newline at end of file diff --git a/example/test/features/feature2.bdd_scenarios.dart b/example/test/features/feature2.bdd_scenarios.dart index 20a2351..7459d9c 100644 --- a/example/test/features/feature2.bdd_scenarios.dart +++ b/example/test/features/feature2.bdd_scenarios.dart @@ -1,30 +1,30 @@ import 'package:flutter_test/flutter_test.dart'; class Scenario2Scenario { - static Future iHaveACounterWithValue0(WidgetTester tester) async { + Future iHaveACounterWithValue0(WidgetTester tester) async { // TODO: Implement Given I have a counter with value 0 } - static Future iIncrementTheCounterBy1(WidgetTester tester) async { + Future iIncrementTheCounterBy1(WidgetTester tester) async { // TODO: Implement When I increment the counter by 1 } - static Future theCounterShouldHaveValue1(WidgetTester tester) async { + Future theCounterShouldHaveValue1(WidgetTester tester) async { // TODO: Implement Then the counter should have value 1 } } class Scenario3Scenario { - static Future iHaveACounterWithValue0(WidgetTester tester) async { + Future iHaveACounterWithValue0(WidgetTester tester) async { // TODO: Implement Given I have a counter with value 0 } - static Future iIncrementTheCounterBy1(WidgetTester tester) async { + Future iIncrementTheCounterBy1(WidgetTester tester) async { // TODO: Implement When I increment the counter by 1 } - static Future theCounterShouldHaveValue1(WidgetTester tester) async { + Future theCounterShouldHaveValue1(WidgetTester tester) async { // TODO: Implement Then the counter should have value 1 } diff --git a/example/test/features/feature2.bdd_test.dart b/example/test/features/feature2.bdd_test.dart index 4a1da93..066027d 100644 --- a/example/test/features/feature2.bdd_test.dart +++ b/example/test/features/feature2.bdd_test.dart @@ -4,22 +4,24 @@ import 'feature2.bdd_scenarios.dart'; void main() { group('Feature 2', () { testWidgets('Scenario 2', (tester) async { + final scenario = Scenario2Scenario(); //Scenario: Scenario 2 // Given I have a counter with value 0 - await Scenario2Scenario.iHaveACounterWithValue0(tester); + await scenario.iHaveACounterWithValue0(tester); // When I increment the counter by 1 - await Scenario2Scenario.iIncrementTheCounterBy1(tester); + await scenario.iIncrementTheCounterBy1(tester); // Then the counter should have value 1 - await Scenario2Scenario.theCounterShouldHaveValue1(tester); + await scenario.theCounterShouldHaveValue1(tester); }); testWidgets('Scenario 3', (tester) async { + final scenario = Scenario3Scenario(); //Scenario: Scenario 3 // Given I have a counter with value 0 - await Scenario3Scenario.iHaveACounterWithValue0(tester); + await scenario.iHaveACounterWithValue0(tester); // When I increment the counter by 1 - await Scenario3Scenario.iIncrementTheCounterBy1(tester); + await scenario.iIncrementTheCounterBy1(tester); // Then the counter should have value 1 - await Scenario3Scenario.theCounterShouldHaveValue1(tester); + await scenario.theCounterShouldHaveValue1(tester); }); }); } diff --git a/example/test/features/feature3.bdd_scenarios.dart b/example/test/features/feature3.bdd_scenarios.dart index c1523fe..682dd72 100644 --- a/example/test/features/feature3.bdd_scenarios.dart +++ b/example/test/features/feature3.bdd_scenarios.dart @@ -1,15 +1,15 @@ import 'package:flutter_test/flutter_test.dart'; class Scenario3Scenario { - static Future iHaveACounterWithValue0(WidgetTester tester) async { + Future iHaveACounterWithValue0(WidgetTester tester) async { // TODO: Implement Given I have a counter with value 0 } - static Future iIncrementTheCounterBy1(WidgetTester tester) async { + Future iIncrementTheCounterBy1(WidgetTester tester) async { // TODO: Implement When I increment the counter by 1 } - static Future theCounterShouldHaveValue1(WidgetTester tester) async { + Future theCounterShouldHaveValue1(WidgetTester tester) async { // TODO: Implement Then the counter should have value 1 } diff --git a/example/test/features/feature3.bdd_test.dart b/example/test/features/feature3.bdd_test.dart index c2a9a27..52a30dd 100644 --- a/example/test/features/feature3.bdd_test.dart +++ b/example/test/features/feature3.bdd_test.dart @@ -4,13 +4,14 @@ import 'feature3.bdd_scenarios.dart'; void main() { group('Feature 3', () { testWidgets('Scenario 3', (tester) async { + final scenario = Scenario3Scenario(); //Scenario: Scenario 3 // Given I have a counter with value 0 - await Scenario3Scenario.iHaveACounterWithValue0(tester); + await scenario.iHaveACounterWithValue0(tester); // When I increment the counter by 1 - await Scenario3Scenario.iIncrementTheCounterBy1(tester); + await scenario.iIncrementTheCounterBy1(tester); // Then the counter should have value 1 - await Scenario3Scenario.theCounterShouldHaveValue1(tester); + await scenario.theCounterShouldHaveValue1(tester); }); }); } diff --git a/example/test/sample/sample.bdd_scenarios.dart b/example/test/sample/sample.bdd_scenarios.dart index 0c058ad..944e307 100644 --- a/example/test/sample/sample.bdd_scenarios.dart +++ b/example/test/sample/sample.bdd_scenarios.dart @@ -1,75 +1,75 @@ import 'package:flutter_test/flutter_test.dart'; class SampleScenario { - static Future iHaveASampleFeature(WidgetTester tester) async { + Future iHaveASampleFeature(WidgetTester tester) async { // TODO: Implement Given I have a sample feature } - static Future iRunTheSampleFeature(WidgetTester tester) async { + Future iRunTheSampleFeature(WidgetTester tester) async { // TODO: Implement When I run the sample feature } - static Future iShouldSeeTheSampleFeature(WidgetTester tester) async { + Future iShouldSeeTheSampleFeature(WidgetTester tester) async { // TODO: Implement Then I should see the sample feature } } -class CounterCustomName { - static Future iHaveACounter(WidgetTester tester) async { +class CounterScenario { + Future iHaveACounter(WidgetTester tester) async { // TODO: Implement Given I have a counter } - static Future iIncrementTheCounter(WidgetTester tester) async { + Future iIncrementTheCounter(WidgetTester tester) async { // TODO: Implement When I increment the counter } - static Future iShouldSeeTheCounterIncremented(WidgetTester tester) async { + Future iShouldSeeTheCounterIncremented(WidgetTester tester) async { // TODO: Implement Then I should see the counter incremented } } class CounterWithExamplesScenario { - static Future iHaveACounter() async { + Future iHaveACounter() async { // TODO: Implement Given I have a counter } - static Future iIncrementTheCounter(String counter) async { + Future iIncrementTheCounter(String counter) async { // TODO: Implement When I increment the } - static Future iShouldSeeTheCounterIncremented() async { + Future iShouldSeeTheCounterIncremented() async { // TODO: Implement Then I should see the counter incremented } } class CounterWithParametersScenario { - static Future iHaveACounter() async { + Future iHaveACounter() async { // TODO: Implement Given I have a counter } - static Future iIncrementTheCounterCounter(String counter) async { + Future iIncrementTheCounterCounter(String counter) async { // TODO: Implement When I increment the counter } - static Future iShouldSeeTheResultResult(String result) async { + Future iShouldSeeTheResultResult(String result) async { // TODO: Implement Then I should see the result } } class CounterWithWidgetTestScenario { - static Future iHaveACounter(WidgetTester tester) async { + Future iHaveACounter(WidgetTester tester) async { // TODO: Implement Given I have a counter } - static Future iIncrementTheCounter(WidgetTester tester) async { + Future iIncrementTheCounter(WidgetTester tester) async { // TODO: Implement When I increment the counter } - static Future iShouldSeeTheCounterIncremented(WidgetTester tester) async { + Future iShouldSeeTheCounterIncremented(WidgetTester tester) async { // TODO: Implement Then I should see the counter incremented } diff --git a/example/test/sample/sample.bdd_test.dart b/example/test/sample/sample.bdd_test.dart index c43c9f0..ca76f76 100644 --- a/example/test/sample/sample.bdd_test.dart +++ b/example/test/sample/sample.bdd_test.dart @@ -4,24 +4,27 @@ import 'sample.bdd_scenarios.dart'; void main() { group('Sample', () { testWidgets('Sample', (tester) async { + final scenario = SampleScenario(); //Scenario: Sample // Given I have a sample feature - await SampleScenario.iHaveASampleFeature(tester); + await scenario.iHaveASampleFeature(tester); // When I run the sample feature - await SampleScenario.iRunTheSampleFeature(tester); + await scenario.iRunTheSampleFeature(tester); // Then I should see the sample feature - await SampleScenario.iShouldSeeTheSampleFeature(tester); + await scenario.iShouldSeeTheSampleFeature(tester); }); testWidgets('Counter', (tester) async { + final scenario = CounterScenario(); //Scenario: Counter // Given I have a counter - await CounterCustomName.iHaveACounter(tester); + await scenario.iHaveACounter(tester); // When I increment the counter - await CounterCustomName.iIncrementTheCounter(tester); + await scenario.iIncrementTheCounter(tester); // Then I should see the counter incremented - await CounterCustomName.iShouldSeeTheCounterIncremented(tester); + await scenario.iShouldSeeTheCounterIncremented(tester); }); test('Counter with examples', () async { + final scenario = CounterWithExamplesScenario(); //Scenario: Counter with examples final examples = [ {'counter': '1',}, @@ -30,14 +33,15 @@ void main() { ]; for (var example in examples) { // Given I have a counter - await CounterWithExamplesScenario.iHaveACounter(); + await scenario.iHaveACounter(); // When I increment the - await CounterWithExamplesScenario.iIncrementTheCounter( example['counter']!); + await scenario.iIncrementTheCounter( example['counter']!); // Then I should see the counter incremented - await CounterWithExamplesScenario.iShouldSeeTheCounterIncremented(); + await scenario.iShouldSeeTheCounterIncremented(); } }); test('Counter with parameters', () async { + final scenario = CounterWithParametersScenario(); //Scenario: Counter with parameters final examples = [ {'counter': '1','result': '2',}, @@ -46,21 +50,22 @@ void main() { ]; for (var example in examples) { // Given I have a counter - await CounterWithParametersScenario.iHaveACounter(); + await scenario.iHaveACounter(); // When I increment the counter - await CounterWithParametersScenario.iIncrementTheCounterCounter( example['counter']!); + await scenario.iIncrementTheCounterCounter( example['counter']!); // Then I should see the result - await CounterWithParametersScenario.iShouldSeeTheResultResult( example['result']!); + await scenario.iShouldSeeTheResultResult( example['result']!); } }); testWidgets('Counter with widget test', (tester) async { + final scenario = CounterWithWidgetTestScenario(); //Scenario: Counter with widget test // Given I have a counter - await CounterWithWidgetTestScenario.iHaveACounter(tester); + await scenario.iHaveACounter(tester); // When I increment the counter - await CounterWithWidgetTestScenario.iIncrementTheCounter(tester); + await scenario.iIncrementTheCounter(tester); // Then I should see the counter incremented - await CounterWithWidgetTestScenario.iShouldSeeTheCounterIncremented(tester); + await scenario.iShouldSeeTheCounterIncremented(tester); }); }); } diff --git a/lib/bdd_flutter.dart b/lib/bdd_flutter.dart index fd40ce4..2806f50 100644 --- a/lib/bdd_flutter.dart +++ b/lib/bdd_flutter.dart @@ -1,6 +1,6 @@ library bdd_flutter; -export 'src/feature/report/test_reporter.dart' show BDDTestReporter; +export 'src/presentation/reporter/test_reporter.dart' show BDDTestReporter; /// A Flutter package for Behavior-Driven Development (BDD) testing. /// diff --git a/lib/src/constraints/file_extenstion.dart b/lib/src/constraints/file_constraint.dart similarity index 92% rename from lib/src/constraints/file_extenstion.dart rename to lib/src/constraints/file_constraint.dart index bc7bfa4..9059ccb 100644 --- a/lib/src/constraints/file_extenstion.dart +++ b/lib/src/constraints/file_constraint.dart @@ -1,4 +1,4 @@ -class FileExtension { +class FileConstraint { static const String feature = '.feature'; static const String generatedTest = '.bdd_test.dart'; static const String generatedScenarios = '.bdd_scenarios.dart'; diff --git a/lib/src/domain/background.dart b/lib/src/domain/background.dart new file mode 100644 index 0000000..c7121a4 --- /dev/null +++ b/lib/src/domain/background.dart @@ -0,0 +1,8 @@ +import 'step.dart'; + +class Background { + String description; + List steps; + + Background({required this.description, required this.steps}); +} diff --git a/lib/src/domain/decorator.dart b/lib/src/domain/decorator.dart new file mode 100644 index 0000000..23f1ebf --- /dev/null +++ b/lib/src/domain/decorator.dart @@ -0,0 +1,37 @@ +enum Decorator { + unitTest, + widgetTest, + enableReporter, + unknown; + + static Decorator fromString(String text) { + return switch (text) { '@unitTest' => Decorator.unitTest, '@widgetTest' => Decorator.widgetTest, '@enableReporter' => Decorator.enableReporter, _ => Decorator.unknown }; + } + + static Set elligibleForScenario() { + return { + Decorator.unitTest, + Decorator.widgetTest, + }; + } + + static Set elligibleForFeature() { + return { + Decorator.unitTest, + Decorator.widgetTest, + Decorator.enableReporter, + }; + } +} + +extension DecoratorX on Decorator { + bool get isUnitTest => this == Decorator.unitTest; + bool get isWidgetTest => this == Decorator.widgetTest; + bool get isEnableReporter => this == Decorator.enableReporter; +} + +extension DecoratorSetX on Set { + bool get hasUnitTest => any((e) => e.isUnitTest); + bool get hasWidgetTest => any((e) => e.isWidgetTest); + bool get hasEnableReporter => any((e) => e.isEnableReporter); +} diff --git a/lib/src/domain/feature.dart b/lib/src/domain/feature.dart new file mode 100644 index 0000000..86e71d2 --- /dev/null +++ b/lib/src/domain/feature.dart @@ -0,0 +1,35 @@ +import '../constraints/file_constraint.dart'; + +import 'background.dart'; +import 'decorator.dart'; +import 'scenario.dart'; + +class Feature { + String name; + String path; + List scenarios; + Background? background; + Set decorators; + + Feature({ + required this.name, + required this.path, + required this.scenarios, + required this.decorators, + this.background, + }); +} + +extension FeatureX on Feature { + String get scenariosFileName { + return '${fileName.replaceAll('.feature', '')}${FileConstraint.generatedScenarios}'; + } + + String get testFileName { + return '${fileName.replaceAll('.feature', '')}${FileConstraint.generatedTest}'; + } + + String get fileName { + return path.split('/').last.replaceAll('.feature', ''); + } +} diff --git a/lib/src/feature/builder/domain/scenario.dart b/lib/src/domain/scenario.dart similarity index 53% rename from lib/src/feature/builder/domain/scenario.dart rename to lib/src/domain/scenario.dart index db58264..db75df5 100644 --- a/lib/src/feature/builder/domain/scenario.dart +++ b/lib/src/domain/scenario.dart @@ -1,4 +1,6 @@ -import 'package:bdd_flutter/src/extensions/string_x.dart'; +import 'dart:convert'; +import '../extensions/string_x.dart'; +import 'package:crypto/crypto.dart'; import 'decorator.dart'; import 'step.dart'; @@ -6,18 +8,30 @@ import 'step.dart'; /// A scenario is a collection of steps class Scenario { /// The name of the scenario - final String name; + String name; /// The steps of the scenario - final List steps; + List steps; /// The examples of the scenario - final List>? examples; + List>? examples; /// The decorators of the scenario - final Set decorators; - - Scenario(this.name, this.steps, {this.examples, this.decorators = const {}}); + Set decorators; + + Scenario( + this.name, + this.steps, { + this.examples, + this.decorators = const {}, + }); + + factory Scenario.init() => Scenario( + '', + [], + examples: [], + decorators: {}, + ); @override String toString() { @@ -30,9 +44,10 @@ extension ScenarioX on Scenario { bool get isWidgetTest => decorators.hasWidgetTest; String get className { - if (decorators.hasClassName) { - return decorators.firstWhere((e) => e.isClassName).value!; - } return name.toScenarioClassName; } + + String get getHash { + return md5.convert(utf8.encode(toString())).toString(); + } } diff --git a/lib/src/feature/builder/domain/step.dart b/lib/src/domain/step.dart similarity index 100% rename from lib/src/feature/builder/domain/step.dart rename to lib/src/domain/step.dart diff --git a/lib/src/extensions/string_x.dart b/lib/src/extensions/string_x.dart index ab6db4f..5139363 100644 --- a/lib/src/extensions/string_x.dart +++ b/lib/src/extensions/string_x.dart @@ -8,7 +8,10 @@ extension StringX on String { } String get snakeCaseToCamelCase { - return split('_').map((word) => word[0].toLowerCase() + word.substring(1).toLowerCase()).join(''); + final parts = split('_'); + if (parts.isEmpty) return this; + return parts[0].toLowerCase() + + parts.skip(1).map((word) => word[0].toUpperCase() + word.substring(1).toLowerCase()).join(''); } String get toSnakeCase { diff --git a/lib/src/feature/builder/bdd_builders/bdd_factory.dart b/lib/src/feature/builder/bdd_builders/bdd_factory.dart deleted file mode 100644 index 9164a29..0000000 --- a/lib/src/feature/builder/bdd_builders/bdd_factory.dart +++ /dev/null @@ -1,24 +0,0 @@ -import '../domain/bdd_options.dart'; -import 'bdd_feature_builder.dart'; -import 'bdd_test_file_builder.dart'; -import 'scenario_file_builder.dart'; - -class BDDFactory { - final BDDFeatureBuilder featureBuilder; - final ScenariosFileBuilder scenarioBuilder; - final BDDTestFileBuilder testFileBuilder; - - BDDFactory({ - required this.featureBuilder, - required this.scenarioBuilder, - required this.testFileBuilder, - }); - - factory BDDFactory.create(BDDOptions options) { - return BDDFactory( - featureBuilder: BDDFeatureBuilder(options: options), - scenarioBuilder: ScenariosFileBuilder(), - testFileBuilder: BDDTestFileBuilder(), - ); - } -} diff --git a/lib/src/feature/builder/bdd_builders/bdd_feature_builder.dart b/lib/src/feature/builder/bdd_builders/bdd_feature_builder.dart deleted file mode 100644 index 4a93fa0..0000000 --- a/lib/src/feature/builder/bdd_builders/bdd_feature_builder.dart +++ /dev/null @@ -1,181 +0,0 @@ -import '../domain/background.dart'; -import '../domain/bdd_options.dart'; -import '../domain/decorator.dart'; -import '../domain/feature.dart'; -import '../domain/scenario.dart'; -import '../domain/step.dart'; -import '../domain/validators/decorators_validator.dart'; - -class BDDFeatureBuilder { - final BDDOptions options; - - BDDFeatureBuilder({required this.options}); - - Feature parseFeature(String featureContent) { - final lines = featureContent.split('\n').map((line) => line.trim()).toList(); - String? featureName; - List scenarios = []; - List currentSteps = []; - String? currentScenarioName; - List>? currentExamples; - List? exampleHeaders; - Set featureDecorators = {}; - Set currentScenarioDecorators = {}; - Background? background; - Map> scenarioDecoratorsMap = {}; - - for (var i = 0; i < lines.length; i++) { - final line = lines[i]; - - if (line.startsWith('Feature:')) { - featureName = line.substring('Feature:'.length).trim(); - } else if (line.startsWith('@ignore')) { - // If @ignore is found, return an empty feature to skip generation - return Feature('', []); - } else if (line.startsWith('Background:')) { - background = Background( - description: line.substring('Background:'.length).trim(), - steps: [], - ); - i++; - if (i < lines.length) { - final backgroundLine = lines[i]; - if (backgroundLine.startsWith('Given') || backgroundLine.startsWith('When') || backgroundLine.startsWith('Then')) { - background.steps.add( - Step( - backgroundLine.split(' ')[0], - backgroundLine.substring(backgroundLine.split(' ')[0].length).trim(), - ), - ); - } - } - } else if (line.startsWith('@')) { - if (featureName == null) { - featureDecorators.add(BDDDecorator.fromString(line)); - } else { - currentScenarioDecorators.add(BDDDecorator.fromString(line)); - } - } else if (line.startsWith('Scenario:')) { - // Store decorators for the current scenario before adding it - if (currentScenarioName != null && currentSteps.isNotEmpty) { - Set tempDecorators = {}; - // Get decorators for the previous scenario - if (scenarioDecoratorsMap[scenarios.length] != null) { - tempDecorators = scenarioDecoratorsMap[scenarios.length]!; - } - final proccessedDecorators = _processDecorators( - tempDecorators, - featureDecorators, - ); - scenarios.add( - Scenario( - currentScenarioName, - List.from(currentSteps), - examples: currentExamples, - decorators: proccessedDecorators, - ), - ); - currentSteps.clear(); - } - - // Store decorators for the new scenario - scenarioDecoratorsMap[scenarios.length] = {...currentScenarioDecorators}; - currentScenarioDecorators.clear(); - - // if scenarios.isEmpty, which means the first scenario - // here is the safe place to process the feature decorators for once - if (scenarios.isEmpty) { - featureDecorators = DecoratorsValidator.getValidFeatureDecorator( - featureDecorators, - options, - ); - } - currentScenarioName = line.substring('Scenario:'.length).trim(); - currentExamples = null; - exampleHeaders = null; - } else if (line.startsWith('Given') || line.startsWith('When') || line.startsWith('Then') || line.startsWith('And') || line.startsWith('But')) { - final keyword = line.split(' ')[0]; - final text = line.substring(keyword.length).trim(); - currentSteps.add(Step(keyword, text)); - } else if (line.startsWith('Examples:')) { - currentExamples = []; - exampleHeaders = null; - // Skip the header row - i++; - if (i < lines.length) { - final headerLine = lines[i]; - if (headerLine.startsWith('|')) { - exampleHeaders = headerLine.split('|').map((cell) => cell.trim()).where((cell) => cell.isNotEmpty).toList(); - } - } - } else if (line.startsWith('|') && currentExamples != null && exampleHeaders != null) { - final cells = line.split('|').map((cell) => cell.trim()).where((cell) => cell.isNotEmpty).toList(); - - if (cells.length == exampleHeaders.length) { - currentExamples.add(Map.fromIterables(exampleHeaders, cells)); - } - } - } - - if (currentScenarioName != null && currentSteps.isNotEmpty) { - final proccessedDecorators = _processDecorators( - scenarioDecoratorsMap[scenarios.length] ?? {}, - featureDecorators, - ); - - final scenario = Scenario( - currentScenarioName, - currentSteps, - examples: currentExamples, - decorators: proccessedDecorators, - ); - scenarios.add(scenario); - currentScenarioDecorators.clear(); - } - - if (featureName == null) { - throw Exception('No Feature defined in the file'); - } - - final feature = Feature( - featureName, - scenarios, - background: background, - decorators: featureDecorators, - ); - // scenarioDecoratorsMap.clear(); - // currentScenarioDecorators.clear(); - // featureDecorators.clear(); - return feature; - } - - /// pass the decorators from the feature and the scenario - /// and return the decorators that should be used for the scenario - /// decorators on the scenario will override the decorators on the feature - Set _processDecorators( - Set decorators, - Set featureDecorators, - ) { - DecoratorsValidator.validateScenarioDecorators(decorators); - - // If no decorators are specified, use the default from options - if (!decorators.hasUnitTest && !decorators.hasWidgetTest) { - if (!featureDecorators.hasUnitTest && !featureDecorators.hasWidgetTest) { - return options.generateWidgetTests ? {...decorators, BDDDecorator.widgetTest()} : {...decorators, BDDDecorator.unitTest()}; - } - return {...decorators, ...featureDecorators}; - } - - // if the feature has @unitTest and the scenario has @widgetTest, - // then remove the @unitTest from the scenario decorators - if (featureDecorators.hasUnitTest && decorators.hasWidgetTest) { - return {...featureDecorators, ...decorators}..removeWhere((e) => e.isUnitTest); - } - // if the feature has @widgetTest and the scenario has @unitTest, - // then remove the @widgetTest from the scenario decorators - if (featureDecorators.hasWidgetTest && decorators.hasUnitTest) { - return {...featureDecorators, ...decorators}..removeWhere((e) => e.isWidgetTest); - } - return {...featureDecorators, ...decorators}; - } -} diff --git a/lib/src/feature/builder/domain/background.dart b/lib/src/feature/builder/domain/background.dart deleted file mode 100644 index 186cee2..0000000 --- a/lib/src/feature/builder/domain/background.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:bdd_flutter/src/feature/builder/domain/step.dart' show Step; - -class Background { - final String description; - final List steps; - - Background({required this.description, required this.steps}); -} diff --git a/lib/src/feature/builder/domain/bdd_options.dart b/lib/src/feature/builder/domain/bdd_options.dart deleted file mode 100644 index e3e6714..0000000 --- a/lib/src/feature/builder/domain/bdd_options.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'dart:io'; -import 'package:yaml/yaml.dart'; - -class BDDOptions { - final bool generateWidgetTests; - final bool enableReporter; - final List ignoreFeatures; - final bool force; - final bool newOnly; - final List only; - - BDDOptions({ - required this.generateWidgetTests, - required this.enableReporter, - required this.ignoreFeatures, - this.force = false, - this.newOnly = false, - this.only = const [], - }); - - factory BDDOptions.defaultOptions() => BDDOptions(enableReporter: false, generateWidgetTests: true, ignoreFeatures: []); - - BDDOptions copyWith({ - bool? generateWidgetTests, - bool? enableReporter, - List? ignoreFeatures, - bool? force, - bool? newOnly, - List? only, - }) { - return BDDOptions( - generateWidgetTests: generateWidgetTests ?? this.generateWidgetTests, - enableReporter: enableReporter ?? this.enableReporter, - ignoreFeatures: ignoreFeatures ?? this.ignoreFeatures, - force: force ?? this.force, - newOnly: newOnly ?? this.newOnly, - only: only ?? this.only, - ); - } - - static const String bddDir = '.bdd_flutter'; - static const String configPath = '$bddDir/config.yaml'; - - // static Future ensureBDDDirectory() async { - // final dir = Directory(bddDir); - // if (!await dir.exists()) { - // await dir.create(); - // } - // } - - static Future fromConfig() async { - final configFile = File(configPath); - if (!await configFile.exists()) { - return BDDOptions.defaultOptions(); - } - - // Start with default values - bool generateWidgetTests = true; - bool enableReporter = false; - List ignoreFeatures = []; - - if (await configFile.exists()) { - try { - final yaml = loadYaml(await configFile.readAsString()); - if (yaml != null) { - generateWidgetTests = yaml['generate_widget_tests'] as bool? ?? generateWidgetTests; - enableReporter = yaml['enable_reporter'] as bool? ?? enableReporter; - ignoreFeatures = (yaml['ignore_features'] as YamlList?)?.cast() ?? ignoreFeatures; - } - } catch (e) { - print('Warning: Failed to parse config file: $e'); - } - } - - return BDDOptions( - generateWidgetTests: generateWidgetTests, - enableReporter: enableReporter, - ignoreFeatures: ignoreFeatures, - ); - } - - // static Future writeConfig(BDDOptions options) async { - // await ensureBDDDirectory(); - // final configFile = File(configPath); - - // final config = { - // 'generate_widget_tests': options.generateWidgetTests, - // 'enable_reporter': options.enableReporter, - // 'ignore_features': options.ignoreFeatures, - // }; - - // await configFile.writeAsString(config.toString()); - // } -} diff --git a/lib/src/feature/builder/domain/decorator.dart b/lib/src/feature/builder/domain/decorator.dart deleted file mode 100644 index 321f713..0000000 --- a/lib/src/feature/builder/domain/decorator.dart +++ /dev/null @@ -1,68 +0,0 @@ -class BDDDecorator { - final DecoratorType type; - final String? value; - - BDDDecorator(this.type, this.value); - factory BDDDecorator.unitTest() => BDDDecorator(DecoratorType.unitTest, null); - factory BDDDecorator.widgetTest() => BDDDecorator(DecoratorType.widgetTest, null); - factory BDDDecorator.enableReporter() => BDDDecorator(DecoratorType.enableReporter, null); - factory BDDDecorator.disableReporter() => BDDDecorator(DecoratorType.disableReporter, null); - factory BDDDecorator.className(String name) => BDDDecorator(DecoratorType.className, name); - static BDDDecorator fromString(String text) { - return switch (text) { - '@unitTest' => BDDDecorator(DecoratorType.unitTest, null), - '@widgetTest' => BDDDecorator(DecoratorType.widgetTest, null), - '@enableReporter' => BDDDecorator(DecoratorType.enableReporter, null), - '@disableReporter' => BDDDecorator(DecoratorType.disableReporter, null), - var t when t.contains("@className") => BDDDecorator(DecoratorType.className, _extractClassNameValue(t)), - _ => BDDDecorator(DecoratorType.unknown, null) - }; - } - - @override - String toString() { - return '@$type${value != null ? '("$value")' : ''}'; - } - - @override - bool operator ==(Object other) { - if (other is BDDDecorator) { - return type == other.type && value == other.value; - } - return false; - } - - @override - int get hashCode => Object.hash(type, value); -} - -enum DecoratorType { - unitTest, - widgetTest, - className, - enableReporter, - disableReporter, - unknown, -} - -extension BDDDecoratorX on BDDDecorator { - bool get isUnitTest => type == DecoratorType.unitTest; - bool get isWidgetTest => type == DecoratorType.widgetTest; - bool get isClassName => type == DecoratorType.className; - bool get isEnableReporter => type == DecoratorType.enableReporter; - bool get isDisableReporter => type == DecoratorType.disableReporter; -} - -extension BDDDecoratorSetX on Set { - bool get hasUnitTest => any((e) => e.isUnitTest); - bool get hasWidgetTest => any((e) => e.isWidgetTest); - bool get hasClassName => any((e) => e.isClassName); - bool get hasEnableReporter => any((e) => e.isEnableReporter); - bool get hasDisableReporter => any((e) => e.isDisableReporter); -} - -String? _extractClassNameValue(String text) { - final regex = RegExp(r'@className\("([^"]+)"\)'); - final match = regex.firstMatch(text); - return match?.group(1); -} diff --git a/lib/src/feature/builder/domain/feature.dart b/lib/src/feature/builder/domain/feature.dart deleted file mode 100644 index d14d2fe..0000000 --- a/lib/src/feature/builder/domain/feature.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'background.dart'; -import 'decorator.dart'; -import 'scenario.dart'; - -/// A feature is a collection of scenarios -class Feature { - /// The name of the feature - final String name; - - /// The scenarios of the feature - final List scenarios; - - /// The decorators of the feature - final Set decorators; - - /// The background of the feature - final Background? background; - String? fileName; - - Feature( - this.name, - this.scenarios, { - this.fileName, - this.decorators = const {}, - this.background, - }); - void setFileName(String value) { - this.fileName = value; - } -} diff --git a/lib/src/feature/builder/domain/manifest.dart b/lib/src/feature/builder/domain/manifest.dart deleted file mode 100644 index c4d38b2..0000000 --- a/lib/src/feature/builder/domain/manifest.dart +++ /dev/null @@ -1,202 +0,0 @@ -import 'dart:io'; - -import 'package:yaml/yaml.dart'; - -class Manifest { - final String version; - DateTime lastGenerated; - final List features; - - Manifest({ - required this.version, - required this.lastGenerated, - required this.features, - }); - - factory Manifest.initial() { - return Manifest( - version: '1.0', - lastGenerated: DateTime.now(), - features: [], - ); - } - - String toYaml() { - final buffer = StringBuffer(); - buffer.writeln('version: "$version"'); - buffer.writeln('last_generated: "${lastGenerated.toIso8601String()}"'); - buffer.writeln('features:'); - for (final feature in features) { - buffer.writeln(' - path: "${feature.path}"'); - buffer.writeln(' last_modified: "${feature.lastModified.toIso8601String()}"'); - buffer.writeln(' test_file: "${feature.testFile}"'); - buffer.writeln(' scenarios:'); - for (final scenario in feature.scenarios) { - buffer.writeln(' - name: "${scenario.name}"'); - buffer.writeln(' hash: "${scenario.hash}"'); - buffer.writeln(' line_start: ${scenario.lineStart}'); - buffer.writeln(' line_end: ${scenario.lineEnd}'); - buffer.writeln(' test_method: "${scenario.testMethod}"'); - } - } - return buffer.toString(); - } - - factory Manifest.fromYaml(Map yaml) { - return Manifest( - version: yaml['version'] as String, - lastGenerated: DateTime.parse(yaml['last_generated'] as String), - features: (yaml['features'] as List).map((f) => FeatureEntry.fromYaml(f as Map)).toList(), - ); - } -} - -class FeatureEntry { - final String path; - final DateTime lastModified; - final String testFile; - final List scenarios; - - FeatureEntry({ - required this.path, - required this.lastModified, - required this.testFile, - required this.scenarios, - }); - - Map toYaml() { - return { - 'path': path, - 'last_modified': lastModified.toIso8601String(), - 'test_file': testFile, - 'scenarios': scenarios.map((s) => s.toYaml()).toList(), - }; - } - - factory FeatureEntry.fromYaml(Map yaml) { - return FeatureEntry( - path: yaml['path'] as String, - lastModified: DateTime.parse(yaml['last_modified'] as String), - testFile: yaml['test_file'] as String, - scenarios: (yaml['scenarios'] as List).map((s) => ScenarioEntry.fromYaml(s as Map)).toList(), - ); - } -} - -class ScenarioEntry { - final String name; - final String hash; - final int lineStart; - final int lineEnd; - final String testMethod; - - ScenarioEntry({ - required this.name, - required this.hash, - required this.lineStart, - required this.lineEnd, - required this.testMethod, - }); - - ScenarioEntry copyWith({ - String? name, - String? hash, - int? lineStart, - int? lineEnd, - String? testMethod, - }) { - return ScenarioEntry( - name: name ?? this.name, - hash: hash ?? this.hash, - lineStart: lineStart ?? this.lineStart, - lineEnd: lineEnd ?? this.lineEnd, - testMethod: testMethod ?? this.testMethod, - ); - } - - Map toYaml() { - return { - 'name': name, - 'hash': hash, - 'line_start': lineStart, - 'line_end': lineEnd, - 'test_method': testMethod, - }; - } - - factory ScenarioEntry.fromYaml(Map yaml) { - return ScenarioEntry( - name: yaml['name'] as String, - hash: yaml['hash'] as String, - lineStart: yaml['line_start'] as int, - lineEnd: yaml['line_end'] as int, - testMethod: yaml['test_method'] as String, - ); - } -} - -class ManifestManager { - static const String bddDir = '.bdd_flutter'; - static const String manifestPath = '$bddDir/manifest.yaml'; - - Future ensureBDDDirectory() async { - final dir = Directory(bddDir); - if (!await dir.exists()) { - await dir.create(); - } - } - - Future readManifest() async { - await ensureBDDDirectory(); - final file = File(manifestPath); - - if (!await file.exists()) { - return Manifest.initial(); - } - - try { - final content = await file.readAsString(); - final yaml = loadYaml(content); - // Convert YamlMap to Map and handle null values - final Map yamlMap = Map.from(yaml as Map); - - // Ensure all required fields exist with default values - final features = (yamlMap['features'] as List?) ?? []; - final version = yamlMap['version'] as String? ?? '1.0'; - final lastGenerated = yamlMap['last_generated'] as String? ?? DateTime.now().toIso8601String(); - - return Manifest( - version: version, - lastGenerated: DateTime.parse(lastGenerated), - features: features.map((f) { - final featureMap = Map.from(f as Map); - return FeatureEntry( - path: featureMap['path'] as String? ?? '', - lastModified: DateTime.parse(featureMap['last_modified'] as String? ?? DateTime.now().toIso8601String()), - testFile: featureMap['test_file'] as String? ?? '', - scenarios: (featureMap['scenarios'] as List?)?.map((s) { - final scenarioMap = Map.from(s as Map); - return ScenarioEntry( - name: scenarioMap['name'] as String? ?? '', - hash: scenarioMap['hash'] as String? ?? '', - lineStart: scenarioMap['line_start'] as int? ?? 0, - lineEnd: scenarioMap['line_end'] as int? ?? 0, - testMethod: scenarioMap['test_method'] as String? ?? '', - ); - }).toList() ?? - [], - ); - }).toList(), - ); - } catch (e) { - print('Warning: Failed to parse manifest file: $e'); - return Manifest.initial(); - } - } - - Future writeManifest(Manifest manifest) async { - await ensureBDDDirectory(); - final file = File(manifestPath); - await file.writeAsString(manifest.toYaml()); - } -} diff --git a/lib/src/feature/builder/domain/validators/decorators_validator.dart b/lib/src/feature/builder/domain/validators/decorators_validator.dart deleted file mode 100644 index df35513..0000000 --- a/lib/src/feature/builder/domain/validators/decorators_validator.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:bdd_flutter/src/feature/builder/domain/bdd_options.dart'; - -import '../decorator.dart'; - -class DecoratorsValidator { - static final _validFeatureDecoratorTypes = [ - DecoratorType.enableReporter, - DecoratorType.disableReporter, - DecoratorType.unitTest, - DecoratorType.widgetTest, - ]; - - static final _validScenarioDecoratorTypes = [ - DecoratorType.unitTest, - DecoratorType.widgetTest, - DecoratorType.className, - ]; - - /// Get valid feature decorators - /// - /// This method validates the feature decorators and returns a new set of decorators - /// that are valid. It also overrides the builder options if necessary. - static Set getValidFeatureDecorator( - Set decorators, - BDDOptions builderOptions, - ) { - final tempSet = {...decorators}; - // check invalid decorators - for (final decorator in tempSet) { - if (!_validFeatureDecoratorTypes.contains(decorator.type)) { - throw Exception( - 'Invalid feature decorator: ${decorator.type}', - ); - } - } - // check invalid decorators combination - if (tempSet.hasUnitTest && tempSet.hasWidgetTest) { - throw Exception( - 'Cannot have both @unitTest and @widgetTest decorators at the same time', - ); - } - - if (tempSet.hasEnableReporter && tempSet.hasDisableReporter) { - throw Exception( - 'Cannot have both @enableReporter and @disableReporter decorators at the same time', - ); - } - - //by default reporter is disabled, if enableReporter is set, override it - if (builderOptions.enableReporter && !tempSet.hasDisableReporter) { - tempSet.add(BDDDecorator.enableReporter()); - } - return tempSet; - } - - /// Get valid scenario decorators - /// - /// This method validates the scenario decorators and returns a new set of decorators - /// that are valid. It also overrides the builder options if necessary. - static validateScenarioDecorators( - Set decorators, - ) { - // check invalid decorators - for (final decorator in decorators) { - if (!_validScenarioDecoratorTypes.contains(decorator.type)) { - throw Exception( - 'Invalid scenario decorator: ${decorator.type}', - ); - } - } - - // check invalid decorators combination - if (decorators.hasUnitTest && decorators.hasWidgetTest) { - throw Exception( - 'Cannot have both @unitTest and @widgetTest decorators at the same time', - ); - } - } -} diff --git a/lib/src/feature/logger/logger.dart b/lib/src/feature/logger/logger.dart deleted file mode 100644 index 59d9441..0000000 --- a/lib/src/feature/logger/logger.dart +++ /dev/null @@ -1,65 +0,0 @@ -/// Log levels for different types of messages -enum LogLevel { - info, - warning, - error, - debug, - verbose, - lean, -} - -/// A class to handle logging to the terminal -class CLILogger { - static final CLILogger _instance = CLILogger._internal(); - factory CLILogger() => _instance; - CLILogger._internal(); - - /// Log a message with the specified level - void log(String message, {LogLevel level = LogLevel.info}) { - final prefix = _getPrefix(level); - print('$prefix $message'); - } - - void logLean(String message) { - log(message, level: LogLevel.lean); - } - - /// Log a message about skipping a file - void logSkipping(String path, {String? reason}) { - final message = reason != null ? 'Skipping $path ($reason)' : 'Skipping $path'; - log(message); - } - - /// Log a message about processing a file - void logProcessing(String path, {String? reason}) { - final message = reason != null ? 'Processing $path ($reason)' : 'Processing $path'; - log(message); - } - - /// Log a warning message - void warning(String message) { - log(message, level: LogLevel.warning); - } - - /// Log an error message - void error(String message) { - log(message, level: LogLevel.error); - } - - String _getPrefix(LogLevel level) { - switch (level) { - case LogLevel.info: - return 'โ„น๏ธ'; - case LogLevel.warning: - return 'โš ๏ธ'; - case LogLevel.error: - return 'โŒ'; - case LogLevel.debug: - return '๐Ÿ›'; - case LogLevel.verbose: - return '๐Ÿ”'; - case LogLevel.lean: - return ''; - } - } -} diff --git a/lib/src/feature/builder/bdd_builders/scenario_file_builder.dart b/lib/src/infrastructure/builders/scenario_file_builder.dart similarity index 57% rename from lib/src/feature/builder/bdd_builders/scenario_file_builder.dart rename to lib/src/infrastructure/builders/scenario_file_builder.dart index 77a1715..15516b0 100644 --- a/lib/src/feature/builder/bdd_builders/scenario_file_builder.dart +++ b/lib/src/infrastructure/builders/scenario_file_builder.dart @@ -1,8 +1,7 @@ -import '../domain/step.dart'; - -import '../domain/feature.dart'; -import '../domain/scenario.dart'; -import '../../../extensions/string_x.dart'; +import '../../extensions/string_x.dart'; +import '../../domain/feature.dart'; +import '../../domain/scenario.dart'; +import '../../domain/step.dart'; class ScenariosFileBuilder { Future buildScenarioFile(Feature feature) async { @@ -14,9 +13,9 @@ class ScenariosFileBuilder { buffer.writeln("class ${feature.name}Background {"); for (var step in feature.background!.steps) { final methodName = step.methodName; - final params = _extractMethodParams(step.text); + final params = extractMethodParams(step.text); buffer.writeln( - " static Future $methodName(${params.isNotEmpty ? params : ''}) async {", + " Future $methodName(${params.isNotEmpty ? params : ''}) async {", ); buffer.writeln(" // TODO: Implement ${step.keyword} ${step.text}"); buffer.writeln(" }"); @@ -25,24 +24,25 @@ class ScenariosFileBuilder { buffer.writeln("}"); buffer.writeln(); } + // Create a class for each scenario for (var scenario in feature.scenarios) { final isUnitTest = scenario.isUnitTest; buffer.writeln("class ${scenario.className} {"); - // Create static methods for each step in the scenario + // Create instance methods for each step in the scenario for (var step in scenario.steps) { final methodName = step.methodName; - final params = _extractMethodParams(step.text); + final params = extractMethodParams(step.text); if (!isUnitTest) { buffer.writeln( - " static Future $methodName(WidgetTester tester${params.isNotEmpty ? ', $params' : ''}) async {", + " Future $methodName(WidgetTester tester${params.isNotEmpty ? ', $params' : ''}) async {", ); } else { buffer.writeln( - " static Future $methodName(${params.isNotEmpty ? params : ''}) async {", + " Future $methodName(${params.isNotEmpty ? params : ''}) async {", ); } buffer.writeln(" // TODO: Implement ${step.keyword} ${step.text}"); @@ -56,17 +56,17 @@ class ScenariosFileBuilder { return buffer.toString(); } +} - String _extractMethodParams(String stepText) { - final params = []; - final regex = RegExp(r'<(\w+)>'); - final matches = regex.allMatches(stepText); - - for (var match in matches) { - final paramName = match.group(1)!; - params.add('String ${paramName.snakeCaseToCamelCase}'); - } +String extractMethodParams(String stepText) { + final params = []; + final regex = RegExp(r'<(\w+)>'); + final matches = regex.allMatches(stepText); - return params.join(', '); + for (var match in matches) { + final paramName = match.group(1)!; + params.add('String ${paramName.snakeCaseToCamelCase}'); } + + return params.join(', '); } diff --git a/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart b/lib/src/infrastructure/builders/test_file_builder.dart similarity index 73% rename from lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart rename to lib/src/infrastructure/builders/test_file_builder.dart index cd8d45b..fe60b88 100644 --- a/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart +++ b/lib/src/infrastructure/builders/test_file_builder.dart @@ -1,13 +1,11 @@ -import '../../../constraints/file_extenstion.dart'; -import '../../../extensions/string_x.dart'; -import '../domain/decorator.dart'; -import '../domain/feature.dart'; -import '../domain/scenario.dart'; -import '../domain/step.dart'; - -final spaceStep = ' '; - -class BDDTestFileBuilder { +import '../../constraints/file_constraint.dart'; +import '../../domain/decorator.dart'; +import '../../domain/feature.dart'; +import '../../domain/scenario.dart'; +import '../../domain/step.dart'; +import '../../extensions/string_x.dart'; + +class TestFileBuilder { Future buildTestFile(Feature feature) async { final buffer = StringBuffer(); buffer.writeln("import 'package:flutter_test/flutter_test.dart';"); @@ -16,7 +14,7 @@ class BDDTestFileBuilder { buffer.writeln("import 'package:bdd_flutter/bdd_flutter.dart';"); } - buffer.writeln("import '${feature.fileName ?? feature.name.toSnakeCase}${FileExtension.generatedScenarios}';"); + buffer.writeln("import '${feature.fileName}${FileConstraint.generatedScenarios}';"); buffer.writeln(); buffer.writeln("void main() {"); @@ -35,14 +33,6 @@ class BDDTestFileBuilder { buffer.writeln(" group('${feature.name}', () {"); - if (feature.background != null) { - buffer.writeln(" //Background: ${feature.background!.description}"); - for (var step in feature.background!.steps) { - final methodName = step.methodName; - buffer.writeln(" ${feature.name}Background.$methodName();"); - } - } - for (var scenario in feature.scenarios) { final className = scenario.className; final isUnitTest = scenario.isUnitTest; @@ -55,6 +45,18 @@ class BDDTestFileBuilder { buffer.writeln(" $testFunction('${scenario.name}', (tester) async {"); } + // Instantiate scenario and background + buffer.writeln(" final scenario = $className();"); + + if (feature.background != null) { + buffer.writeln(" final background = ${feature.name}Background();"); + buffer.writeln(" //Background: ${feature.background!.description}"); + for (var step in feature.background!.steps) { + final methodName = step.methodName; + buffer.writeln(" await background.$methodName();"); + } + } + buffer.writeln(" //Scenario: ${scenario.name}"); //add start scenario if needed @@ -87,11 +89,7 @@ class BDDTestFileBuilder { } } - buffer.writeln(_generateTestFunction( - buffer, - testFunction, - scenario.name, - className, + buffer.writeln(_generateStepCall( step, feature.decorators.hasEnableReporter, isUnitTest, @@ -102,11 +100,7 @@ class BDDTestFileBuilder { } else { // For scenarios without examples, just call all steps once for (var step in scenario.steps) { - buffer.writeln(_generateTestFunction( - buffer, - testFunction, - scenario.name, - className, + buffer.writeln(_generateStepCall( step, feature.decorators.hasEnableReporter, isUnitTest, @@ -124,11 +118,7 @@ class BDDTestFileBuilder { } } -String _generateTestFunction( - StringBuffer buffer, - String testFunction, - String scenarioName, - String className, +String _generateStepCall( Step step, bool withReporter, bool isUnitTest, @@ -138,12 +128,12 @@ String _generateTestFunction( if (withReporter) { return ''' await reporter.guard( - () => $className.$methodName(${isUnitTest ? '' : 'tester'}${params.isNotEmpty ? "${isUnitTest ? '' : ','} ${params.join(', ')}" : ''}), + () => scenario.$methodName(${isUnitTest ? '' : 'tester'}${params.isNotEmpty ? "${isUnitTest ? '' : ','} ${params.join(', ')}" : ''}), '${step.message}', );'''; } else { return ''' // ${step.message} - await $className.$methodName(${isUnitTest ? '' : 'tester'}${params.isNotEmpty ? "${isUnitTest ? '' : ','} ${params.join(', ')}" : ''});'''; + await scenario.$methodName(${isUnitTest ? '' : 'tester'}${params.isNotEmpty ? "${isUnitTest ? '' : ','} ${params.join(', ')}" : ''});'''; } } diff --git a/lib/src/infrastructure/parsers/feature_parser.dart b/lib/src/infrastructure/parsers/feature_parser.dart new file mode 100644 index 0000000..662c644 --- /dev/null +++ b/lib/src/infrastructure/parsers/feature_parser.dart @@ -0,0 +1,165 @@ +import 'dart:io'; + +import 'package:bdd_flutter/src/domain/background.dart'; + +import '../../domain/decorator.dart'; +import '../../domain/feature.dart'; +import '../../domain/scenario.dart'; +import '../../domain/step.dart'; + +class FeatureParser { + Future parseFeature(String filePath) async { + // read files and parse the content + + final file = File(filePath); + final fileContent = await file.readAsString(); + final lines = fileContent.split('\n').map((line) => line.trim()).toList(); + + String? featureName; + List scenarios = []; + Set featureDecorators = {}; + + Background? background; + + // scenario that is being process + Scenario? currentScenario; + // currrent scenario decorators that is being process + List currentScenarioDecorators = []; + // current scenario example that is being process + // List> currentExamples = []; + // List exampleHeaders = []; + + // bool isParsingExamples = false; + + ExampleContent? currentExampleContent; + + bool isParsingBackground = false; + + //read each line + for (var line in lines) { + //get feature name + if (line.startsWith('Feature:')) { + featureName = line.substring('Feature:'.length).trim(); + } + //parsing decorators + else if (line.startsWith("@")) { + //parsing feature decorators + if (featureName == null) { + // if lines start with @ and feature name is null, + // it means it's a decorator for the feature + featureDecorators.add(Decorator.fromString(line)); + } + //parsing scenario decorators + else { + isParsingBackground = false; + if (currentScenario != null) { + // add currentScenario to the list and clear it and currentScenarioDecorators + currentScenario.examples = currentExampleContent?.examples; + + scenarios.add(currentScenario); + + currentExampleContent = null; + currentScenario = null; + currentScenarioDecorators = []; + } + + // if lines start with @ and feature name is not null, + // it means it's a decorator for the scenario + currentScenarioDecorators.add(Decorator.fromString(line)); + } + } + // start parsing background + else if (line.startsWith('Background:')) { + // if lines start with Background:, it means it's a background + isParsingBackground = true; + background = Background( + description: line.substring('Background:'.length).trim(), + steps: [], + ); + } + + // parsing scenario name + else if (line.startsWith('Scenario:')) { + isParsingBackground = false; + // isParsingExamples = false; + // currentExampleContent = null; + + // if lines start with Scenario:, it means it's a scenario name + final name = line.substring('Scenario:'.length).trim(); + if (currentScenario != null) { + // if currentScenario is not null, add it to the list + currentScenario.examples = currentExampleContent?.examples; + // currentExampleContent = null; + // add the current scenario to the list + scenarios.add(currentScenario); + // reset the examples + // currentExamples = []; + // exampleHeaders = []; + currentExampleContent = null; + } + + //create new scenario + final decorators = currentScenarioDecorators.toSet(); + + currentScenario = Scenario( + name, + [], + decorators: decorators, + ); + // Reset decorators and example content for next scenario + currentScenarioDecorators = []; + currentExampleContent = null; + } + // parsing steps + else if (line.startsWith('Given') || line.startsWith('When') || line.startsWith('Then') || line.startsWith('And')) { + // if lines start with Given, When, Then or And, it means it's a step + final parts = line.split(' '); + final stepType = parts[0]; + final stepText = parts.sublist(1).join(' ').trim(); + if (isParsingBackground) { + background?.steps.add(Step(stepType, stepText)); + } else { + currentScenario?.steps.add(Step(stepType, stepText)); + } + } + // check if start parsing example + else if (line.startsWith('Examples:')) { + // if lines start with Examples:, it means it's the start of examples + currentExampleContent = ExampleContent(); + } + // parsing example + else if (currentExampleContent != null && line.isNotEmpty) { + // if we are parsing examples and the line is not empty + final cells = line.split('|').where((cell) => cell.trim().isNotEmpty).map((cell) => cell.trim()).toList(); + + if (currentExampleContent.headers.isEmpty) { + currentExampleContent.headers.addAll(cells); + } else { + currentExampleContent.values.add(cells); + } + } + } + + if (currentScenario != null) { + currentScenario.examples = currentExampleContent?.examples; + scenarios.add(currentScenario); + } + + return Feature( + name: featureName ?? 'Unnamed Feature', + path: filePath, + scenarios: scenarios, + decorators: featureDecorators.toSet(), + background: background, + ); + } +} + +class ExampleContent { + List headers = []; + List> values = []; + + List> get examples { + return values.map((value) => Map.fromIterables(headers, value)).toList(); + } +} diff --git a/lib/src/presentation/cli/bbd_cli.dart b/lib/src/presentation/cli/bbd_cli.dart new file mode 100644 index 0000000..a7e1f93 --- /dev/null +++ b/lib/src/presentation/cli/bbd_cli.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +import '../controllers/bdd_controller.dart'; + +class BDDCLI { + final BDDController _bddController; + + BDDCLI({BDDController? bddController}) + : _bddController = bddController ?? BDDController(); + + Future run(List arguments) async { + if (arguments.isEmpty) { + _printUsage(); + return; + } + + final command = arguments.first; + + switch (command) { + case 'build': + await _bddController.generateFeatureTestCases(); + break; + default: + stdout.writeln('Unknown command: $command'); + _printUsage(); + } + } + + void _printUsage() { + stdout.writeln('Usage: dart run bdd_flutter '); + stdout.writeln(''); + stdout.writeln('Available commands:'); + stdout.writeln(' build Generate test files from .feature files'); + } +} diff --git a/lib/src/presentation/controllers/bdd_controller.dart b/lib/src/presentation/controllers/bdd_controller.dart new file mode 100644 index 0000000..18efc75 --- /dev/null +++ b/lib/src/presentation/controllers/bdd_controller.dart @@ -0,0 +1,50 @@ +import 'dart:io'; + +import '../../infrastructure/parsers/feature_parser.dart'; + +import '../../infrastructure/builders/scenario_file_builder.dart'; +import '../../infrastructure/builders/test_file_builder.dart'; + +class BDDController { + final FeatureParser _featureParser; + final ScenariosFileBuilder _scenarioFileBuilder; + final TestFileBuilder _testFileBuilder; + + BDDController({ + FeatureParser? featureParser, + ScenariosFileBuilder? scenarioFileBuilder, + TestFileBuilder? testFileBuilder, + }) : _featureParser = featureParser ?? FeatureParser(), + _scenarioFileBuilder = scenarioFileBuilder ?? ScenariosFileBuilder(), + _testFileBuilder = testFileBuilder ?? TestFileBuilder(); + + Future generateFeatureTestCases() async { + final featureFiles = Directory('test/') + .listSync(recursive: true) + .where((file) => file.path.endsWith('.feature')); + + if (featureFiles.isEmpty) { + stdout.writeln('No .feature files found in test/ directory.'); + return; + } + + stdout.writeln('Found ${featureFiles.length} feature file(s).'); + + for (var featureFile in featureFiles) { + final feature = await _featureParser.parseFeature(featureFile.path); + final scenarioContent = await _scenarioFileBuilder.buildScenarioFile(feature); + final testContent = await _testFileBuilder.buildTestFile(feature); + + final scenarioPath = feature.path.replaceAll('.feature', '.bdd_scenarios.dart'); + final testPath = feature.path.replaceAll('.feature', '.bdd_test.dart'); + + await File(scenarioPath).writeAsString(scenarioContent); + await File(testPath).writeAsString(testContent); + + stdout.writeln(' Generated: $scenarioPath'); + stdout.writeln(' Generated: $testPath'); + } + + stdout.writeln('Done.'); + } +} diff --git a/lib/src/feature/report/test_reporter.dart b/lib/src/presentation/reporter/test_reporter.dart similarity index 100% rename from lib/src/feature/report/test_reporter.dart rename to lib/src/presentation/reporter/test_reporter.dart diff --git a/lib/src/runner/build_command.dart b/lib/src/runner/build_command.dart deleted file mode 100644 index f21aa9a..0000000 --- a/lib/src/runner/build_command.dart +++ /dev/null @@ -1,374 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:bdd_flutter/src/extensions/list_x.dart'; -import 'package:bdd_flutter/src/feature/builder/bdd_builders/bdd_factory.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/bdd_options.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/feature.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/manifest.dart'; -import 'package:crypto/crypto.dart'; - -import '../constraints/file_extenstion.dart'; -import '../extensions/string_x.dart'; -import '../feature/logger/logger.dart'; -import 'domain/cmd_flag.dart'; - -class BuildCommand { - final CLILogger _logger; - - final ManifestManager _manifestManager; - - BuildCommand({ - CLILogger? logger, - ManifestManager? manifestManager, - }) : _logger = logger ?? CLILogger(), - _manifestManager = manifestManager ?? ManifestManager(); - - /// Executes the build command to generate test files from feature files - Future generate(List flags) async { - final options = await _parseGeneratorOption(flags); - final factory = BDDFactory.create(options); - final manifest = await _manifestManager.readManifest(); - - // Find all .feature files - final features = Directory('test/').listSync(recursive: true).where((file) => file.path.endsWith('.feature')).toList(); - - for (final feature in features) { - await _processFeature(featurePath: feature.path, options: options, factory: factory, manifest: manifest); - } - - // Update manifest - manifest.lastGenerated = DateTime.now(); - await _manifestManager.writeManifest(manifest); - } - - Future _processFeature({ - required String featurePath, - required BDDOptions options, - required BDDFactory factory, - required Manifest manifest, - }) async { - final ignoredFiles = options.ignoreFeatures; - - final featureFile = File(featurePath); - if (ignoredFiles.contains(featurePath)) { - _logger.logSkipping(featurePath, reason: 'ignored'); - return; - } - - final featureFileName = featurePath.split('/').last.replaceAll('.feature', ''); - - final featureContent = featureFile.readAsStringSync(); - final parsedFeature = await factory.featureBuilder.parseFeature(featureContent) - ..setFileName(featureFileName); - - final lastModified = featureFile.lastModifiedSync(); - - // Get paths for generated files - final testFilePath = '${featurePath.replaceAll('.feature', '')}${FileExtension.generatedTest}'; - final scenariosFilePath = '${featurePath.replaceAll('.feature', '')}${FileExtension.generatedScenarios}'; - final testFile = File(testFilePath); - final scenariosFile = File(scenariosFilePath); - - // Check if generated files exist - final testFileExists = await testFile.exists(); - final scenariosFileExists = await scenariosFile.exists(); - final filesExist = testFileExists && scenariosFileExists; - - // Find existing feature entry - final existingFeatureIndex = manifest.features.indexWhere((f) => f.path == featurePath); - final existingFeature = existingFeatureIndex != -1 ? manifest.features[existingFeatureIndex] : null; - - if (options.force) { - // Force regenerate everything - _logger.logProcessing(featurePath, reason: 'force regenerate'); - await _generateScenarioAndTestFile(factory, parsedFeature, featurePath); - _updateManifestEntry(manifest, featurePath, lastModified, parsedFeature); - } else if (options.newOnly) { - // Only generate for new features - if (existingFeature == null) { - _logger.logProcessing(featurePath, reason: 'new feature'); - await _generateScenarioAndTestFile(factory, parsedFeature, featurePath); - _updateManifestEntry(manifest, featurePath, lastModified, parsedFeature); - } else { - _logger.logSkipping(featurePath, reason: 'existing feature'); - } - } else { - // Incremental update - if (!filesExist) { - // Files don't exist, regenerate everything - _logger.logProcessing(featurePath, reason: 'missing generated files'); - await _generateScenarioAndTestFile(factory, parsedFeature, featurePath); - _updateManifestEntry(manifest, featurePath, lastModified, parsedFeature); - } else if (existingFeature == null) { - // Feature not in manifest, regenerate everything - _logger.logProcessing(featurePath, reason: 'not in manifest'); - await _generateScenarioAndTestFile(factory, parsedFeature, featurePath); - _updateManifestEntry(manifest, featurePath, lastModified, parsedFeature); - } else if (lastModified.isAfter(existingFeature.lastModified)) { - // Feature modified, check for scenario changes - _logger.logProcessing(featurePath, reason: 'modified since last generation'); - await _generateScenarioAndTestFile( - factory, - parsedFeature, - featurePath, - existingScenarios: existingFeature.scenarios, - ); - _updateManifestEntry(manifest, featurePath, lastModified, parsedFeature); - } else { - _logger.logSkipping(featurePath, reason: 'unchanged'); - } - } - } - - void _updateManifestEntry( - Manifest manifest, - String featurePath, - DateTime lastModified, - Feature parsedFeature, - ) { - final scenarios = _parseManifestScenarios( - parsedFeature: parsedFeature, - featureFilePath: featurePath, - ); - final testFile = '${featurePath.replaceAll('.feature', '')}${FileExtension.generatedTest}'; - - final featureEntry = FeatureEntry( - path: featurePath, - lastModified: lastModified, - testFile: testFile, - scenarios: scenarios, - ); - - final existingIndex = manifest.features.indexWhere((f) => f.path == featurePath); - if (existingIndex != -1) { - manifest.features[existingIndex] = featureEntry; - _logger.log('Updated manifest entry for: $featurePath'); - } else { - manifest.features.add(featureEntry); - _logger.log('Added new manifest entry for: $featurePath'); - } - } - - List _parseManifestScenarios({ - required Feature parsedFeature, - required String featureFilePath, - }) { - final scenarios = []; - final lines = File(featureFilePath).readAsStringSync().split('\n'); - - ScenarioEntry? tempScenario; - - for (var i = 0; i < lines.length; i++) { - final line = lines[i].trim(); - // check for end of scenario - // if there is a temp scenario, add it to the list - // and reset the temp scenario - if (line.startsWith('Scenario:') || line.startsWith('@')) { - if (tempScenario != null) { - scenarios.add(tempScenario.copyWith(lineEnd: i - 1)); - tempScenario = null; - } - } - - if (line.startsWith('Scenario:')) { - final scenarioName = line.substring('Scenario:'.length).trim(); - final parsedScenario = parsedFeature.scenarios.firstWhereOrNull((s) => s.name == scenarioName); - if (parsedScenario == null) { - _logger.error('Scenario not found: $scenarioName'); - continue; - } - - final hash = md5.convert(utf8.encode(parsedScenario.toString())).toString(); - tempScenario = ScenarioEntry( - name: scenarioName, - hash: hash, - lineStart: i, - lineEnd: i, - testMethod: 'test${scenarioName.replaceAll(' ', '')}', - ); - } - } - - return scenarios; - } - - /// Generates the scenario and test files for a feature - /// - /// If [existingScenarios] is provided, it will only generate the scenarios and test files for the scenarios that have changed - /// - Future _generateScenarioAndTestFile( - BDDFactory factory, - Feature parsedFeature, - String featurePath, { - List? existingScenarios, - }) async { - final testFilePath = '${featurePath.replaceAll('.feature', '')}${FileExtension.generatedTest}'; - final scenariosFilePath = '${featurePath.replaceAll('.feature', '')}${FileExtension.generatedScenarios}'; - - // Get current scenarios and their hashes - final currentScenarioEntries = _parseManifestScenarios( - parsedFeature: parsedFeature, - featureFilePath: featurePath, - ); - - final changedScenarioEntries = []; - final newScenarioEntries = []; - - if (existingScenarios != null) { - // Find changed and new scenarios - for (final current in currentScenarioEntries) { - final existing = existingScenarios.firstWhereOrNull((s) => s.name == current.name); - - if (existing == null) { - newScenarioEntries.add(current); - _logger.log('New scenario: ${current.name}'); - continue; - } else if (existing.hash != current.hash) { - changedScenarioEntries.add(current); - _logger.log('Scenario changed: ${current.name}'); - } - } - - // Find new scenarios - for (final current in currentScenarioEntries) { - if (!existingScenarios.any((s) => s.name == current.name)) { - newScenarioEntries.add(current); - _logger.log('New scenario: ${current.name}'); - } - } - } else { - // If no existing scenarios, all are new - newScenarioEntries.addAll(currentScenarioEntries); - } - - if (changedScenarioEntries.isEmpty && newScenarioEntries.isEmpty) { - _logger.log('No changes detected in scenarios'); - return; - } - - await _updateScenariosFile(factory, parsedFeature, scenariosFilePath, existingScenarios, changedScenarioEntries, newScenarioEntries); - await _updateTestFile(factory, parsedFeature, testFilePath, existingScenarios, changedScenarioEntries, newScenarioEntries); - } - - Future _updateScenariosFile( - BDDFactory factory, - Feature parsedFeature, - String scenariosFilePath, - List? existingScenarioEntries, - List changedScenarioEntries, - List newScenarioEntries, - ) async { - // Build scenarios file - final scenarios = await factory.scenarioBuilder.buildScenarioFile(parsedFeature); - final scenariosFile = File(scenariosFilePath); - - if (await scenariosFile.exists() && existingScenarioEntries != null) { - // Read existing file - final existingContent = await scenariosFile.readAsString(); - final lines = existingContent.split('\n'); - - // Update only changed scenarios - for (final scenario in [...changedScenarioEntries, ...newScenarioEntries]) { - // Find the scenario class in the existing content - final scenarioClass = "class ${scenario.name.toScenarioClassName} {"; - final classStartIndex = lines.indexWhere((line) => line.contains(scenarioClass)); - - if (classStartIndex != -1) { - // Find the end of the class - var classEndIndex = classStartIndex; - while (classEndIndex < lines.length && !lines[classEndIndex].contains('}')) { - classEndIndex++; - } - - // Replace the scenario class content - final newScenarioContent = scenarios.split('\n').where((line) => line.contains(scenarioClass)).join('\n'); - - lines.removeRange(classStartIndex, classEndIndex + 1); - lines.insert(classStartIndex, newScenarioContent); - } - } - - // Write updated content - await scenariosFile.writeAsString(lines.join('\n')); - } else { - // Write new file - await scenariosFile.writeAsString(scenarios); - } - - _logger.log('Updated scenarios file: $scenariosFilePath'); - } - - Future _updateTestFile( - BDDFactory factory, - Feature parsedFeature, - String testFilePath, - List? existingScenarios, - List changedScenarios, - List newScenarios, - ) async { - // Build test file - final testFile = await factory.testFileBuilder.buildTestFile(parsedFeature); - final testFileContent = File(testFilePath); - - if (await testFileContent.exists() && existingScenarios != null) { - // Read existing file - final existingContent = await testFileContent.readAsString(); - final lines = existingContent.split('\n'); - - // Update only changed scenarios - for (final scenario in [...changedScenarios, ...newScenarios]) { - final testMethod = scenario.testMethod; - final testStart = lines.indexWhere((line) => line.contains("$testMethod('${scenario.name}'")); - - if (testStart != -1) { - // Find the end of the test - var testEnd = testStart; - while (testEnd < lines.length && !lines[testEnd].contains('});')) { - testEnd++; - } - - // Find the new test content - final newTestContent = testFile.split('\n').where((line) => line.contains("$testMethod('${scenario.name}'")).join('\n'); - - // Replace the test content - lines.removeRange(testStart, testEnd + 1); - lines.insert(testStart, newTestContent); - } - } - - // Write updated content - await testFileContent.writeAsString(lines.join('\n')); - } else { - // Write new file - await testFileContent.writeAsString(testFile); - } - _logger.log('Updated test file: $testFilePath'); - } - - Future _parseGeneratorOption(List flags) async { - var options = await BDDOptions.fromConfig(); - - for (final flag in flags) { - switch (flag) { - case CmdFlag.unitTest: - options = options.copyWith(generateWidgetTests: false); - break; - case CmdFlag.reporter: - options = options.copyWith(enableReporter: true); - break; - case CmdFlag.force: - options = options.copyWith(force: true); - break; - case CmdFlag.newOnly: - options = options.copyWith(newOnly: true); - break; - default: - _logger.error('Invalid flag: ${flag.longForm}'); - break; - } - } - - return options; - } -} diff --git a/lib/src/runner/command_parser.dart b/lib/src/runner/command_parser.dart deleted file mode 100644 index 3812bbb..0000000 --- a/lib/src/runner/command_parser.dart +++ /dev/null @@ -1,54 +0,0 @@ -import '../feature/logger/logger.dart'; -import 'build_command.dart'; -import 'domain/cmd_flag.dart'; -import 'help_command.dart'; - -class CommandParser { - final BuildCommand _buildCommand; - final CLILogger _logger; - - CommandParser({BuildCommand? buildCommand, CLILogger? logger}) - : _buildCommand = buildCommand ?? BuildCommand(), - _logger = logger ?? CLILogger(); - - Future parse(List arguments) async { - if (arguments.isEmpty) { - // Default to build command if no arguments provided - await _buildCommand.generate([]); - return; - } - - final flags = arguments.map((argument) => CmdFlag.fromString(argument)).toList(); - print('flags: $flags'); - final error = CmdFlagValidator.validate(flags); - - if (error != null) { - _logger.log(error, level: LogLevel.error); - help(_logger); - return; - } else if (flags.contains(CmdFlag.help)) { - help(_logger); - return; - } else { - await _buildCommand.generate(flags); - } - - // final command = arguments[0].toLowerCase(); - - // if (arguments.contains('--help') || arguments.contains('-h')) { - // help(_logger); - // } else if (_commands.contains(command)) { - // // Simple validation for mutually exclusive flags - // if ((arguments.contains('--force') || arguments.contains('-f')) && (arguments.contains('--new') || arguments.contains('-n'))) { - // _logger.log('Error: Cannot use --force with --new', level: LogLevel.error); - // help(_logger); - // return; - // } else { - // await _buildCommand.generate(arguments); - // } - // } else { - // _logger.log('Unknown command: $command', level: LogLevel.error); - // help(_logger); - // } - } -} diff --git a/lib/src/runner/domain/cmd_flag.dart b/lib/src/runner/domain/cmd_flag.dart deleted file mode 100644 index 84d5705..0000000 --- a/lib/src/runner/domain/cmd_flag.dart +++ /dev/null @@ -1,49 +0,0 @@ -class CmdFlag { - static const help = CmdFlag('--help', '-h', 'Show help'); - static const unitTest = CmdFlag('--unit-test', '-u', 'Generate unit tests instead of widget tests, Widget tests are generated by default'); - static const reporter = CmdFlag('--reporter', '-r', 'Enable reporter, disable by default'); - static const force = CmdFlag('--force', '-f', 'Force generation, all feature files are overwritten'); - static const newOnly = CmdFlag('--new', '-n', 'Only generate new features, modified feature files are not generated'); - - final String longForm; - final String shortForm; - final String description; - - const CmdFlag(this.longForm, this.shortForm, this.description); - - CmdFlag copyWith({String? text, String? shortText, String? description, String? value}) { - return CmdFlag( - text ?? this.longForm, - shortText ?? this.shortForm, - description ?? this.description, - ); - } - - @override - String toString() { - return '$longForm, $shortForm, $description'; - } - - static List get values => [unitTest, reporter, force, newOnly, help]; - - static CmdFlag fromString(String text) { - return values.firstWhere( - (flag) => flag.longForm == text || flag.shortForm == text, - orElse: () => CmdFlag(text, text, 'Invalid flag'), - ); - } -} - -class CmdFlagValidator { - static String? validate(List flags) { - for (final flag in flags) { - if (!CmdFlag.values.contains(flag)) { - return 'Invalid flag: ${flag.longForm} ${flag.description}'; - } - } - if (flags.contains(CmdFlag.newOnly) && flags.contains(CmdFlag.force)) { - return 'Cannot use --new with --force'; - } - return null; - } -} diff --git a/lib/src/runner/help_command.dart b/lib/src/runner/help_command.dart deleted file mode 100644 index c4f4a7d..0000000 --- a/lib/src/runner/help_command.dart +++ /dev/null @@ -1,23 +0,0 @@ -import '../feature/logger/logger.dart'; - -void help(CLILogger logger) { - logger.logLean(''' -Usage: dart run bdd_flutter [options] - -Options: - --help, -h Show this help message - --force, -f Force regenerate all feature files - --new, -n Only generate new feature files - --unit-test, -u Generate unit tests (default: false) - --reporter, -r Enable test reporter (default: false) - -Examples: - dart run bdd_flutter - dart run bdd_flutter --force - dart run bdd_flutter --new - dart run bdd_flutter --unit-test - dart run bdd_flutter --reporter - -For more information, visit: https://github.com/samderlust/bdd_flutter -'''); -} diff --git a/lib/src/runner/update_manifest_cmd.dart b/lib/src/runner/update_manifest_cmd.dart deleted file mode 100644 index 8b13789..0000000 --- a/lib/src/runner/update_manifest_cmd.dart +++ /dev/null @@ -1 +0,0 @@ - diff --git a/overview.md b/overview.md new file mode 100644 index 0000000..8c42eb5 --- /dev/null +++ b/overview.md @@ -0,0 +1,100 @@ +# Project Overview + +This is an overview and guidance for developing `bdd_flutter` package and the structure of the code. + +The `bdd_flutter` package is a powerful tool for Behavior-Driven Development (BDD) in Flutter. It simplifies the process of writing and maintaining tests by automatically generating test files from Gherkin feature files. + +## Key Features + +- **Automatic Test Generation**: Write your tests in Gherkin syntax, and let the package generate the corresponding Dart test files. +- **Incremental Updates**: Preserve your custom code during test file regeneration. +- **Customizable**: Configure the package to suit your project's needs. +- **Support for Both Widget and Unit Tests**: Choose the type of tests you want to generate. +- **Test Reporter**: Optionally enable a test reporter to get detailed test results. + +## Flowchart + +```mermaid +flowchart TD + n1[".feature file"] --> n2["parsing feature file"] + n2 --> n13["pasring .manifest files"] + n3["Feature"] --> n4["content parsing"] + n5(["start parsing feature file"]) --> n1 + n4 --> n6["ScenariosContent"] & n7["TestCasesContent"] + n6 --> n8["writing file"] + n7 --> n8 + n8 --> n9["scenarios.dart"] & n10["test.dart"] & n16["Update manifest file"] + n9 --> n11(["END"]) + n10 --> n11 + n14["Scenarios and test files exists?"] -- YES --> n15(["Skip Feature"]) + n16 --> n12[".manifest"] + n12 --> n14 + n13 --> n12 + n14 -- NO --> n3 + n15 --> n11 + n1@{ shape: lean-l} + n3@{ shape: lean-l} + n6@{ shape: lean-r} + n7@{ shape: lean-r} + n9@{ shape: lean-r} + n10@{ shape: lean-r} + n16@{ shape: rect} + n14@{ shape: diam} + n12@{ shape: lean-r} + +``` + +## Project layout + +1. Parsing functionality + +- Feature file parsing: parse feature file content into a Feature object + - location: `lib/src2/feature/parsing/feature_parser.dart` +- Scenario file parsing: parse scenario classes from a Dart file into Content object + - location: `lib/src2/feature/parsing/scenario_parser.dart` +- Test file parsing: parse test cases from a Dart file into Content object + - location: `lib/src2/feature/parsing/test_case_parser.dart` + +2. Content: + +- Scenario content: + - parsed from scenario Dart file + - to be used to generate scenario Dart file +- Test case content: parsed from test file + - to be used to generate test file + - to be used to generate test file + +3. File Processing + + - to read and write files + +4. Builder + + - to parse CMD + +## Project Structure + +- Domain layer + - Feature + - Scenario + - Step + - Content + - Decorator +- Parsing layer + - FeatureParser + - ScenarioParser + - TestCaseParser +- File processing layer + - DartFileWriter + - DartFileReader +- Builder layer + - BuildCommand + - CommandParser +- CLI layer + - CLILogger + - CLIHelp +- Config layer + - ConfigManager +- Manifest layer + - ManifestManager + - ManifestParser diff --git a/plan/refactor.md b/plan/refactor.md new file mode 100644 index 0000000..e0c2e25 --- /dev/null +++ b/plan/refactor.md @@ -0,0 +1,219 @@ +# Refactoring Plan: bdd_flutter CLI + +## Context + +The project was rewritten from a `build_runner`-based approach to a CLI tool. The new clean architecture lives in `lib/src/` but only has basic parsing and generation. The README documents a full-featured CLI with config files, manifest tracking, incremental builds, multiple flags, and several decorators โ€” most of which are not yet implemented. + +**Goal**: Bring `lib/src/` to feature parity with what the README promises, iteratively. + +## Design Decision: Instance-Based Scenario Classes + +Change from static methods to instance methods, one class per scenario: + +```dart +// _scenarios.dart +class IncrementScenario { + Future iHaveACounterWithValue0(WidgetTester tester) async { + // TODO: Implement + } + Future iIncrementTheCounterBy(WidgetTester tester, String value) async { + // TODO: Implement + } +} + +// _test.dart +testWidgets('Increment', (tester) async { + final scenario = IncrementScenario(); + await scenario.iHaveACounterWithValue0(tester); + await scenario.iIncrementTheCounterBy(tester, '1'); +}); +``` + +**Why instance over static:** +- Users can add `late` fields to share state between steps (mocks, widgets, counters) +- Each test gets a fresh instance โ€” proper test isolation +- No global state or parameter threading needed for complex cases + +## Gap Analysis + +| Feature | Status | +|---------|--------| +| Parse .feature files (Feature, Scenario, Given/When/Then) | Done | +| Background support | Done | +| Examples table | Done | +| @unitTest / @widgetTest decorators | Done | +| @enableReporter decorator | Done | +| `And` step keyword | **Missing** | +| @ignore decorator | **Missing** | +| @className("Name") decorator | **Missing** | +| @disableReporter decorator | **Missing** | +| Feature-level decorator inheritance to scenarios | **Missing** | +| CLI argument parsing (build/rename commands, flags) | **Missing** | +| --widget-test, --reporter, --force, --new-only flags | **Missing** | +| Config file (.bdd_flutter/config.yaml) | **Missing** | +| Manifest tracking (.bdd_flutter/manifest.yaml) | **Missing** | +| Incremental generation (default mode) | **Missing** | +| Force regeneration mode | **Missing** | +| New-only generation mode | **Missing** | +| `rename` command (remove .bdd_ prefix) | **Missing** | +| ignore_features filtering | **Missing** | +| Remove debug print() statements | **Missing** | +| Unit tests | **Missing** (test/ dirs are empty) | + +--- + +## Iteration 1: Core Pipeline Works End-to-End + +**Goal**: `dart run bdd_flutter build` parses any .feature file and generates correct instance-based _scenarios.dart and _test.dart files. Basic `build` command works with no flags. + +### 1.1 Fix FeatureParser โ€” `lib/src/infrastructure/parsers/feature_parser.dart` +- Add `And` step keyword support +- Remove debug `print()` statement (line 131) + +### 1.2 Update ScenariosFileBuilder โ€” `lib/src/infrastructure/builders/scenario_file_builder.dart` +- Change from static methods to instance methods (drop `static` keyword) +- Background class also instance-based + +### 1.3 Update TestFileBuilder โ€” `lib/src/infrastructure/builders/test_file_builder.dart` +- Instantiate scenario: `final scenario = IncrementScenario();` +- Call `scenario.step(tester, ...)` instead of `IncrementScenario.step(tester, ...)` +- Background: `final background = {FeatureName}Background();` instantiated per test +- Remove debug `print()` statements (lines 64-65) + +### 1.4 Basic CLI โ€” `lib/src/presentation/cli/bbd_cli.dart` +- Parse `build` command (just route to controller, no flags yet) + +### 1.5 Tests for Iteration 1 +- `test/parsers/feature_parser_test.dart` โ€” basic features, scenarios, steps, And, Background, Examples +- `test/builders/scenario_file_builder_test.dart` โ€” instance methods, params, Background +- `test/builders/test_file_builder_test.dart` โ€” instantiation, widget vs unit, examples loop + +### Verification +- `dart run bin/bdd_flutter.dart build` generates correct output for all example .feature files +- `dart analyze` passes +- `flutter test` at package root passes + +--- + +## Iteration 2: Decorators & CLI Flags + +**Goal**: All decorators work. CLI accepts flags that override defaults. + +### 2.1 Add missing decorators โ€” `lib/src/domain/decorator.dart` +- Add `ignore`, `className`, `disableReporter` to enum +- `@className("Name")` parses the argument string +- Update `fromString()` for new patterns + +### 2.2 Update FeatureParser for new decorators +- Parse `@ignore`, `@className("Name")`, `@disableReporter` + +### 2.3 Feature-level decorator inheritance โ€” `lib/src/domain/scenario.dart` +- Scenario decorators override feature-level; fall back to feature if absent +- Builders pass feature decorators context + +### 2.4 Update builders for decorators +- `@className` overrides scenario class name +- `@ignore` skips generation for that feature/scenario +- `@disableReporter` support + +### 2.5 CLI flags โ€” `lib/src/presentation/cli/bbd_cli.dart` +- Parse flags: `--widget-test`, `--reporter`, `--force`, `--new-only` +- Create `lib/src/domain/build_options.dart` to encapsulate options +- Pass options to controller + +### 2.6 Tests for Iteration 2 +- Decorator parsing tests (all types including @className) +- Builder tests for @className, @ignore +- Feature-level inheritance tests + +### Verification +- Decorators in example .feature files produce correct output +- `dart run bdd_flutter build --widget-test` works +- `dart analyze` and `flutter test` pass + +--- + +## Iteration 3: Config File & Manifest Tracking + +**Goal**: Config file drives defaults. Manifest enables incremental generation. Three generation modes work. + +### 3.1 Config model & parser +- New file `lib/src/domain/config.dart` +- New file `lib/src/infrastructure/parsers/config_parser.dart` +- Read `.bdd_flutter/config.yaml` using `yaml` package +- Create default config if missing + +### 3.2 Manifest model & parser/writer +- New file `lib/src/domain/manifest.dart` +- New file `lib/src/infrastructure/parsers/manifest_parser.dart` +- Track per-feature: path, last modified, scenario hashes +- Read/write `.bdd_flutter/manifest.yaml` + +### 3.3 Refactor BDDController โ€” `lib/src/presentation/controllers/bdd_controller.dart` +- Load config, merge with CLI flags (CLI overrides config) +- Filter out `ignore_features` from config +- Implement generation modes: + - **Default (incremental)**: check manifest, only regenerate changed features + - **Force (`--force`)**: regenerate everything + - **New-only (`--new-only`)**: only generate for features not in manifest +- Update manifest after generation + +### 3.4 Tests for Iteration 3 +- Config parser: defaults, custom values, missing file +- Manifest parser: read/write round-trip, change detection +- Controller: incremental vs force vs new-only behavior + +### Verification +- `dart run bdd_flutter build` only regenerates changed files +- `dart run bdd_flutter build --force` regenerates all +- `dart run bdd_flutter build --new-only` skips existing +- Config file options are respected +- `dart analyze` and `flutter test` pass + +--- + +## Iteration 4: Rename Command & Polish + +**Goal**: `rename` command works. Clean up, final docs. + +### 4.1 Rename command +- `dart run bdd_flutter rename` strips `.bdd_` prefix from generated files +- Add to CLI parser and controller + +### 4.2 Cleanup +- Remove all remaining debug `print()` statements +- Update CLAUDE.md to reflect final architecture +- Update README if needed + +### 4.3 Final verification +- All commands work end-to-end against example project +- `dart analyze` โ€” no issues +- `flutter test` โ€” all tests pass + +--- + +## Files to Modify + +| File | Iteration | +|------|-----------| +| `lib/src/infrastructure/parsers/feature_parser.dart` | 1, 2 | +| `lib/src/infrastructure/builders/scenario_file_builder.dart` | 1, 2 | +| `lib/src/infrastructure/builders/test_file_builder.dart` | 1, 2 | +| `lib/src/presentation/cli/bbd_cli.dart` | 1, 2, 4 | +| `lib/src/presentation/controllers/bdd_controller.dart` | 2, 3, 4 | +| `lib/src/domain/decorator.dart` | 2 | +| `lib/src/domain/scenario.dart` | 2 | +| `lib/src/domain/feature.dart` | 2 | + +## New Files + +| File | Iteration | +|------|-----------| +| `test/parsers/feature_parser_test.dart` | 1 | +| `test/builders/scenario_file_builder_test.dart` | 1 | +| `test/builders/test_file_builder_test.dart` | 1 | +| `lib/src/domain/build_options.dart` | 2 | +| `lib/src/domain/config.dart` | 3 | +| `lib/src/domain/manifest.dart` | 3 | +| `lib/src/infrastructure/parsers/config_parser.dart` | 3 | +| `lib/src/infrastructure/parsers/manifest_parser.dart` | 3 | diff --git a/pubspec.yaml b/pubspec.yaml index fd713db..b093cf0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ dependencies: yaml: ^3.1.0 dev_dependencies: - lints: ^2.0.0 + flutter_lints: ^2.0.1 test: ^1.24.0 flutter_test: sdk: flutter diff --git a/test/builders/scenario_file_builder_test.dart b/test/builders/scenario_file_builder_test.dart new file mode 100644 index 0000000..c73b318 --- /dev/null +++ b/test/builders/scenario_file_builder_test.dart @@ -0,0 +1,128 @@ +import 'package:bdd_flutter/src/domain/decorator.dart'; +import 'package:bdd_flutter/src/domain/feature.dart'; +import 'package:bdd_flutter/src/domain/scenario.dart'; +import 'package:bdd_flutter/src/domain/step.dart'; +import 'package:bdd_flutter/src/domain/background.dart'; +import 'package:bdd_flutter/src/infrastructure/builders/scenario_file_builder.dart'; +import 'package:test/test.dart'; + +void main() { + late ScenariosFileBuilder builder; + + setUp(() { + builder = ScenariosFileBuilder(); + }); + + group('ScenariosFileBuilder', () { + test('generates instance methods (not static)', () async { + final feature = Feature( + name: 'Counter', + path: 'test/counter.feature', + scenarios: [ + Scenario('Increment', [ + Step('Given', 'I have a counter'), + Step('When', 'I increment the counter'), + ]), + ], + decorators: {}, + ); + + final result = await builder.buildScenarioFile(feature); + + expect(result, contains('class IncrementScenario {')); + expect(result, contains(' Future iHaveACounter(WidgetTester tester) async {')); + expect(result, contains(' Future iIncrementTheCounter(WidgetTester tester) async {')); + // Should NOT contain static + expect(result, isNot(contains('static Future'))); + }); + + test('generates unit test methods without WidgetTester', () async { + final feature = Feature( + name: 'Calculator', + path: 'test/calculator.feature', + scenarios: [ + Scenario('Add', [ + Step('Given', 'I have a calculator'), + ], decorators: {Decorator.unitTest}), + ], + decorators: {}, + ); + + final result = await builder.buildScenarioFile(feature); + + expect(result, contains(' Future iHaveACalculator() async {')); + expect(result, isNot(contains('WidgetTester'))); + }); + + test('generates methods with parameters from angle brackets', () async { + final feature = Feature( + name: 'Calculator', + path: 'test/calculator.feature', + scenarios: [ + Scenario('Add', [ + Step('When', 'I add and '), + ]), + ], + decorators: {}, + ); + + final result = await builder.buildScenarioFile(feature); + + expect(result, contains('Future iAddFirstNumberAndSecondNumber(WidgetTester tester, String firstNumber, String secondNumber) async {')); + }); + + test('generates Background class with instance methods', () async { + final feature = Feature( + name: 'Counter', + path: 'test/counter.feature', + scenarios: [ + Scenario('Increment', [ + Step('When', 'I increment'), + ]), + ], + decorators: {}, + background: Background( + description: 'Counter starts at 0', + steps: [Step('Given', 'I have a counter with value 0')], + ), + ); + + final result = await builder.buildScenarioFile(feature); + + expect(result, contains('class CounterBackground {')); + expect(result, contains(' Future iHaveACounterWithValue0() async {')); + // Background should NOT have static + expect(result, isNot(contains('static Future'))); + }); + + test('generates import for flutter_test', () async { + final feature = Feature( + name: 'Test', + path: 'test/test.feature', + scenarios: [], + decorators: {}, + ); + + final result = await builder.buildScenarioFile(feature); + + expect(result, contains("import 'package:flutter_test/flutter_test.dart';")); + }); + + test('generates multiple scenario classes', () async { + final feature = Feature( + name: 'Login', + path: 'test/login.feature', + scenarios: [ + Scenario('Successful login', [Step('Given', 'I am logged in')]), + Scenario('Failed login', [Step('Given', 'I am not logged in')]), + ], + decorators: {}, + ); + + final result = await builder.buildScenarioFile(feature); + + expect(result, contains('class SuccessfulLoginScenario {')); + expect(result, contains('class FailedLoginScenario {')); + }); + }); +} diff --git a/test/builders/test_file_builder_test.dart b/test/builders/test_file_builder_test.dart new file mode 100644 index 0000000..46992ac --- /dev/null +++ b/test/builders/test_file_builder_test.dart @@ -0,0 +1,184 @@ +import 'package:bdd_flutter/src/domain/decorator.dart'; +import 'package:bdd_flutter/src/domain/feature.dart'; +import 'package:bdd_flutter/src/domain/scenario.dart'; +import 'package:bdd_flutter/src/domain/step.dart'; +import 'package:bdd_flutter/src/domain/background.dart'; +import 'package:bdd_flutter/src/infrastructure/builders/test_file_builder.dart'; +import 'package:test/test.dart'; + +void main() { + late TestFileBuilder builder; + + setUp(() { + builder = TestFileBuilder(); + }); + + group('TestFileBuilder', () { + test('generates testWidgets by default', () async { + final feature = Feature( + name: 'Counter', + path: 'test/counter.feature', + scenarios: [ + Scenario('Increment', [ + Step('Given', 'I have a counter'), + Step('When', 'I increment the counter'), + ]), + ], + decorators: {}, + ); + + final result = await builder.buildTestFile(feature); + + expect(result, contains("testWidgets('Increment', (tester) async {")); + }); + + test('instantiates scenario class', () async { + final feature = Feature( + name: 'Counter', + path: 'test/counter.feature', + scenarios: [ + Scenario('Increment', [ + Step('Given', 'I have a counter'), + ]), + ], + decorators: {}, + ); + + final result = await builder.buildTestFile(feature); + + expect(result, contains('final scenario = IncrementScenario();')); + }); + + test('calls instance methods on scenario', () async { + final feature = Feature( + name: 'Counter', + path: 'test/counter.feature', + scenarios: [ + Scenario('Increment', [ + Step('Given', 'I have a counter'), + Step('When', 'I increment the counter'), + ]), + ], + decorators: {}, + ); + + final result = await builder.buildTestFile(feature); + + expect(result, contains('await scenario.iHaveACounter(tester)')); + expect(result, contains('await scenario.iIncrementTheCounter(tester)')); + // Should NOT contain static class calls + expect(result, isNot(contains('IncrementScenario.iHaveACounter'))); + }); + + test('generates test() for unit tests', () async { + final feature = Feature( + name: 'Calculator', + path: 'test/calculator.feature', + scenarios: [ + Scenario('Add', [ + Step('Given', 'I have a calculator'), + ], decorators: {Decorator.unitTest}), + ], + decorators: {}, + ); + + final result = await builder.buildTestFile(feature); + + expect(result, contains("test('Add', () async {")); + expect(result, isNot(contains('tester'))); + }); + + test('generates examples loop', () async { + final feature = Feature( + name: 'Calculator', + path: 'test/calculator.feature', + scenarios: [ + Scenario('Add', [ + Step('When', 'I add and '), + Step('Then', 'the result is '), + ], examples: [ + {'a': '1', 'b': '2', 'result': '3'}, + {'a': '5', 'b': '3', 'result': '8'}, + ]), + ], + decorators: {}, + ); + + final result = await builder.buildTestFile(feature); + + expect(result, contains('final examples = [')); + expect(result, contains('for (var example in examples)')); + }); + + test('instantiates background and calls its methods', () async { + final feature = Feature( + name: 'Counter', + path: 'test/counter.feature', + scenarios: [ + Scenario('Increment', [ + Step('When', 'I increment'), + ]), + ], + decorators: {}, + background: Background( + description: 'Counter starts at 0', + steps: [Step('Given', 'I have a counter with value 0')], + ), + ); + + final result = await builder.buildTestFile(feature); + + expect(result, contains('final background = CounterBackground();')); + expect(result, contains('await background.iHaveACounterWithValue0();')); + }); + + test('generates imports', () async { + final feature = Feature( + name: 'Counter', + path: 'test/counter.feature', + scenarios: [], + decorators: {}, + ); + + final result = await builder.buildTestFile(feature); + + expect(result, contains("import 'package:flutter_test/flutter_test.dart';")); + expect(result, contains("import 'counter.bdd_scenarios.dart';")); + }); + + test('generates reporter setup when @enableReporter', () async { + final feature = Feature( + name: 'Counter', + path: 'test/counter.feature', + scenarios: [ + Scenario('Increment', [ + Step('Given', 'I have a counter'), + ]), + ], + decorators: {Decorator.enableReporter}, + ); + + final result = await builder.buildTestFile(feature); + + expect(result, contains("import 'package:bdd_flutter/bdd_flutter.dart';")); + expect(result, contains("final reporter = BDDTestReporter(featureName: 'Counter');")); + expect(result, contains('reporter.startScenario')); + expect(result, contains('reporter.guard')); + }); + + test('wraps group with feature name', () async { + final feature = Feature( + name: 'My Feature', + path: 'test/my_feature.feature', + scenarios: [ + Scenario('Test', [Step('Given', 'something')]), + ], + decorators: {}, + ); + + final result = await builder.buildTestFile(feature); + + expect(result, contains("group('My Feature', () {")); + }); + }); +} diff --git a/test/feature_builder/build_scenarios_file_test.dart b/test/feature_builder/build_scenarios_file_test.dart deleted file mode 100644 index 6a3292e..0000000 --- a/test/feature_builder/build_scenarios_file_test.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:bdd_flutter/src/feature/builder/bdd_builders/scenario_file_builder.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/feature.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/scenario.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/step.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/background.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/decorator.dart'; - -void main() { - group('ScenariosFileBuilder', () { - late ScenariosFileBuilder builder; - - setUp(() { - builder = ScenariosFileBuilder(); - }); - - test('builds scenario file with basic scenario', () async { - final feature = Feature( - 'Test Feature', - [ - Scenario( - 'Basic', - [ - Step('Given', 'I have a counter'), - Step('When', 'I increment the counter'), - Step('Then', 'I should see the counter incremented'), - ], - ), - ], - ); - - final result = await builder.buildScenarioFile(feature); - - expect(result, contains("import 'package:flutter_test/flutter_test.dart';")); - expect(result, contains('class BasicScenario {')); - expect(result, contains('static Future iHaveACounter(WidgetTester tester) async {')); - expect(result, contains('static Future iIncrementTheCounter(WidgetTester tester) async {')); - expect(result, contains('static Future iShouldSeeTheCounterIncremented(WidgetTester tester) async {')); - }); - - test('builds scenario file with background', () async { - final feature = Feature( - 'Test', - [ - Scenario( - 'Basic', - [ - Step('Given', 'I have a counter'), - Step('When', 'I increment the counter'), - Step('Then', 'I should see the counter incremented'), - ], - ), - ], - background: Background( - description: 'Background steps', - steps: [ - Step('Given', 'I am on the home page'), - Step('And', 'I am logged in'), - ], - ), - ); - - final result = await builder.buildScenarioFile(feature); - - expect(result, contains('class TestBackground {')); - expect(result, contains('static Future iAmOnTheHomePage() async {')); - expect(result, contains('static Future iAmLoggedIn() async {')); - }); - - test('builds scenario file with unit test scenario', () async { - final feature = Feature( - 'Test Feature', - [ - Scenario( - 'Unit Test', - [ - Step('Given', 'I have a counter'), - Step('When', 'I increment the counter'), - Step('Then', 'I should see the counter incremented'), - ], - decorators: {BDDDecorator.unitTest()}, - ), - ], - ); - - final result = await builder.buildScenarioFile(feature); - - expect(result, contains('class UnitTestScenario {')); - expect(result, contains('static Future iHaveACounter() async {')); - expect(result, contains('static Future iIncrementTheCounter() async {')); - expect(result, contains('static Future iShouldSeeTheCounterIncremented() async {')); - }); - - test('builds scenario file with scenario containing parameters', () async { - final feature = Feature( - 'Test', - [ - Scenario( - 'Parameter', - [ - Step('Given', 'I have a counter'), - Step('When', 'I increment the counter by '), - Step('Then', 'I should see the counter at '), - ], - ), - ], - ); - - final result = await builder.buildScenarioFile(feature); - - expect(result, contains('class ParameterScenario {')); - expect(result, contains('static Future iHaveACounter(WidgetTester tester) async {')); - expect(result, contains('static Future iIncrementTheCounterBy(WidgetTester tester, String amount) async {')); - expect(result, contains('static Future iShouldSeeTheCounterAt(WidgetTester tester, String result) async {')); - }); - - test('builds scenario file with numeric parameters', () async { - final feature = Feature( - 'Test', - [ - Scenario( - 'Numeric Parameters', - [ - Step('Given', 'I have the number '), - Step('And', 'I have the number '), - Step('When', 'I divide them'), - Step('Then', 'the result should be '), - ], - ), - ], - ); - - final result = await builder.buildScenarioFile(feature); - - expect(result, contains('class NumericParametersScenario {')); - expect(result, contains('static Future iHaveTheNumberNumber1(WidgetTester tester, String number1) async {')); - expect(result, contains('static Future iHaveTheNumberNumber2(WidgetTester tester, String number2) async {')); - expect(result, contains('static Future iDivideThem(WidgetTester tester) async {')); - expect(result, contains('static Future theResultShouldBeResult(WidgetTester tester, String result) async {')); - }); - }); -} diff --git a/test/feature_builder/build_test_file_test.dart b/test/feature_builder/build_test_file_test.dart deleted file mode 100644 index bdb47ce..0000000 --- a/test/feature_builder/build_test_file_test.dart +++ /dev/null @@ -1,217 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:bdd_flutter/src/feature/builder/bdd_builders/bdd_test_file_builder.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/feature.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/scenario.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/step.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/background.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/decorator.dart'; - -void main() { - group('BDDTestFileBuilder', () { - late BDDTestFileBuilder builder; - - setUp(() { - builder = BDDTestFileBuilder(); - }); - - test('builds test file with basic scenario', () async { - final feature = Feature( - 'Test', - fileName: 'test', - [ - Scenario( - 'Basic', - [ - Step('Given', 'I have a counter'), - Step('When', 'I increment the counter'), - Step('Then', 'I should see the counter incremented'), - ], - ), - ], - ); - - final result = await builder.buildTestFile(feature); - - expect(result, contains("import 'package:flutter_test/flutter_test.dart';")); - expect(result, contains("import 'test.bdd_scenarios.dart';")); - expect(result, contains("void main() {")); - expect(result, contains("group('Test', () {")); - expect(result, contains("testWidgets('Basic', (tester) async {")); - expect(result, contains("BasicScenario.iHaveACounter(tester);")); - expect(result, contains("BasicScenario.iIncrementTheCounter(tester);")); - expect(result, contains("BasicScenario.iShouldSeeTheCounterIncremented(tester);")); - }); - - test('builds test file with background', () async { - final feature = Feature( - 'Test', - [ - Scenario( - 'Basic', - [ - Step('Given', 'I have a counter'), - Step('When', 'I increment the counter'), - Step('Then', 'I should see the counter incremented'), - ], - ), - ], - background: Background( - description: 'Background steps', - steps: [ - Step('Given', 'I am on the home page'), - Step('And', 'I am logged in'), - ], - ), - ); - - final result = await builder.buildTestFile(feature); - - expect(result, contains("//Background: Background steps")); - expect(result, contains("TestBackground.iAmOnTheHomePage();")); - expect(result, contains("TestBackground.iAmLoggedIn();")); - }); - - test('builds test file with unit test scenario', () async { - final feature = Feature( - 'Test', - [ - Scenario( - 'Unit Test', - [ - Step('Given', 'I have a counter'), - Step('When', 'I increment the counter'), - Step('Then', 'I should see the counter incremented'), - ], - decorators: {BDDDecorator.unitTest()}, - ), - ], - ); - - final result = await builder.buildTestFile(feature); - - expect(result, contains("test('Unit Test', () async {")); - expect(result, contains("UnitTestScenario.iHaveACounter();")); - expect(result, contains("UnitTestScenario.iIncrementTheCounter();")); - expect(result, contains("UnitTestScenario.iShouldSeeTheCounterIncremented();")); - }); - - test('builds test file with scenario containing examples', () async { - final feature = Feature( - 'Test', - [ - Scenario( - 'Parameterized', - [ - Step('Given', 'I have a counter'), - Step('When', 'I increment the counter by '), - Step('Then', 'I should see the counter at '), - ], - examples: [ - {'amount': '1', 'result': '1'}, - {'amount': '2', 'result': '2'}, - ], - ), - ], - ); - - final result = await builder.buildTestFile(feature); - - expect(result, contains("final examples = [")); - expect(result, contains("for (var example in examples) {")); - expect(result, contains("ParameterizedScenario.iIncrementTheCounterBy(tester, example['amount']!);")); - expect(result, contains("ParameterizedScenario.iShouldSeeTheCounterAt(tester, example['result']!);")); - }); - - test('builds test file with reporter enabled', () async { - final feature = Feature( - 'Test Feature', - [ - Scenario( - 'Basic', - [ - Step('Given', 'I have a counter'), - Step('When', 'I increment the counter'), - Step('Then', 'I should see the counter incremented'), - ], - ), - ], - decorators: {BDDDecorator.enableReporter()}, - ); - - final result = await builder.buildTestFile(feature); - - expect(result, contains("import 'package:bdd_flutter/bdd_flutter.dart';")); - expect(result, contains("final reporter = BDDTestReporter(featureName: 'Test Feature');")); - expect(result, contains("setUpAll(() {")); - expect(result, contains("reporter.testStarted(); // start recording")); - expect(result, contains("tearDownAll(() {")); - expect(result, contains("reporter.testFinished(); // stop recording")); - expect(result, contains("reporter.printReport(); // print report")); - expect(result, contains("reporter.startScenario('Basic');")); - }); - - test('builds test file with multiple scenarios', () async { - final feature = Feature( - 'Test', - [ - Scenario( - 'First', - [ - Step('Given', 'I have a counter'), - Step('When', 'I increment the counter'), - Step('Then', 'I should see the counter incremented'), - ], - ), - Scenario( - 'Second', - [ - Step('Given', 'I have a counter'), - Step('When', 'I decrement the counter'), - Step('Then', 'I should see the counter decremented'), - ], - ), - ], - ); - - final result = await builder.buildTestFile(feature); - - expect(result, contains("testWidgets('First', (tester) async {")); - expect(result, contains("testWidgets('Second', (tester) async {")); - expect(result, contains("FirstScenario.iHaveACounter(tester);")); - expect(result, contains("SecondScenario.iHaveACounter(tester);")); - expect(result, contains("SecondScenario.iDecrementTheCounter(tester);")); - expect(result, contains("SecondScenario.iShouldSeeTheCounterDecremented(tester);")); - }); - - test('builds test file with numeric parameters', () async { - final feature = Feature( - 'Test', - [ - Scenario( - 'Numeric Parameters', - [ - Step('Given', 'I have the number '), - Step('And', 'I have the number '), - Step('When', 'I divide them'), - Step('Then', 'the result should be '), - ], - examples: [ - {'number1': '10', 'number2': '2', 'result': '5'}, - {'number1': '20', 'number2': '4', 'result': '5'}, - ], - ), - ], - ); - - final result = await builder.buildTestFile(feature); - - expect(result, contains("testWidgets('Numeric Parameters', (tester) async {")); - expect(result, contains("final examples = [")); - expect(result, contains("for (var example in examples) {")); - expect(result, contains("NumericParametersScenario.iHaveTheNumberNumber1(tester, example['number1']!);")); - expect(result, contains("NumericParametersScenario.iHaveTheNumberNumber2(tester, example['number2']!);")); - expect(result, contains("NumericParametersScenario.iDivideThem(tester);")); - expect(result, contains("NumericParametersScenario.theResultShouldBeResult(tester, example['result']!);")); - }); - }); -} diff --git a/test/feature_builder/parse_feature_test.dart b/test/feature_builder/parse_feature_test.dart deleted file mode 100644 index 02206b2..0000000 --- a/test/feature_builder/parse_feature_test.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'package:bdd_flutter/src/feature/builder/bdd_builders/bdd_feature_builder.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/bdd_options.dart'; -import 'package:bdd_flutter/src/feature/builder/domain/decorator.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - late BDDFeatureBuilder featureBuilder; - - setUp(() { - featureBuilder = BDDFeatureBuilder(options: BDDOptions.defaultOptions()); - }); - - group('parseFeature', () { - test('should parse basic feature with single scenario', () { - const featureContent = ''' -Feature: Login functionality - Scenario: Successful login - Given I am on the login page - When I enter valid credentials - Then I should be logged in successfully -'''; - - final feature = featureBuilder.parseFeature(featureContent); - - expect(feature.name, equals('Login functionality')); - expect(feature.scenarios.length, equals(1)); - expect(feature.scenarios[0].name, equals('Successful login')); - expect(feature.scenarios[0].steps.length, equals(3)); - expect(feature.scenarios[0].steps[0].keyword, equals('Given')); - expect(feature.scenarios[0].steps[0].text, equals('I am on the login page')); - }); - - test('should parse feature with decorators', () { - const featureContent = ''' -@enableReporter -Feature: User registration - @unitTest - Scenario: Register with valid data - Given I am on the registration page - When I fill in valid registration data - Then I should be registered successfully -'''; - - final feature = featureBuilder.parseFeature(featureContent); - - expect(feature.name, equals('User registration')); - expect(feature.decorators.length, equals(1)); - expect(feature.decorators.contains(BDDDecorator.enableReporter()), isTrue); - expect(feature.scenarios[0].decorators.contains(BDDDecorator.unitTest()), isTrue); - }); - - test('should parse feature with examples', () { - const featureContent = ''' -Feature: Calculator - Scenario: Add two numbers - Given I have entered into the calculator - And I have entered into the calculator - When I press add - Then the result should be - - Examples: - | number1 | number2 | result | - | 1 | 2 | 3 | - | 5 | 5 | 10 | -'''; - - final feature = featureBuilder.parseFeature(featureContent); - - expect(feature.name, equals('Calculator')); - expect(feature.scenarios.length, equals(1)); - expect(feature.scenarios[0].examples, isNotNull); - expect(feature.scenarios[0].examples!.length, equals(2)); - expect(feature.scenarios[0].examples![0]['number1'], equals('1')); - expect(feature.scenarios[0].examples![0]['result'], equals('3')); - }); - - test('should parse feature with multiple scenarios', () { - const featureContent = ''' -Feature: Search functionality - Scenario: Search with valid keyword - Given I am on the search page - When I enter a valid search term - Then I should see relevant results - - Scenario: Search with empty keyword - Given I am on the search page - When I enter an empty search term - Then I should see an error message -'''; - - final feature = featureBuilder.parseFeature(featureContent); - - expect(feature.name, equals('Search functionality')); - expect(feature.scenarios.length, equals(2)); - expect(feature.scenarios[0].name, equals('Search with valid keyword')); - expect(feature.scenarios[1].name, equals('Search with empty keyword')); - }); - - test('should throw exception when no feature is defined', () { - const featureContent = ''' -Scenario: Invalid feature file - Given some precondition - When some action - Then some result -'''; - - expect( - () => featureBuilder.parseFeature(featureContent), - throwsException, - ); - }); - - test('should handle @ignore decorator', () { - const featureContent = ''' -@ignore -Feature: Ignored feature - Scenario: This should be ignored - Given some precondition - When some action - Then some result -'''; - - final feature = featureBuilder.parseFeature(featureContent); - expect(feature.name, isEmpty); - expect(feature.scenarios, isEmpty); - }); - - test('should handle empty feature content', () { - const featureContent = ''; - - expect( - () => featureBuilder.parseFeature(featureContent), - throwsException, - ); - }); - - test('should handle feature with only whitespace', () { - const featureContent = ' \n \t '; - - expect( - () => featureBuilder.parseFeature(featureContent), - throwsException, - ); - }); - - test('should handle scenario with And/But steps', () { - const featureContent = ''' -Feature: Complex steps - Scenario: Using And/But steps - Given I am on the page - And I am logged in - When I click the button - But I wait for 2 seconds - Then I should see the result - And the result should be correct -'''; - - final feature = featureBuilder.parseFeature(featureContent); - expect(feature.scenarios[0].steps.length, equals(6)); - expect(feature.scenarios[0].steps[1].keyword, equals('And')); - expect(feature.scenarios[0].steps[3].keyword, equals('But')); - }); - - test('should handle scenario with empty steps', () { - const featureContent = ''' -Feature: Empty steps - Scenario: Empty step scenario - Given - When - Then -'''; - - final feature = featureBuilder.parseFeature(featureContent); - expect(feature.scenarios[0].steps.length, equals(3)); - expect(feature.scenarios[0].steps[0].text, isEmpty); - }); - - test('should handle feature with special characters in names', () { - const featureContent = ''' -Feature: Special @#\$%^&*() characters - Scenario: Test with special chars !@#\$%^&*() - Given I have special chars - When I process them - Then they should be handled correctly -'''; - - final feature = featureBuilder.parseFeature(featureContent); - expect(feature.name, equals('Special @#\$%^&*() characters')); - expect(feature.scenarios[0].name, equals('Test with special chars !@#\$%^&*()')); - }); - }); -} diff --git a/test/parsers/feature_parser_test.dart b/test/parsers/feature_parser_test.dart new file mode 100644 index 0000000..99d4b58 --- /dev/null +++ b/test/parsers/feature_parser_test.dart @@ -0,0 +1,201 @@ +import 'dart:io'; + +import 'package:bdd_flutter/src/domain/decorator.dart'; +import 'package:bdd_flutter/src/infrastructure/parsers/feature_parser.dart'; +import 'package:test/test.dart'; + +void main() { + late FeatureParser parser; + late Directory tempDir; + + setUp(() { + parser = FeatureParser(); + tempDir = Directory.systemTemp.createTempSync('bdd_test_'); + }); + + tearDown(() { + tempDir.deleteSync(recursive: true); + }); + + File createFeatureFile(String content) { + final file = File('${tempDir.path}/test.feature'); + file.writeAsStringSync(content); + return file; + } + + group('FeatureParser', () { + test('parses feature name', () async { + final file = createFeatureFile(''' +Feature: Login + Scenario: Successful login + Given I am on the login page +'''); + final feature = await parser.parseFeature(file.path); + expect(feature.name, equals('Login')); + }); + + test('parses scenario name', () async { + final file = createFeatureFile(''' +Feature: Login + Scenario: Successful login + Given I am on the login page +'''); + final feature = await parser.parseFeature(file.path); + expect(feature.scenarios.length, equals(1)); + expect(feature.scenarios.first.name, equals('Successful login')); + }); + + test('parses Given/When/Then steps', () async { + final file = createFeatureFile(''' +Feature: Login + Scenario: Successful login + Given I am on the login page + When I enter valid credentials + Then I should see the home page +'''); + final feature = await parser.parseFeature(file.path); + final steps = feature.scenarios.first.steps; + expect(steps.length, equals(3)); + expect(steps[0].keyword, equals('Given')); + expect(steps[0].text, equals('I am on the login page')); + expect(steps[1].keyword, equals('When')); + expect(steps[1].text, equals('I enter valid credentials')); + expect(steps[2].keyword, equals('Then')); + expect(steps[2].text, equals('I should see the home page')); + }); + + test('parses And steps', () async { + final file = createFeatureFile(''' +Feature: Login + Scenario: Successful login + Given I am on the login page + And I have a valid account + When I enter valid credentials + Then I should see the home page +'''); + final feature = await parser.parseFeature(file.path); + final steps = feature.scenarios.first.steps; + expect(steps.length, equals(4)); + expect(steps[1].keyword, equals('And')); + expect(steps[1].text, equals('I have a valid account')); + }); + + test('parses multiple scenarios', () async { + final file = createFeatureFile(''' +Feature: Login + Scenario: Successful login + Given I am on the login page + When I enter valid credentials + Then I should see the home page + Scenario: Failed login + Given I am on the login page + When I enter invalid credentials + Then I should see an error message +'''); + final feature = await parser.parseFeature(file.path); + expect(feature.scenarios.length, equals(2)); + expect(feature.scenarios[0].name, equals('Successful login')); + expect(feature.scenarios[1].name, equals('Failed login')); + }); + + test('parses Background', () async { + final file = createFeatureFile(''' +Feature: Counter + Background: Counter starts at 0 + Given I have a counter with value 0 + Scenario: Increment + When I increment the counter + Then the counter should have value 1 +'''); + final feature = await parser.parseFeature(file.path); + expect(feature.background, isNotNull); + expect(feature.background!.description, equals('Counter starts at 0')); + expect(feature.background!.steps.length, equals(1)); + expect(feature.background!.steps.first.text, equals('I have a counter with value 0')); + }); + + test('parses Examples table', () async { + final file = createFeatureFile(''' +Feature: Calculator + Scenario: Add two numbers + Given I have a calculator + When I add and + Then the result should be + Examples: + | a | b | result | + | 1 | 2 | 3 | + | 5 | 3 | 8 | +'''); + final feature = await parser.parseFeature(file.path); + final scenario = feature.scenarios.first; + expect(scenario.examples, isNotNull); + expect(scenario.examples!.length, equals(2)); + expect(scenario.examples![0], equals({'a': '1', 'b': '2', 'result': '3'})); + expect(scenario.examples![1], equals({'a': '5', 'b': '3', 'result': '8'})); + }); + + test('parses @unitTest decorator on scenario', () async { + final file = createFeatureFile(''' +Feature: Calculator + @unitTest + Scenario: Add two numbers + Given I have a calculator +'''); + final feature = await parser.parseFeature(file.path); + expect(feature.scenarios.first.decorators.hasUnitTest, isTrue); + }); + + test('parses @widgetTest decorator on scenario', () async { + final file = createFeatureFile(''' +Feature: Counter + @widgetTest + Scenario: Increment + Given I have a counter +'''); + final feature = await parser.parseFeature(file.path); + expect(feature.scenarios.first.decorators.hasWidgetTest, isTrue); + }); + + test('parses @enableReporter decorator on feature', () async { + final file = createFeatureFile(''' +@enableReporter +Feature: Counter + Scenario: Increment + Given I have a counter +'''); + final feature = await parser.parseFeature(file.path); + expect(feature.decorators.hasEnableReporter, isTrue); + }); + + test('parses feature with no scenarios returns empty list', () async { + final file = createFeatureFile(''' +Feature: Empty +'''); + final feature = await parser.parseFeature(file.path); + expect(feature.name, equals('Empty')); + expect(feature.scenarios, isEmpty); + }); + + test('parses multiple scenarios with Examples', () async { + final file = createFeatureFile(''' +Feature: Calculator + Scenario: Add + When I add and + Then the result is + Examples: + | a | b | result | + | 1 | 2 | 3 | + Scenario: Subtract + When I subtract from + Then the result is + Examples: + | a | b | result | + | 5 | 3 | 2 | +'''); + final feature = await parser.parseFeature(file.path); + expect(feature.scenarios.length, equals(2)); + expect(feature.scenarios[0].examples!.length, equals(1)); + expect(feature.scenarios[1].examples!.length, equals(1)); + }); + }); +} diff --git a/test/runner/flag_parser_test.dart b/test/runner/flag_parser_test.dart deleted file mode 100644 index cd24245..0000000 --- a/test/runner/flag_parser_test.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:bdd_flutter/src/runner/domain/cmd_flag.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('CmdFlag', () { - test('should create flag with correct properties', () { - const flag = CmdFlag('--test', '-t', 'Test flag'); - expect(flag.longForm, equals('--test')); - expect(flag.shortForm, equals('-t')); - expect(flag.description, equals('Test flag')); - }); - - test('should create copy with modified properties', () { - const original = CmdFlag('--test', '-t', 'Test flag'); - final copy = original.copyWith( - text: '--new', - shortText: '-n', - description: 'New flag', - ); - expect(copy.longForm, equals('--new')); - expect(copy.shortForm, equals('-n')); - expect(copy.description, equals('New flag')); - }); - - test('should maintain original properties when copying with null values', () { - const original = CmdFlag('--test', '-t', 'Test flag'); - final copy = original.copyWith(); - expect(copy.longForm, equals(original.longForm)); - expect(copy.shortForm, equals(original.shortForm)); - expect(copy.description, equals(original.description)); - }); - - test('should convert to string correctly', () { - const flag = CmdFlag('--test', '-t', 'Test flag'); - expect(flag.toString(), equals('--test, -t, Test flag')); - }); - }); - - group('CmdFlag.fromString', () { - test('should parse valid long form flag', () { - final flag = CmdFlag.fromString('--help'); - expect(flag, equals(CmdFlag.help)); - }); - - test('should parse valid short form flag', () { - final flag = CmdFlag.fromString('-h'); - expect(flag, equals(CmdFlag.help)); - }); - - test('should return invalid flag for unknown input', () { - final flag = CmdFlag.fromString('--unknown'); - expect(flag.longForm, equals('--unknown')); - expect(flag.shortForm, equals('--unknown')); - expect(flag.description, equals('Invalid flag')); - }); - - test('should parse all predefined flags', () { - for (final flag in CmdFlag.values) { - expect(CmdFlag.fromString(flag.longForm), equals(flag)); - expect(CmdFlag.fromString(flag.shortForm), equals(flag)); - } - }); - }); - - group('CmdFlagValidator', () { - test('should validate valid flags', () { - final flags = [CmdFlag.help, CmdFlag.unitTest]; - expect(CmdFlagValidator.validate(flags), isNull); - }); - - test('should detect invalid flag', () { - final flags = [CmdFlag.help, CmdFlag('--invalid', '-i', 'Invalid flag')]; - final error = CmdFlagValidator.validate(flags); - expect(error, isNotNull); - expect(error, contains('Invalid flag')); - }); - - test('should detect mutually exclusive flags', () { - final flags = [CmdFlag.newOnly, CmdFlag.force]; - final error = CmdFlagValidator.validate(flags); - expect(error, isNotNull); - expect(error, contains('Cannot use --new with --force')); - }); - - test('should validate empty flag list', () { - expect(CmdFlagValidator.validate([]), isNull); - }); - - test('should validate single valid flag', () { - expect(CmdFlagValidator.validate([CmdFlag.help]), isNull); - }); - }); -} From 1c2906ee2423e085a0edeaacd5f6cb6ba3c9042d Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Tue, 31 Mar 2026 21:05:21 -0600 Subject: [PATCH 13/24] feat: add support for custom scenario class names, ignore/disable decorators, and configurable build options via CLI --- .../calculator/calculator.bdd_scenarios.dart | 2 +- .../test/calculator/calculator.bdd_test.dart | 2 +- example/test/sample/sample.bdd_scenarios.dart | 2 +- example/test/sample/sample.bdd_test.dart | 2 +- lib/src/domain/build_options.dart | 13 +++ lib/src/domain/decorator.dart | 33 +++++--- lib/src/domain/scenario.dart | 15 ++++ .../builders/scenario_file_builder.dart | 8 +- .../builders/test_file_builder.dart | 19 +++-- .../parsers/feature_parser.dart | 31 +++---- lib/src/presentation/cli/bbd_cli.dart | 18 ++++- .../controllers/bdd_controller.dart | 12 ++- plan/refactor.md | 14 ++-- pubspec.yaml | 1 + test/builders/scenario_file_builder_test.dart | 49 ++++++++++++ test/parsers/feature_parser_test.dart | 80 +++++++++++++++++++ 16 files changed, 242 insertions(+), 59 deletions(-) create mode 100644 lib/src/domain/build_options.dart diff --git a/example/test/calculator/calculator.bdd_scenarios.dart b/example/test/calculator/calculator.bdd_scenarios.dart index dec73f9..da2fe89 100644 --- a/example/test/calculator/calculator.bdd_scenarios.dart +++ b/example/test/calculator/calculator.bdd_scenarios.dart @@ -19,7 +19,7 @@ class AddTwoNumbersScenario { } -class SubtractTwoNumbersScenario { +class Subtract { Future iHaveTheNumber5(WidgetTester tester) async { // TODO: Implement Given I have the number 5 } diff --git a/example/test/calculator/calculator.bdd_test.dart b/example/test/calculator/calculator.bdd_test.dart index 2c69927..f3e3b59 100644 --- a/example/test/calculator/calculator.bdd_test.dart +++ b/example/test/calculator/calculator.bdd_test.dart @@ -16,7 +16,7 @@ void main() { await scenario.theResultShouldBe3(tester); }); testWidgets('Subtract two numbers', (tester) async { - final scenario = SubtractTwoNumbersScenario(); + final scenario = Subtract(); //Scenario: Subtract two numbers // Given I have the number 5 await scenario.iHaveTheNumber5(tester); diff --git a/example/test/sample/sample.bdd_scenarios.dart b/example/test/sample/sample.bdd_scenarios.dart index 944e307..25f5445 100644 --- a/example/test/sample/sample.bdd_scenarios.dart +++ b/example/test/sample/sample.bdd_scenarios.dart @@ -15,7 +15,7 @@ class SampleScenario { } -class CounterScenario { +class CounterCustomName { Future iHaveACounter(WidgetTester tester) async { // TODO: Implement Given I have a counter } diff --git a/example/test/sample/sample.bdd_test.dart b/example/test/sample/sample.bdd_test.dart index ca76f76..1a86242 100644 --- a/example/test/sample/sample.bdd_test.dart +++ b/example/test/sample/sample.bdd_test.dart @@ -14,7 +14,7 @@ void main() { await scenario.iShouldSeeTheSampleFeature(tester); }); testWidgets('Counter', (tester) async { - final scenario = CounterScenario(); + final scenario = CounterCustomName(); //Scenario: Counter // Given I have a counter await scenario.iHaveACounter(tester); diff --git a/lib/src/domain/build_options.dart b/lib/src/domain/build_options.dart new file mode 100644 index 0000000..af9f673 --- /dev/null +++ b/lib/src/domain/build_options.dart @@ -0,0 +1,13 @@ +class BuildOptions { + final bool widgetTest; + final bool reporter; + final bool force; + final bool newOnly; + + const BuildOptions({ + this.widgetTest = true, + this.reporter = false, + this.force = false, + this.newOnly = false, + }); +} diff --git a/lib/src/domain/decorator.dart b/lib/src/domain/decorator.dart index 23f1ebf..b2058c0 100644 --- a/lib/src/domain/decorator.dart +++ b/lib/src/domain/decorator.dart @@ -2,25 +2,28 @@ enum Decorator { unitTest, widgetTest, enableReporter, + disableReporter, + ignore, unknown; static Decorator fromString(String text) { - return switch (text) { '@unitTest' => Decorator.unitTest, '@widgetTest' => Decorator.widgetTest, '@enableReporter' => Decorator.enableReporter, _ => Decorator.unknown }; - } - - static Set elligibleForScenario() { - return { - Decorator.unitTest, - Decorator.widgetTest, + // Strip the text to handle just the decorator name + final trimmed = text.trim(); + return switch (trimmed) { + '@unitTest' => Decorator.unitTest, + '@widgetTest' => Decorator.widgetTest, + '@enableReporter' => Decorator.enableReporter, + '@disableReporter' => Decorator.disableReporter, + '@ignore' => Decorator.ignore, + _ => Decorator.unknown, }; } - static Set elligibleForFeature() { - return { - Decorator.unitTest, - Decorator.widgetTest, - Decorator.enableReporter, - }; + /// Check if text is a @className("...") decorator and extract the name + static String? parseClassName(String text) { + final regex = RegExp(r'^@className\("(.+)"\)$'); + final match = regex.firstMatch(text.trim()); + return match?.group(1); } } @@ -28,10 +31,14 @@ extension DecoratorX on Decorator { bool get isUnitTest => this == Decorator.unitTest; bool get isWidgetTest => this == Decorator.widgetTest; bool get isEnableReporter => this == Decorator.enableReporter; + bool get isDisableReporter => this == Decorator.disableReporter; + bool get isIgnore => this == Decorator.ignore; } extension DecoratorSetX on Set { bool get hasUnitTest => any((e) => e.isUnitTest); bool get hasWidgetTest => any((e) => e.isWidgetTest); bool get hasEnableReporter => any((e) => e.isEnableReporter); + bool get hasDisableReporter => any((e) => e.isDisableReporter); + bool get hasIgnore => any((e) => e.isIgnore); } diff --git a/lib/src/domain/scenario.dart b/lib/src/domain/scenario.dart index db75df5..69b7553 100644 --- a/lib/src/domain/scenario.dart +++ b/lib/src/domain/scenario.dart @@ -19,11 +19,15 @@ class Scenario { /// The decorators of the scenario Set decorators; + /// Custom class name from @className("...") decorator + String? customClassName; + Scenario( this.name, this.steps, { this.examples, this.decorators = const {}, + this.customClassName, }); factory Scenario.init() => Scenario( @@ -40,10 +44,21 @@ class Scenario { } extension ScenarioX on Scenario { + /// Check if unit test โ€” scenario decorator overrides, then fall back to feature bool get isUnitTest => decorators.hasUnitTest; bool get isWidgetTest => decorators.hasWidgetTest; + /// Resolve whether this is a unit test considering feature-level decorators + bool isUnitTestWithFeature(Set featureDecorators) { + if (decorators.hasUnitTest) return true; + if (decorators.hasWidgetTest) return false; + // Fall back to feature-level + if (featureDecorators.hasUnitTest) return true; + return false; + } + String get className { + if (customClassName != null) return customClassName!; return name.toScenarioClassName; } diff --git a/lib/src/infrastructure/builders/scenario_file_builder.dart b/lib/src/infrastructure/builders/scenario_file_builder.dart index 15516b0..dd5db71 100644 --- a/lib/src/infrastructure/builders/scenario_file_builder.dart +++ b/lib/src/infrastructure/builders/scenario_file_builder.dart @@ -1,3 +1,4 @@ +import '../../domain/decorator.dart'; import '../../extensions/string_x.dart'; import '../../domain/feature.dart'; import '../../domain/scenario.dart'; @@ -25,13 +26,14 @@ class ScenariosFileBuilder { buffer.writeln(); } - // Create a class for each scenario for (var scenario in feature.scenarios) { - final isUnitTest = scenario.isUnitTest; + if (scenario.decorators.hasIgnore) continue; + + // Resolve unit test considering feature-level decorators + final isUnitTest = scenario.isUnitTestWithFeature(feature.decorators); buffer.writeln("class ${scenario.className} {"); - // Create instance methods for each step in the scenario for (var step in scenario.steps) { final methodName = step.methodName; final params = extractMethodParams(step.text); diff --git a/lib/src/infrastructure/builders/test_file_builder.dart b/lib/src/infrastructure/builders/test_file_builder.dart index fe60b88..9232a00 100644 --- a/lib/src/infrastructure/builders/test_file_builder.dart +++ b/lib/src/infrastructure/builders/test_file_builder.dart @@ -8,9 +8,11 @@ import '../../extensions/string_x.dart'; class TestFileBuilder { Future buildTestFile(Feature feature) async { final buffer = StringBuffer(); + final useReporter = feature.decorators.hasEnableReporter && + !feature.decorators.hasDisableReporter; + buffer.writeln("import 'package:flutter_test/flutter_test.dart';"); - //add reporter import if needed - if (feature.decorators.hasEnableReporter) { + if (useReporter) { buffer.writeln("import 'package:bdd_flutter/bdd_flutter.dart';"); } @@ -18,8 +20,7 @@ class TestFileBuilder { buffer.writeln(); buffer.writeln("void main() {"); - //add reporter initialization if needed - if (feature.decorators.hasEnableReporter) { + if (useReporter) { buffer.writeln(" final reporter = BDDTestReporter(featureName: '${feature.name}');"); buffer.writeln(" setUpAll(() {"); buffer.writeln(" reporter.testStarted(); // start recording"); @@ -34,8 +35,10 @@ class TestFileBuilder { buffer.writeln(" group('${feature.name}', () {"); for (var scenario in feature.scenarios) { + if (scenario.decorators.hasIgnore) continue; + final className = scenario.className; - final isUnitTest = scenario.isUnitTest; + final isUnitTest = scenario.isUnitTestWithFeature(feature.decorators); final testFunction = isUnitTest ? 'test' : 'testWidgets'; // Generate one test case per scenario @@ -60,7 +63,7 @@ class TestFileBuilder { buffer.writeln(" //Scenario: ${scenario.name}"); //add start scenario if needed - if (feature.decorators.hasEnableReporter) { + if (useReporter) { buffer.writeln(" reporter.startScenario('${scenario.name}');"); } @@ -91,7 +94,7 @@ class TestFileBuilder { buffer.writeln(_generateStepCall( step, - feature.decorators.hasEnableReporter, + useReporter, isUnitTest, params, )); @@ -102,7 +105,7 @@ class TestFileBuilder { for (var step in scenario.steps) { buffer.writeln(_generateStepCall( step, - feature.decorators.hasEnableReporter, + useReporter, isUnitTest, [], )); diff --git a/lib/src/infrastructure/parsers/feature_parser.dart b/lib/src/infrastructure/parsers/feature_parser.dart index 662c644..dd49818 100644 --- a/lib/src/infrastructure/parsers/feature_parser.dart +++ b/lib/src/infrastructure/parsers/feature_parser.dart @@ -23,13 +23,8 @@ class FeatureParser { // scenario that is being process Scenario? currentScenario; - // currrent scenario decorators that is being process List currentScenarioDecorators = []; - // current scenario example that is being process - // List> currentExamples = []; - // List exampleHeaders = []; - - // bool isParsingExamples = false; + String? currentScenarioClassName; ExampleContent? currentExampleContent; @@ -45,27 +40,27 @@ class FeatureParser { else if (line.startsWith("@")) { //parsing feature decorators if (featureName == null) { - // if lines start with @ and feature name is null, - // it means it's a decorator for the feature featureDecorators.add(Decorator.fromString(line)); } //parsing scenario decorators else { isParsingBackground = false; if (currentScenario != null) { - // add currentScenario to the list and clear it and currentScenarioDecorators currentScenario.examples = currentExampleContent?.examples; - scenarios.add(currentScenario); - currentExampleContent = null; currentScenario = null; currentScenarioDecorators = []; + currentScenarioClassName = null; } - // if lines start with @ and feature name is not null, - // it means it's a decorator for the scenario - currentScenarioDecorators.add(Decorator.fromString(line)); + // Check for @className("...") decorator + final className = Decorator.parseClassName(line); + if (className != null) { + currentScenarioClassName = className; + } else { + currentScenarioDecorators.add(Decorator.fromString(line)); + } } } // start parsing background @@ -98,16 +93,14 @@ class FeatureParser { currentExampleContent = null; } - //create new scenario - final decorators = currentScenarioDecorators.toSet(); - currentScenario = Scenario( name, [], - decorators: decorators, + decorators: currentScenarioDecorators.toSet(), + customClassName: currentScenarioClassName, ); - // Reset decorators and example content for next scenario currentScenarioDecorators = []; + currentScenarioClassName = null; currentExampleContent = null; } // parsing steps diff --git a/lib/src/presentation/cli/bbd_cli.dart b/lib/src/presentation/cli/bbd_cli.dart index a7e1f93..a4160f8 100644 --- a/lib/src/presentation/cli/bbd_cli.dart +++ b/lib/src/presentation/cli/bbd_cli.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import '../../domain/build_options.dart'; import '../controllers/bdd_controller.dart'; class BDDCLI { @@ -15,10 +16,17 @@ class BDDCLI { } final command = arguments.first; + final flags = arguments.skip(1).toSet(); switch (command) { case 'build': - await _bddController.generateFeatureTestCases(); + final options = BuildOptions( + widgetTest: !flags.contains('--no-widget-test'), + reporter: flags.contains('--reporter'), + force: flags.contains('--force'), + newOnly: flags.contains('--new-only'), + ); + await _bddController.generateFeatureTestCases(options: options); break; default: stdout.writeln('Unknown command: $command'); @@ -27,9 +35,15 @@ class BDDCLI { } void _printUsage() { - stdout.writeln('Usage: dart run bdd_flutter '); + stdout.writeln('Usage: dart run bdd_flutter [flags]'); stdout.writeln(''); stdout.writeln('Available commands:'); stdout.writeln(' build Generate test files from .feature files'); + stdout.writeln(''); + stdout.writeln('Flags:'); + stdout.writeln(' --no-widget-test Generate unit tests instead of widget tests'); + stdout.writeln(' --reporter Enable test reporter'); + stdout.writeln(' --force Force regenerate all files'); + stdout.writeln(' --new-only Only generate for new feature files'); } } diff --git a/lib/src/presentation/controllers/bdd_controller.dart b/lib/src/presentation/controllers/bdd_controller.dart index 18efc75..3bb8daf 100644 --- a/lib/src/presentation/controllers/bdd_controller.dart +++ b/lib/src/presentation/controllers/bdd_controller.dart @@ -1,7 +1,8 @@ import 'dart:io'; +import '../../domain/build_options.dart'; +import '../../domain/decorator.dart'; import '../../infrastructure/parsers/feature_parser.dart'; - import '../../infrastructure/builders/scenario_file_builder.dart'; import '../../infrastructure/builders/test_file_builder.dart'; @@ -18,7 +19,7 @@ class BDDController { _scenarioFileBuilder = scenarioFileBuilder ?? ScenariosFileBuilder(), _testFileBuilder = testFileBuilder ?? TestFileBuilder(); - Future generateFeatureTestCases() async { + Future generateFeatureTestCases({BuildOptions options = const BuildOptions()}) async { final featureFiles = Directory('test/') .listSync(recursive: true) .where((file) => file.path.endsWith('.feature')); @@ -32,6 +33,13 @@ class BDDController { for (var featureFile in featureFiles) { final feature = await _featureParser.parseFeature(featureFile.path); + + // Skip features with @ignore decorator + if (feature.decorators.hasIgnore) { + stdout.writeln(' Skipped (ignored): ${featureFile.path}'); + continue; + } + final scenarioContent = await _scenarioFileBuilder.buildScenarioFile(feature); final testContent = await _testFileBuilder.buildTestFile(feature); diff --git a/plan/refactor.md b/plan/refactor.md index e0c2e25..781ccb7 100644 --- a/plan/refactor.md +++ b/plan/refactor.md @@ -172,20 +172,18 @@ testWidgets('Increment', (tester) async { --- -## Iteration 4: Rename Command & Polish +## Iteration 4: Polish -**Goal**: `rename` command works. Clean up, final docs. +**Goal**: Clean up, final docs. -### 4.1 Rename command -- `dart run bdd_flutter rename` strips `.bdd_` prefix from generated files -- Add to CLI parser and controller +~~Rename command removed~~ โ€” manifest tracking makes it unnecessary. The `.bdd_` prefix signals generated files for `.gitignore`, and incremental builds avoid overwriting implemented steps. -### 4.2 Cleanup +### 4.1 Cleanup - Remove all remaining debug `print()` statements - Update CLAUDE.md to reflect final architecture -- Update README if needed +- Update README to remove `rename` command references -### 4.3 Final verification +### 4.2 Final verification - All commands work end-to-end against example project - `dart analyze` โ€” no issues - `flutter test` โ€” all tests pass diff --git a/pubspec.yaml b/pubspec.yaml index b093cf0..bb0f7aa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,7 @@ topics: - gherkin dependencies: + crypto: ^3.0.0 yaml: ^3.1.0 dev_dependencies: diff --git a/test/builders/scenario_file_builder_test.dart b/test/builders/scenario_file_builder_test.dart index c73b318..3721c9e 100644 --- a/test/builders/scenario_file_builder_test.dart +++ b/test/builders/scenario_file_builder_test.dart @@ -108,6 +108,55 @@ void main() { expect(result, contains("import 'package:flutter_test/flutter_test.dart';")); }); + test('uses @className for custom class name', () async { + final feature = Feature( + name: 'Login', + path: 'test/login.feature', + scenarios: [ + Scenario('Test', [Step('Given', 'something')], customClassName: 'MyCustomScenario'), + ], + decorators: {}, + ); + + final result = await builder.buildScenarioFile(feature); + + expect(result, contains('class MyCustomScenario {')); + expect(result, isNot(contains('class TestScenario {'))); + }); + + test('skips scenarios with @ignore', () async { + final feature = Feature( + name: 'Login', + path: 'test/login.feature', + scenarios: [ + Scenario('Skipped', [Step('Given', 'something')], decorators: {Decorator.ignore}), + Scenario('Active', [Step('Given', 'something else')]), + ], + decorators: {}, + ); + + final result = await builder.buildScenarioFile(feature); + + expect(result, isNot(contains('class SkippedScenario {'))); + expect(result, contains('class ActiveScenario {')); + }); + + test('inherits @unitTest from feature decorators', () async { + final feature = Feature( + name: 'Calculator', + path: 'test/calculator.feature', + scenarios: [ + Scenario('Add', [Step('Given', 'I have a calculator')]), + ], + decorators: {Decorator.unitTest}, + ); + + final result = await builder.buildScenarioFile(feature); + + // Should NOT have WidgetTester since feature is @unitTest + expect(result, isNot(contains('WidgetTester'))); + }); + test('generates multiple scenario classes', () async { final feature = Feature( name: 'Login', diff --git a/test/parsers/feature_parser_test.dart b/test/parsers/feature_parser_test.dart index 99d4b58..a350d28 100644 --- a/test/parsers/feature_parser_test.dart +++ b/test/parsers/feature_parser_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:bdd_flutter/src/domain/decorator.dart'; +import 'package:bdd_flutter/src/domain/scenario.dart'; import 'package:bdd_flutter/src/infrastructure/parsers/feature_parser.dart'; import 'package:test/test.dart'; @@ -176,6 +177,85 @@ Feature: Empty expect(feature.scenarios, isEmpty); }); + test('parses @ignore decorator on feature', () async { + final file = createFeatureFile(''' +@ignore +Feature: Ignored + Scenario: Test + Given something +'''); + final feature = await parser.parseFeature(file.path); + expect(feature.decorators.hasIgnore, isTrue); + }); + + test('parses @ignore decorator on scenario', () async { + final file = createFeatureFile(''' +Feature: Login + @ignore + Scenario: Skipped + Given something + Scenario: Active + Given something else +'''); + final feature = await parser.parseFeature(file.path); + expect(feature.scenarios[0].decorators.hasIgnore, isTrue); + expect(feature.scenarios[1].decorators.hasIgnore, isFalse); + }); + + test('parses @className decorator', () async { + final file = createFeatureFile(''' +Feature: Login + @className("MyCustomClass") + Scenario: Test + Given something +'''); + final feature = await parser.parseFeature(file.path); + expect(feature.scenarios.first.customClassName, equals('MyCustomClass')); + }); + + test('parses @disableReporter decorator on feature', () async { + final file = createFeatureFile(''' +@enableReporter +@disableReporter +Feature: Counter + Scenario: Test + Given something +'''); + final feature = await parser.parseFeature(file.path); + expect(feature.decorators.hasEnableReporter, isTrue); + expect(feature.decorators.hasDisableReporter, isTrue); + }); + + test('parses @unitTest on feature applies to scenarios', () async { + final file = createFeatureFile(''' +@unitTest +Feature: Calculator + Scenario: Add + Given I have a calculator +'''); + final feature = await parser.parseFeature(file.path); + expect(feature.decorators.hasUnitTest, isTrue); + // Scenario inherits from feature + expect(feature.scenarios.first.isUnitTestWithFeature(feature.decorators), isTrue); + }); + + test('scenario decorator overrides feature decorator', () async { + final file = createFeatureFile(''' +@unitTest +Feature: Calculator + @widgetTest + Scenario: Widget scenario + Given something + Scenario: Unit scenario + Given something else +'''); + final feature = await parser.parseFeature(file.path); + // First scenario has @widgetTest, should NOT be unit test + expect(feature.scenarios[0].isUnitTestWithFeature(feature.decorators), isFalse); + // Second scenario has no decorator, falls back to feature @unitTest + expect(feature.scenarios[1].isUnitTestWithFeature(feature.decorators), isTrue); + }); + test('parses multiple scenarios with Examples', () async { final file = createFeatureFile(''' Feature: Calculator From 73b5823ebdb46b2c84f6985c0985edf723a2839a Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Tue, 31 Mar 2026 21:06:51 -0600 Subject: [PATCH 14/24] feat: implement incremental test generation with manifest tracking and configuration support --- lib/src/domain/config.dart | 11 +++ lib/src/domain/manifest.dart | 37 ++++++++ .../infrastructure/parsers/config_parser.dart | 41 ++++++++ .../parsers/manifest_parser.dart | 94 +++++++++++++++++++ .../controllers/bdd_controller.dart | 82 +++++++++++++++- 5 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 lib/src/domain/config.dart create mode 100644 lib/src/domain/manifest.dart create mode 100644 lib/src/infrastructure/parsers/config_parser.dart create mode 100644 lib/src/infrastructure/parsers/manifest_parser.dart diff --git a/lib/src/domain/config.dart b/lib/src/domain/config.dart new file mode 100644 index 0000000..3839106 --- /dev/null +++ b/lib/src/domain/config.dart @@ -0,0 +1,11 @@ +class BDDConfig { + final bool generateWidgetTests; + final bool enableReporter; + final List ignoreFeatures; + + const BDDConfig({ + this.generateWidgetTests = true, + this.enableReporter = false, + this.ignoreFeatures = const [], + }); +} diff --git a/lib/src/domain/manifest.dart b/lib/src/domain/manifest.dart new file mode 100644 index 0000000..32745b0 --- /dev/null +++ b/lib/src/domain/manifest.dart @@ -0,0 +1,37 @@ +class Manifest { + final String version; + final DateTime lastGenerated; + final List features; + + Manifest({ + this.version = '1.0', + DateTime? lastGenerated, + this.features = const [], + }) : lastGenerated = lastGenerated ?? DateTime.now(); +} + +class ManifestFeature { + final String path; + final String lastModified; + final String testFile; + final List scenarios; + + ManifestFeature({ + required this.path, + required this.lastModified, + required this.testFile, + this.scenarios = const [], + }); +} + +class ManifestScenario { + final String name; + final String hash; + final String testMethod; + + ManifestScenario({ + required this.name, + required this.hash, + required this.testMethod, + }); +} diff --git a/lib/src/infrastructure/parsers/config_parser.dart b/lib/src/infrastructure/parsers/config_parser.dart new file mode 100644 index 0000000..2a1ad02 --- /dev/null +++ b/lib/src/infrastructure/parsers/config_parser.dart @@ -0,0 +1,41 @@ +import 'dart:io'; + +import 'package:yaml/yaml.dart'; + +import '../../domain/config.dart'; + +class ConfigParser { + static const String configDir = '.bdd_flutter'; + static const String configFile = '$configDir/config.yaml'; + + Future loadConfig() async { + final file = File(configFile); + + if (!file.existsSync()) { + return const BDDConfig(); + } + + final content = await file.readAsString(); + if (content.trim().isEmpty) { + return const BDDConfig(); + } + + final yaml = loadYaml(content); + if (yaml is! YamlMap) { + return const BDDConfig(); + } + + return BDDConfig( + generateWidgetTests: yaml['generate_widget_tests'] as bool? ?? true, + enableReporter: yaml['enable_reporter'] as bool? ?? false, + ignoreFeatures: _parseStringList(yaml['ignore_features']), + ); + } + + List _parseStringList(dynamic value) { + if (value is YamlList) { + return value.map((e) => e.toString()).toList(); + } + return []; + } +} diff --git a/lib/src/infrastructure/parsers/manifest_parser.dart b/lib/src/infrastructure/parsers/manifest_parser.dart new file mode 100644 index 0000000..6b0b54c --- /dev/null +++ b/lib/src/infrastructure/parsers/manifest_parser.dart @@ -0,0 +1,94 @@ +import 'dart:io'; + +import 'package:yaml/yaml.dart'; + +import '../../domain/manifest.dart'; + +class ManifestParser { + static const String manifestDir = '.bdd_flutter'; + static const String manifestFile = '$manifestDir/manifest.yaml'; + + Future loadManifest() async { + final file = File(manifestFile); + + if (!file.existsSync()) { + return Manifest(); + } + + final content = await file.readAsString(); + if (content.trim().isEmpty) { + return Manifest(); + } + + final yaml = loadYaml(content); + if (yaml is! YamlMap) { + return Manifest(); + } + + final features = []; + final yamlFeatures = yaml['features']; + if (yamlFeatures is YamlList) { + for (final f in yamlFeatures) { + if (f is! YamlMap) continue; + final scenarios = []; + final yamlScenarios = f['scenarios']; + if (yamlScenarios is YamlList) { + for (final s in yamlScenarios) { + if (s is! YamlMap) continue; + scenarios.add(ManifestScenario( + name: s['name']?.toString() ?? '', + hash: s['hash']?.toString() ?? '', + testMethod: s['test_method']?.toString() ?? '', + )); + } + } + features.add(ManifestFeature( + path: f['path']?.toString() ?? '', + lastModified: f['last_modified']?.toString() ?? '', + testFile: f['test_file']?.toString() ?? '', + scenarios: scenarios, + )); + } + } + + return Manifest( + version: yaml['version']?.toString() ?? '1.0', + lastGenerated: DateTime.tryParse(yaml['last_generated']?.toString() ?? ''), + features: features, + ); + } + + Future saveManifest(Manifest manifest) async { + final dir = Directory(manifestDir); + if (!dir.existsSync()) { + dir.createSync(recursive: true); + } + + final buffer = StringBuffer(); + buffer.writeln('version: "${manifest.version}"'); + buffer.writeln('last_generated: "${manifest.lastGenerated.toIso8601String()}"'); + buffer.writeln('features:'); + + for (final feature in manifest.features) { + buffer.writeln(' - path: "${feature.path}"'); + buffer.writeln(' last_modified: "${feature.lastModified}"'); + buffer.writeln(' test_file: "${feature.testFile}"'); + buffer.writeln(' scenarios:'); + for (final scenario in feature.scenarios) { + buffer.writeln(' - name: "${scenario.name}"'); + buffer.writeln(' hash: "${scenario.hash}"'); + buffer.writeln(' test_method: "${scenario.testMethod}"'); + } + } + + await File(manifestFile).writeAsString(buffer.toString()); + } + + /// Find a feature entry in the manifest by path + ManifestFeature? findFeature(Manifest manifest, String path) { + for (final feature in manifest.features) { + if (feature.path == path) return feature; + } + return null; + } +} diff --git a/lib/src/presentation/controllers/bdd_controller.dart b/lib/src/presentation/controllers/bdd_controller.dart index 3bb8daf..76f077f 100644 --- a/lib/src/presentation/controllers/bdd_controller.dart +++ b/lib/src/presentation/controllers/bdd_controller.dart @@ -2,7 +2,11 @@ import 'dart:io'; import '../../domain/build_options.dart'; import '../../domain/decorator.dart'; +import '../../domain/manifest.dart'; +import '../../domain/scenario.dart'; +import '../../infrastructure/parsers/config_parser.dart'; import '../../infrastructure/parsers/feature_parser.dart'; +import '../../infrastructure/parsers/manifest_parser.dart'; import '../../infrastructure/builders/scenario_file_builder.dart'; import '../../infrastructure/builders/test_file_builder.dart'; @@ -10,16 +14,28 @@ class BDDController { final FeatureParser _featureParser; final ScenariosFileBuilder _scenarioFileBuilder; final TestFileBuilder _testFileBuilder; + final ConfigParser _configParser; + final ManifestParser _manifestParser; BDDController({ FeatureParser? featureParser, ScenariosFileBuilder? scenarioFileBuilder, TestFileBuilder? testFileBuilder, + ConfigParser? configParser, + ManifestParser? manifestParser, }) : _featureParser = featureParser ?? FeatureParser(), _scenarioFileBuilder = scenarioFileBuilder ?? ScenariosFileBuilder(), - _testFileBuilder = testFileBuilder ?? TestFileBuilder(); + _testFileBuilder = testFileBuilder ?? TestFileBuilder(), + _configParser = configParser ?? ConfigParser(), + _manifestParser = manifestParser ?? ManifestParser(); Future generateFeatureTestCases({BuildOptions options = const BuildOptions()}) async { + // Load config + final config = await _configParser.loadConfig(); + + // Load existing manifest + final manifest = await _manifestParser.loadManifest(); + final featureFiles = Directory('test/') .listSync(recursive: true) .where((file) => file.path.endsWith('.feature')); @@ -31,15 +47,56 @@ class BDDController { stdout.writeln('Found ${featureFiles.length} feature file(s).'); + final updatedFeatures = []; + int generated = 0; + int skipped = 0; + for (var featureFile in featureFiles) { final feature = await _featureParser.parseFeature(featureFile.path); // Skip features with @ignore decorator if (feature.decorators.hasIgnore) { - stdout.writeln(' Skipped (ignored): ${featureFile.path}'); + stdout.writeln(' Skipped (@ignore): ${featureFile.path}'); + skipped++; + continue; + } + + // Skip features in ignore_features config + if (config.ignoreFeatures.any((ignored) => featureFile.path.endsWith(ignored))) { + stdout.writeln(' Skipped (config): ${featureFile.path}'); + skipped++; + continue; + } + + final existingManifestEntry = _manifestParser.findFeature(manifest, featureFile.path); + + // Check generation mode + if (options.newOnly && existingManifestEntry != null) { + stdout.writeln(' Skipped (existing): ${featureFile.path}'); + skipped++; + // Keep existing manifest entry + updatedFeatures.add(existingManifestEntry); continue; } + if (!options.force && existingManifestEntry != null) { + // Incremental mode: check if feature has changed + final fileLastModified = featureFile.statSync().modified.toIso8601String(); + if (existingManifestEntry.lastModified == fileLastModified) { + // Check if all scenario hashes match + final currentHashes = feature.scenarios.map((s) => s.getHash).toSet(); + final manifestHashes = existingManifestEntry.scenarios.map((s) => s.hash).toSet(); + if (currentHashes.length == manifestHashes.length && + currentHashes.containsAll(manifestHashes)) { + stdout.writeln(' Skipped (unchanged): ${featureFile.path}'); + skipped++; + updatedFeatures.add(existingManifestEntry); + continue; + } + } + } + + // Generate files final scenarioContent = await _scenarioFileBuilder.buildScenarioFile(feature); final testContent = await _testFileBuilder.buildTestFile(feature); @@ -51,8 +108,27 @@ class BDDController { stdout.writeln(' Generated: $scenarioPath'); stdout.writeln(' Generated: $testPath'); + generated++; + + // Build manifest entry for this feature + updatedFeatures.add(ManifestFeature( + path: featureFile.path, + lastModified: featureFile.statSync().modified.toIso8601String(), + testFile: testPath, + scenarios: feature.scenarios.map((s) => ManifestScenario( + name: s.name, + hash: s.getHash, + testMethod: 'test${s.className}', + )).toList(), + )); } - stdout.writeln('Done.'); + // Save updated manifest + final updatedManifest = Manifest( + features: updatedFeatures, + ); + await _manifestParser.saveManifest(updatedManifest); + + stdout.writeln('Done. Generated: $generated, Skipped: $skipped.'); } } From d0277c202c349ee2e7a092de6a5358edf087b38a Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Tue, 31 Mar 2026 21:13:03 -0600 Subject: [PATCH 15/24] test: add unit tests for config and manifest parsers and update manifest schema --- example/.bdd_flutter/manifest.yaml | 94 +++++++------- .../infrastructure/parsers/config_parser.dart | 8 +- .../parsers/manifest_parser.dart | 12 +- test/parsers/config_parser_test.dart | 72 +++++++++++ test/parsers/manifest_parser_test.dart | 122 ++++++++++++++++++ 5 files changed, 256 insertions(+), 52 deletions(-) create mode 100644 test/parsers/config_parser_test.dart create mode 100644 test/parsers/manifest_parser_test.dart diff --git a/example/.bdd_flutter/manifest.yaml b/example/.bdd_flutter/manifest.yaml index 707a43c..71c705e 100644 --- a/example/.bdd_flutter/manifest.yaml +++ b/example/.bdd_flutter/manifest.yaml @@ -1,87 +1,87 @@ version: "1.0" -last_generated: "2025-05-25T17:03:13.083276" +last_generated: "2026-03-31T21:12:49.191295" features: - path: "test/calculator/calculator.feature" - last_modified: "2025-05-16T22:11:21.000" + last_modified: "2025-08-16T13:55:30.921" test_file: "test/calculator/calculator.bdd_test.dart" scenarios: - name: "Add two numbers" - hash: "0a0e99eea6929a8ae27c1d95a88b293d" - test_method: "testAddtwonumbers" + hash: "f03974460050014a1179e86546325452" + test_method: "testAddTwoNumbersScenario" - name: "Subtract two numbers" - hash: "ccc195782104eafe6ae0987cb7435146" - test_method: "testSubtracttwonumbers" + hash: "58dfc2abf3ca9433c7c97ab174300a1d" + test_method: "testSubtract" - name: "Subtract two numbers2" - hash: "58ce85c7cb7cc7c2453e862ebb12e20b" - test_method: "testSubtracttwonumbers2" + hash: "f50907ed69f6ec5526f6acf958f01496" + test_method: "testSubtractTwoNumbers2Scenario" - name: "Multiply two numbers" - hash: "560cbb5eedd961771237ba5d9ae3e7a3" - test_method: "testMultiplytwonumbers" + hash: "8dba45ac682e9fa560bc6eefa73bc1f4" + test_method: "testMultiplyTwoNumbersScenario" - name: "Divide two numbers" - hash: "e85196f20fc91d407521ee37fcad2320" - test_method: "testDividetwonumbers" + hash: "415425b012fac2d176e72560dac0b8e1" + test_method: "testDivideTwoNumbersScenario" - name: "Divide two numbers2" - hash: "19716544b83f2724d31ac3204a997498" - test_method: "testDividetwonumbers2" + hash: "737c00e721ef5df3f8d9eeb338a51b79" + test_method: "testDivideTwoNumbers2Scenario" - path: "test/features/feature2.feature" - last_modified: "2025-05-19T14:28:03.000" + last_modified: "2025-05-19T14:28:03.988" test_file: "test/features/feature2.bdd_test.dart" scenarios: - name: "Scenario 2" - hash: "5084b8f7e6886a111ed0bbc7e953f6fa" - test_method: "testScenario2" + hash: "8023fb9d6a1d88eadee8e007b8f8e967" + test_method: "testScenario2Scenario" - name: "Scenario 3" - hash: "17288edb6b3917af4162d920d37e8b8a" - test_method: "testScenario3" + hash: "995fc3dbd32f20d5657d97ba82c0f467" + test_method: "testScenario3Scenario" - path: "test/features/feature3.feature" - last_modified: "2025-05-14T21:04:20.000" + last_modified: "2025-05-14T21:04:20.415" test_file: "test/features/feature3.bdd_test.dart" scenarios: - name: "Scenario 3" - hash: "17288edb6b3917af4162d920d37e8b8a" - test_method: "testScenario3" + hash: "995fc3dbd32f20d5657d97ba82c0f467" + test_method: "testScenario3Scenario" - path: "test/features/feature1.feature" - last_modified: "2025-05-20T22:28:54.000" + last_modified: "2025-05-20T22:28:54.794" test_file: "test/features/feature1.bdd_test.dart" scenarios: - name: "Scenario 1" - hash: "37b9806aac30d8368e7d7ffb3d741dd2" - test_method: "testScenario1" + hash: "0872c6351cf96b32a10cde4c40b50082" + test_method: "testScenario1Scenario" - name: "Scenario 2" - hash: "5084b8f7e6886a111ed0bbc7e953f6fa" - test_method: "testScenario2" + hash: "8023fb9d6a1d88eadee8e007b8f8e967" + test_method: "testScenario2Scenario" - name: "Scenario 3" - hash: "17288edb6b3917af4162d920d37e8b8a" - test_method: "testScenario3" + hash: "995fc3dbd32f20d5657d97ba82c0f467" + test_method: "testScenario3Scenario" - name: "Scenario 4" - hash: "4ec1439f905b1bfa508cf050d8601517" - test_method: "testScenario4" + hash: "3616c7a4f65b5c618759cbf467ef932c" + test_method: "testScenario4Scenario" - name: "Scenario 5" - hash: "18b76868f1cce6a18fb13439ea85b987" - test_method: "testScenario5" + hash: "8dbde53f132f931117b624fb9df580b0" + test_method: "testScenario5Scenario" - path: "test/sample/sample.feature" - last_modified: "2025-05-11T21:52:29.000" + last_modified: "2025-05-11T21:52:29.585" test_file: "test/sample/sample.bdd_test.dart" scenarios: - name: "Sample" - hash: "56586753878cd78f2da7966b21643465" - test_method: "testSample" + hash: "55f8a2694d752e1c1cb32c7b9b9a56b2" + test_method: "testSampleScenario" - name: "Counter" - hash: "c5ecf1c5f3ca86df90f11ed5108056d6" - test_method: "testCounter" + hash: "2a009734b4ded7bb14973e86aea9b4eb" + test_method: "testCounterCustomName" - name: "Counter with examples" - hash: "cce46f3313000a10062f248e0bf771e3" - test_method: "testCounterwithexamples" + hash: "9cba8be6501d2cfc9bfb3586c4714cc9" + test_method: "testCounterWithExamplesScenario" - name: "Counter with parameters" - hash: "3bb4729b114ac6188f9f3993f4049793" - test_method: "testCounterwithparameters" + hash: "bcdc61902c339891d57cf97da815b9c5" + test_method: "testCounterWithParametersScenario" - name: "Counter with widget test" - hash: "96cc5ec24c542bae66e92c1fe5d5815f" - test_method: "testCounterwithwidgettest" + hash: "c6e6af68b963a51031605f70a444b308" + test_method: "testCounterWithWidgetTestScenario" - path: "test/counter/counter.feature" - last_modified: "2025-05-04T10:27:15.000" + last_modified: "2025-05-04T10:27:15.962" test_file: "test/counter/counter.bdd_test.dart" scenarios: - name: "Increment" - hash: "d2c48c31dce2f0456a25420d893856a0" - test_method: "testIncrement" + hash: "4ac2c8fb0c941d6c8cde8372ba6f6b5f" + test_method: "testIncrementScenario" diff --git a/lib/src/infrastructure/parsers/config_parser.dart b/lib/src/infrastructure/parsers/config_parser.dart index 2a1ad02..3d66102 100644 --- a/lib/src/infrastructure/parsers/config_parser.dart +++ b/lib/src/infrastructure/parsers/config_parser.dart @@ -5,8 +5,12 @@ import 'package:yaml/yaml.dart'; import '../../domain/config.dart'; class ConfigParser { - static const String configDir = '.bdd_flutter'; - static const String configFile = '$configDir/config.yaml'; + static const String defaultConfigDir = '.bdd_flutter'; + static const String defaultConfigFile = '$defaultConfigDir/config.yaml'; + + final String configFile; + + ConfigParser({String? configFile}) : configFile = configFile ?? defaultConfigFile; Future loadConfig() async { final file = File(configFile); diff --git a/lib/src/infrastructure/parsers/manifest_parser.dart b/lib/src/infrastructure/parsers/manifest_parser.dart index 6b0b54c..da1ffe9 100644 --- a/lib/src/infrastructure/parsers/manifest_parser.dart +++ b/lib/src/infrastructure/parsers/manifest_parser.dart @@ -5,8 +5,15 @@ import 'package:yaml/yaml.dart'; import '../../domain/manifest.dart'; class ManifestParser { - static const String manifestDir = '.bdd_flutter'; - static const String manifestFile = '$manifestDir/manifest.yaml'; + static const String defaultManifestDir = '.bdd_flutter'; + static const String defaultManifestFile = '$defaultManifestDir/manifest.yaml'; + + final String manifestDir; + final String manifestFile; + + ManifestParser({String? manifestDir, String? manifestFile}) + : manifestDir = manifestDir ?? defaultManifestDir, + manifestFile = manifestFile ?? defaultManifestFile; Future loadManifest() async { final file = File(manifestFile); @@ -84,7 +91,6 @@ class ManifestParser { await File(manifestFile).writeAsString(buffer.toString()); } - /// Find a feature entry in the manifest by path ManifestFeature? findFeature(Manifest manifest, String path) { for (final feature in manifest.features) { if (feature.path == path) return feature; diff --git a/test/parsers/config_parser_test.dart b/test/parsers/config_parser_test.dart new file mode 100644 index 0000000..4f2648b --- /dev/null +++ b/test/parsers/config_parser_test.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import 'package:bdd_flutter/src/infrastructure/parsers/config_parser.dart'; +import 'package:test/test.dart'; + +void main() { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('bdd_config_test_'); + }); + + tearDown(() { + tempDir.deleteSync(recursive: true); + }); + + ConfigParser parserInDir() { + return ConfigParser(configFile: '${tempDir.path}/.bdd_flutter/config.yaml'); + } + + group('ConfigParser', () { + test('returns defaults when config file does not exist', () async { + final config = await parserInDir().loadConfig(); + + expect(config.generateWidgetTests, isTrue); + expect(config.enableReporter, isFalse); + expect(config.ignoreFeatures, isEmpty); + }); + + test('parses config file with all options', () async { + Directory('${tempDir.path}/.bdd_flutter').createSync(); + File('${tempDir.path}/.bdd_flutter/config.yaml').writeAsStringSync(''' +generate_widget_tests: false +enable_reporter: true +ignore_features: + - test/features/login.feature + - test/features/signup.feature +'''); + + final config = await parserInDir().loadConfig(); + + expect(config.generateWidgetTests, isFalse); + expect(config.enableReporter, isTrue); + expect(config.ignoreFeatures, hasLength(2)); + expect(config.ignoreFeatures, contains('test/features/login.feature')); + expect(config.ignoreFeatures, contains('test/features/signup.feature')); + }); + + test('returns defaults for empty config file', () async { + Directory('${tempDir.path}/.bdd_flutter').createSync(); + File('${tempDir.path}/.bdd_flutter/config.yaml').writeAsStringSync(''); + + final config = await parserInDir().loadConfig(); + + expect(config.generateWidgetTests, isTrue); + expect(config.enableReporter, isFalse); + }); + + test('handles partial config', () async { + Directory('${tempDir.path}/.bdd_flutter').createSync(); + File('${tempDir.path}/.bdd_flutter/config.yaml').writeAsStringSync(''' +enable_reporter: true +'''); + + final config = await parserInDir().loadConfig(); + + expect(config.generateWidgetTests, isTrue); + expect(config.enableReporter, isTrue); + expect(config.ignoreFeatures, isEmpty); + }); + }); +} diff --git a/test/parsers/manifest_parser_test.dart b/test/parsers/manifest_parser_test.dart new file mode 100644 index 0000000..e145a0d --- /dev/null +++ b/test/parsers/manifest_parser_test.dart @@ -0,0 +1,122 @@ +import 'dart:io'; + +import 'package:bdd_flutter/src/domain/manifest.dart'; +import 'package:bdd_flutter/src/infrastructure/parsers/manifest_parser.dart'; +import 'package:test/test.dart'; + +void main() { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('bdd_manifest_test_'); + }); + + tearDown(() { + tempDir.deleteSync(recursive: true); + }); + + ManifestParser parserInDir() { + return ManifestParser( + manifestDir: '${tempDir.path}/.bdd_flutter', + manifestFile: '${tempDir.path}/.bdd_flutter/manifest.yaml', + ); + } + + group('ManifestParser', () { + test('returns empty manifest when file does not exist', () async { + final manifest = await parserInDir().loadManifest(); + + expect(manifest.version, equals('1.0')); + expect(manifest.features, isEmpty); + }); + + test('saves and loads manifest round-trip', () async { + final parser = parserInDir(); + final manifest = Manifest( + features: [ + ManifestFeature( + path: 'test/counter/counter.feature', + lastModified: '2025-05-04T10:27:15.000', + testFile: 'test/counter/counter.bdd_test.dart', + scenarios: [ + ManifestScenario( + name: 'Increment', + hash: 'abc123', + testMethod: 'testIncrementScenario', + ), + ], + ), + ], + ); + + await parser.saveManifest(manifest); + + expect(File('${tempDir.path}/.bdd_flutter/manifest.yaml').existsSync(), isTrue); + + final loaded = await parser.loadManifest(); + + expect(loaded.version, equals('1.0')); + expect(loaded.features, hasLength(1)); + expect(loaded.features.first.path, equals('test/counter/counter.feature')); + expect(loaded.features.first.lastModified, equals('2025-05-04T10:27:15.000')); + expect(loaded.features.first.testFile, equals('test/counter/counter.bdd_test.dart')); + expect(loaded.features.first.scenarios, hasLength(1)); + expect(loaded.features.first.scenarios.first.name, equals('Increment')); + expect(loaded.features.first.scenarios.first.hash, equals('abc123')); + }); + + test('findFeature returns matching feature', () { + final parser = parserInDir(); + final manifest = Manifest( + features: [ + ManifestFeature(path: 'test/a.feature', lastModified: '', testFile: ''), + ManifestFeature(path: 'test/b.feature', lastModified: '', testFile: ''), + ], + ); + + final found = parser.findFeature(manifest, 'test/b.feature'); + expect(found, isNotNull); + expect(found!.path, equals('test/b.feature')); + }); + + test('findFeature returns null for missing feature', () { + final parser = parserInDir(); + final manifest = Manifest(features: []); + + expect(parser.findFeature(manifest, 'test/missing.feature'), isNull); + }); + + test('handles multiple features with multiple scenarios', () async { + final parser = parserInDir(); + final manifest = Manifest( + features: [ + ManifestFeature( + path: 'test/a.feature', + lastModified: '2025-01-01T00:00:00.000', + testFile: 'test/a.bdd_test.dart', + scenarios: [ + ManifestScenario(name: 'S1', hash: 'h1', testMethod: 'm1'), + ManifestScenario(name: 'S2', hash: 'h2', testMethod: 'm2'), + ], + ), + ManifestFeature( + path: 'test/b.feature', + lastModified: '2025-01-02T00:00:00.000', + testFile: 'test/b.bdd_test.dart', + scenarios: [ + ManifestScenario(name: 'S3', hash: 'h3', testMethod: 'm3'), + ], + ), + ], + ); + + await parser.saveManifest(manifest); + final loaded = await parser.loadManifest(); + + expect(loaded.features, hasLength(2)); + expect(loaded.features[0].scenarios, hasLength(2)); + expect(loaded.features[1].scenarios, hasLength(1)); + expect(loaded.features[1].scenarios.first.name, equals('S3')); + }); + }); +} From 294ad8dd1f89496cf28a7390e4f51c7ec4c1b114 Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Tue, 31 Mar 2026 22:34:34 -0600 Subject: [PATCH 16/24] feat: add login feature example and improve BDD test generation architecture --- CLAUDE.md | 82 +++-- README.md | 300 +++++++----------- example/.bdd_flutter/manifest.yaml | 20 +- example/lib/src/auth_provider.dart | 40 +++ example/lib/src/auth_repository.dart | 28 ++ example/lib/src/login_screen.dart | 80 +++++ example/pubspec.lock | 24 ++ example/pubspec.yaml | 2 + .../test/counter/counter.bdd_scenarios.dart | 11 + example/test/counter/counter.bdd_test.dart | 11 + example/test/counter/counter.feature | 6 +- example/test/login/login.bdd_scenarios.dart | 145 +++++++++ example/test/login/login.bdd_test.dart | 67 ++++ example/test/login/login.feature | 31 ++ lib/src/domain/build_options.dart | 2 - lib/src/domain/config.dart | 2 - lib/src/domain/decorator.dart | 20 -- lib/src/domain/scenario.dart | 25 +- .../builders/scenario_file_builder.dart | 37 ++- .../builders/test_file_builder.dart | 61 +--- .../infrastructure/parsers/config_parser.dart | 1 - .../parsers/feature_parser.dart | 22 +- lib/src/presentation/cli/bbd_cli.dart | 2 - .../controllers/bdd_controller.dart | 137 ++++---- test/builders/scenario_file_builder_test.dart | 33 -- test/builders/test_file_builder_test.dart | 20 -- test/parsers/config_parser_test.dart | 9 +- test/parsers/feature_parser_test.dart | 60 ---- 28 files changed, 754 insertions(+), 524 deletions(-) create mode 100644 example/lib/src/auth_provider.dart create mode 100644 example/lib/src/auth_repository.dart create mode 100644 example/lib/src/login_screen.dart create mode 100644 example/test/login/login.bdd_scenarios.dart create mode 100644 example/test/login/login.bdd_test.dart create mode 100644 example/test/login/login.feature diff --git a/CLAUDE.md b/CLAUDE.md index 9911e1b..958b502 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,14 +9,14 @@ bdd_flutter is a Dart CLI tool that generates Flutter/Dart test files from Gherk ## Common Commands ```bash -# Run all tests -flutter test - -# Run a single test file -flutter test test/file_procesors/feature_parser_test.dart +# Run package tests (not example โ€” example uses flutter_test) +dart test test/parsers/ test/builders/ # Run the CLI locally against the example project -dart run bin/bdd_flutter.dart build +cd example && dart run bdd_flutter build + +# Force regenerate all +cd example && dart run bdd_flutter build --force # Analyze code dart analyze @@ -27,51 +27,83 @@ dart pub get ## Architecture -Clean Architecture in `lib/src2/`. The CLI entry point is `bin/bdd_flutter.dart`. +Clean Architecture in `lib/src/`. The CLI entry point is `bin/bdd_flutter.dart`. ### Code Generation Pipeline ``` BDDCLI.run(args) - โ†’ BDDController.generateFeatureTestCases() - โ†’ FeatureParser.parseFeature(filePath) # .feature โ†’ Feature model - โ†’ ScenariosFileBuilder.buildScenarioFile() # Feature โ†’ .bdd_scenarios.dart - โ†’ TestFileBuilder.buildTestFile() # Feature โ†’ .bdd_test.dart + โ†’ BDDController.generateFeatureTestCases(options) + โ†’ ConfigParser.loadConfig() # .bdd_flutter/config.yaml + โ†’ ManifestParser.loadManifest() # .bdd_flutter/manifest.yaml + โ†’ FeatureParser.parseFeature(filePath) # .feature โ†’ Feature model + โ†’ ScenariosFileBuilder.buildScenarioFile(feature) # Feature โ†’ .bdd_scenarios.dart + โ†’ TestFileBuilder.buildTestFile(feature) # Feature โ†’ .bdd_test.dart + โ†’ ManifestParser.saveManifest() # update manifest ``` ### Layers -- **`domain/`** โ€” Core models: Feature, Scenario, Step, Decorator, Background -- **`infrastructure/parsers/`** โ€” FeatureParser (reads `.feature` files into domain models) +- **`domain/`** โ€” Core models: Feature, Scenario, Step, Decorator, Background, BDDConfig, Manifest, BuildOptions +- **`infrastructure/parsers/`** โ€” FeatureParser, ConfigParser, ManifestParser - **`infrastructure/builders/`** โ€” ScenariosFileBuilder, TestFileBuilder (domain models โ†’ Dart code) -- **`presentation/cli/`** โ€” BDDCLI entry point -- **`presentation/controllers/`** โ€” BDDController orchestrates parsing and building +- **`presentation/cli/`** โ€” BDDCLI entry point, argument parsing +- **`presentation/controllers/`** โ€” BDDController orchestrates config, manifest, parsing, building +- **`presentation/reporter/`** โ€” BDDTestReporter (exported for use in generated tests) ### Domain Models - **Feature** โ€” name, path, scenarios, decorators, optional background -- **Scenario** โ€” name, steps, optional examples table, decorators +- **Scenario** โ€” name, steps, optional examples table, decorators, optional customClassName - **Step** โ€” keyword (Given/When/Then/And) + text with `` placeholders -- **Decorator** โ€” enum: `unitTest`, `widgetTest`, `enableReporter`, `ignore` +- **Decorator** โ€” enum: `unitTest`, `widgetTest` - **Background** โ€” shared setup steps applied to all scenarios in a feature +- **BDDConfig** โ€” generate_widget_tests, enable_reporter, ignore_features +- **Manifest** โ€” tracks generated features, scenario hashes for incremental builds +- **BuildOptions** โ€” CLI flags: widgetTest, reporter, force, newOnly + +### Generated Code Pattern -### Generated File Conventions +Scenario classes use **instance methods** (not static), so users can add `late` fields for shared state between steps: + +```dart +// .bdd_scenarios.dart +class IncrementScenario { + Future iHaveACounter(WidgetTester tester) async { ... } + Future iIncrementIt(WidgetTester tester) async { ... } +} + +// .bdd_test.dart +final scenario = IncrementScenario(); +await scenario.iHaveACounter(tester); +``` -- `.bdd_scenarios.dart` โ€” Static classes with step method stubs -- `.bdd_test.dart` โ€” Executable test file using `test()` or `testWidgets()` -- `.feature` โ€” Gherkin source files -- Generated files are placed alongside the `.feature` file +### Generation Modes + +- **Incremental (default)** โ€” compares file timestamps + scenario hashes against manifest, skips unchanged +- **Force (`--force`)** โ€” regenerates everything +- **New-only (`--new-only`)** โ€” only generates for features not in manifest + +### Decorators + +- `@unitTest` / `@widgetTest` โ€” on feature or scenario (scenario overrides feature) +- Feature files only contain behavior-relevant tags; tooling config lives in `.bdd_flutter/config.yaml` ### CLI Flags -- `--widget-test` โ€” Generate widget tests (uses `testWidgets` + `WidgetTester`) -- `--reporter` โ€” Enable BDD test reporter +- `--no-widget-test` โ€” Generate unit tests instead of widget tests - `--force` โ€” Regenerate all files regardless of changes - `--new-only` โ€” Only generate for new feature files ### Config File -User projects store config at `.bdd_flutter/config.yaml` with options: `generate_widget_tests`, `enable_reporter`, `ignore_features`. +`.bdd_flutter/config.yaml`: +- `generate_widget_tests` (bool, default true) +- `ignore_features` (list of paths to skip) + +### Manifest File + +`.bdd_flutter/manifest.yaml` โ€” auto-generated, tracks per-feature paths, timestamps, and scenario hashes for incremental builds. ## Coding Conventions diff --git a/README.md b/README.md index b89bdf9..35b6ea0 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,34 @@ -# ๐Ÿš€ BDD Flutter +# BDD Flutter [![pub package](https://img.shields.io/pub/v/bdd_flutter.svg)](https://pub.dev/packages/bdd_flutter) -[![style: very good analysis](https://img.shields.io/badge/style-very_good_analysis-B22C89.svg)](https://pub.dev/packages/very_good_analysis) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Buy Me A Coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/samderlust) -A powerful Flutter package that simplifies Behavior Driven Development (BDD) by automatically generating test files from Gherkin feature files. Write expressive tests in plain English using Given/When/Then scenarios and let BDD Flutter handle the boilerplate code generation. +A Flutter package that simplifies Behavior Driven Development (BDD) by automatically generating test files from Gherkin feature files. Write expressive tests in plain English using Given/When/Then scenarios and let BDD Flutter handle the boilerplate code generation. -> **Note**: This package is currently in active development. While it's stable for production use, new features and improvements are being added regularly. Feel free to submit issues or feature requests on GitHub. +## Features -## ๐Ÿšจ Breaking Changes in v1.0.0 +- Parse `.feature` files written in Gherkin syntax (Given/When/Then/And) +- Generate boilerplate test files automatically +- Support for both widget tests and unit tests +- Incremental generation โ€” new scenarios are appended without overwriting existing implementations +- Instance-based scenario classes for shared state between steps +- Background support for shared setup steps +- Examples tables for parameterized scenarios +- Configurable via `.bdd_flutter/config.yaml` +- Manifest tracking in `.bdd_flutter/manifest.yaml` -- The package no longer uses `build_runner`. Instead, it now uses a simpler CLI approach: - 1. Remove `build_runner` from your dev_dependencies if you added it previously - 2. Use `dart run bdd_flutter build` to generate test files - 3. Generated files will use the `.bdd.dart` extension for better clarity +## Installation -To migrate: - -1. Remove build_runner if present in your dev_dependencies -2. If you want to keep current test files, consider add `.feature` file paths into `.bdd_flutter/config.yaml` under `ignore_features` section (see [Configuration](#-configuration) for more details) - -## โœจ Features - -- ๐Ÿ“ Parse `.feature` files written in Gherkin syntax -- โšก Generate boilerplate test files automatically -- ๐Ÿงช Support for both widget tests and unit tests -- ๐Ÿ“„ Incremental generation to preserve user-written code -- โš™๏ธ Configurable test generation -- ๐Ÿ“„ Ignore specific generated files using `.bdd_flutter/config.yaml` -- ๐Ÿ“„ Configuration in `.bdd_flutter/config.yaml` -- ๐Ÿ“„ Manifest tracking in `.bdd_flutter/manifest.yaml` - -## ๐Ÿ“ฆ Installation - -Add the following dependencies to your package's `pubspec.yaml` file: +Add to your `pubspec.yaml`: ```yaml dev_dependencies: bdd_flutter: latest ``` -## ๐Ÿš€ Quick Start +## Quick Start -1. Create a `.feature` file in your project test folder: +1. Create a `.feature` file in your test folder: ```gherkin Feature: Counter @@ -58,239 +43,182 @@ Feature: Counter | 3 | 3 | ``` -2. Run the generator to create test files: +2. Generate test files: ```bash dart run bdd_flutter build ``` -3. Run your tests: - -```bash -flutter test -``` - -## ๐Ÿ’ก Recommendations - -When working with generated test files, follow these best practices: - -1. **Generated Files**: - - - Generated files will have the `.bdd.*.dart` extension (e.g., `counter_test.bdd.test.dart` or `counter_scenarios.bdd.scenarios.dart`) - - After implementing your tests, it's recommended to: - - Remove the `.bdd.*` extension from the file name - - or you can run `dart run bdd_flutter rename` to rename the files - - Add an ignore decorator to your feature file (@ignore) - - This will prevent the generated files from being overwritten by subsequent builds - -2. **Feature File Ignore**: - Add this comment at the top of your feature file: - ```gherkin - @ignore - Feature: Counter - ``` - -This approach ensures that: - -- Your implemented tests won't be overwritten by subsequent builds -- Generated files are properly ignored in version control -- You maintain a clean project structure - -## ๐Ÿš€ Configuration - -You can configure the generator in `bdd_config.yaml`: - -```yaml -generate_widget_tests: true -enable_reporter: false -ignore_features: - - test/features/login.feature - - test/features/registration.feature -``` +3. Implement the generated step methods in `counter.bdd_scenarios.dart` -Or use command-line arguments: +4. Run your tests: ```bash -dart run bdd_flutter build --no-widget-tests --enable-reporter --ignore login.feature +flutter test ``` -### Configuration Options - -| Option | Type | Default | Description | -| ----------------------- | ---- | ------- | ------------------------------------------------------ | -| `generate_widget_tests` | bool | true | Generate widget tests when true, unit tests when false | -| `enable_reporter` | bool | false | Enable/disable test reporter | -| `ignore_features` | List | [] | List of feature file paths to ignore during generation | - -## ๐Ÿท๏ธ Decorators - -Control test generation with decorators: - -| Decorator | Scope | Description | -| -------------------------- | ----------------- | --------------------------------------- | -| `@unitTest` | Feature, Scenario | Generate unit test (overrides config) | -| `@widgetTest` | Feature, Scenario | Generate widget test (overrides config) | -| `@className("CustomName")` | Scenario | Generate custom class name | -| `@enableReporter` | Feature | Enable test reporter | -| `@disableReporter` | Feature | Disable test reporter | +## Generated Files -> ๐Ÿ’ก Decorators follow a hierarchy: Scenario-level decorators override Feature-level ones. +The generator creates two files per `.feature` file: -## ๐Ÿ“ Complete Example +- **`.bdd_scenarios.dart`** โ€” Scenario classes with step method stubs (your implementation goes here) +- **`.bdd_test.dart`** โ€” Test orchestration file (auto-generated, do not edit) -### 1. Feature File (`counter.feature`) +### How It Works -```gherkin -Feature: Counter - Scenario: Increment - Given I have a counter with value 0 - When I increment the counter by - Then the counter should have value - Examples: - | value | expected_value | - | 1 | 1 | - | 2 | 2 | - | 3 | 3 | -``` +- Scenario classes use **instance methods**, so you can add `late` fields for shared state (mocks, widgets, etc.) +- Each test instantiates a **fresh scenario** for proper test isolation +- When you add a new scenario to a `.feature` file, only the new scenario class is **appended** โ€” existing implementations are preserved +- The test file is **always regenerated** (it contains no user code) -### 2. Generated Files +### Example Output -#### `counter_scenarios.dart` +#### `counter.bdd_scenarios.dart` ```dart import 'package:flutter_test/flutter_test.dart'; class IncrementScenario { - static Future iHaveACounterWithValue0(WidgetTester tester) async { + Future iHaveACounterWithValue0(WidgetTester tester) async { // TODO: Implement Given I have a counter with value 0 } - static Future iIncrementTheCounterBy(WidgetTester tester, dynamic value) async { + Future iIncrementTheCounterByValue(WidgetTester tester, String value) async { // TODO: Implement When I increment the counter by } - static Future theCounterShouldHaveValue(WidgetTester tester, dynamic expected_value) async { + Future theCounterShouldHaveValueExpectedValue(WidgetTester tester, String expectedValue) async { // TODO: Implement Then the counter should have value } } ``` -#### `counter_test.dart` +#### `counter.bdd_test.dart` ```dart import 'package:flutter_test/flutter_test.dart'; -import 'counter_scenarios.dart'; +import 'counter.bdd_scenarios.dart'; void main() { group('Counter', () { testWidgets('Increment', (tester) async { - await IncrementScenario.iHaveACounterWithValue0(tester); - // Example with values: 1, 1 - await IncrementScenario.iIncrementTheCounterBy(tester, '1'); - await IncrementScenario.theCounterShouldHaveValue(tester, '1'); - // Example with values: 2, 2 - await IncrementScenario.iIncrementTheCounterBy(tester, '2'); - await IncrementScenario.theCounterShouldHaveValue(tester, '2'); - // Example with values: 3, 3 - await IncrementScenario.iIncrementTheCounterBy(tester, '3'); - await IncrementScenario.theCounterShouldHaveValue(tester, '3'); + final scenario = IncrementScenario(); + final examples = [ + {'value': '1', 'expectedValue': '1'}, + {'value': '2', 'expectedValue': '2'}, + {'value': '3', 'expectedValue': '3'}, + ]; + for (var example in examples) { + await scenario.iIncrementTheCounterByValue(tester, example['value']!); + await scenario.theCounterShouldHaveValueExpectedValue(tester, example['expectedValue']!); + } }); }); } ``` -## ๐Ÿค Contributing +## Configuration -We welcome contributions! Please feel free to: +Configure the generator in `.bdd_flutter/config.yaml`: -- Open an issue -- Submit a pull request -- Share your feedback +```yaml +generate_widget_tests: true +ignore_features: + - test/features/login.feature + - test/features/registration.feature +``` -## ๐Ÿ“„ License +Or use CLI flags: -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +```bash +dart run bdd_flutter build --no-widget-test --force +``` -## Additional Information +### Config Options -For more information, visit the [documentation](https://example.com/bdd_flutter). +| Option | Type | Default | Description | +| ----------------------- | ---- | ------- | ------------------------------------------------------ | +| `generate_widget_tests` | bool | true | Generate widget tests when true, unit tests when false | +| `ignore_features` | List | [] | List of feature file paths to skip during generation | -## Project Structure +### CLI Flags -``` -your_project/ -โ”œโ”€โ”€ .bdd_flutter/ -โ”‚ โ”œโ”€โ”€ config.yaml # Configuration settings -โ”‚ โ””โ”€โ”€ manifest.yaml # Generation state tracking -โ”œโ”€โ”€ test/ -โ”‚ โ”œโ”€โ”€ features/ -โ”‚ โ”‚ โ””โ”€โ”€ login.feature -โ”‚ โ””โ”€โ”€ features_test/ -โ”‚ โ”œโ”€โ”€ login_test.dart -โ”‚ โ””โ”€โ”€ login_scenarios.dart -โ””โ”€โ”€ pubspec.yaml -``` +| Flag | Description | +| ------------------ | ---------------------------------------- | +| `--no-widget-test` | Generate unit tests instead of widget tests | +| `--force` | Force regenerate all files | +| `--new-only` | Only generate for new feature files | -## Generation Modes +## Decorators -The package supports three generation modes: +Control test type with standard Gherkin tags: + +| Decorator | Scope | Description | +| ------------- | ----------------- | --------------------------------------- | +| `@unitTest` | Feature, Scenario | Generate unit test (overrides config) | +| `@widgetTest` | Feature, Scenario | Generate widget test (overrides config) | + +Scenario-level decorators override Feature-level ones. + +To skip generation for specific features, use `ignore_features` in config. + +## Generation Modes -### 1. Incremental Update (Default) +### Incremental (Default) ```bash dart run bdd_flutter build ``` -- Processes new and modified scenarios -- Preserves user-written code -- Tracks changes in `.bdd_flutter/manifest.yaml` +- Skips unchanged features (tracked via manifest) +- New scenarios are **appended** to existing scenario files โ€” implementations are preserved +- Test files are regenerated to include all scenarios -### 2. Force Regenerate +### Force Regenerate ```bash dart run bdd_flutter build --force ``` -- Regenerates all test files -- Overwrites existing files -- Use with caution +- Regenerates all files from scratch +- **Overwrites** existing scenario implementations โ€” use with caution -### 3. New Files Only +### New Files Only ```bash dart run bdd_flutter build --new-only ``` -- Only processes new feature files -- Skips existing files -- Useful for initial setup +- Only generates for feature files not yet in the manifest +- Skips all existing features entirely + +## Project Structure + +``` +your_project/ +โ”œโ”€โ”€ .bdd_flutter/ +โ”‚ โ”œโ”€โ”€ config.yaml # Configuration (commit to version control) +โ”‚ โ””โ”€โ”€ manifest.yaml # Generation tracking (commit to version control) +โ”œโ”€โ”€ test/ +โ”‚ โ””โ”€โ”€ login/ +โ”‚ โ”œโ”€โ”€ login.feature +โ”‚ โ”œโ”€โ”€ login.bdd_scenarios.dart # Your implementations +โ”‚ โ””โ”€โ”€ login.bdd_test.dart # Auto-generated orchestration +โ””โ”€โ”€ pubspec.yaml +``` ## Best Practices -1. **Version Control** +1. **Version Control** โ€” Keep both `config.yaml` and `manifest.yaml` in version control. The manifest prevents incremental builds from overwriting implemented code on fresh clones. - - Add `.bdd_flutter/manifest.yaml` to `.gitignore` - - Keep `.bdd_flutter/config.yaml` in version control +2. **Scenario Files** โ€” Implement your test logic in `.bdd_scenarios.dart`. Use `late` fields for shared state between steps (mocks, providers, widgets). -2. **Feature Files** +3. **Test Files** โ€” Do not edit `.bdd_test.dart` files. They are regenerated automatically and contain no user code. - - Keep feature files in `test/features/` - - Use descriptive names for scenarios - - Follow Gherkin syntax guidelines +4. **Adding Scenarios** โ€” When you add new scenarios to a `.feature` file, run `build` โ€” new scenario classes are appended without touching existing ones. -3. **Test Files** +5. **Feature Files** โ€” Keep feature files clean. Only use `@unitTest`/`@widgetTest` decorators. All other configuration belongs in `.bdd_flutter/config.yaml`. - - Don't modify generated test files directly - - Add your implementation in the provided methods - - Use the incremental update mode to preserve changes +## License -4. **Incremental Generation Limitations** - - The default incremental generation mode has some limitations when working with existing `.feature` files: - - Adding new features at the end of the file works fine - - Modifying existing scenarios may cause issues with scenario and test file generation - - Adding new scenarios in the middle of the file can cause generation problems - - When making significant changes to existing feature files, consider using the force regenerate mode: - ```bash - dart run bdd_flutter build --force - ``` - - Always backup your implemented test code before force regenerating +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/example/.bdd_flutter/manifest.yaml b/example/.bdd_flutter/manifest.yaml index 71c705e..df52e1a 100644 --- a/example/.bdd_flutter/manifest.yaml +++ b/example/.bdd_flutter/manifest.yaml @@ -1,5 +1,5 @@ version: "1.0" -last_generated: "2026-03-31T21:12:49.191295" +last_generated: "2026-03-31T21:51:26.620372" features: - path: "test/calculator/calculator.feature" last_modified: "2025-08-16T13:55:30.921" @@ -79,9 +79,25 @@ features: hash: "c6e6af68b963a51031605f70a444b308" test_method: "testCounterWithWidgetTestScenario" - path: "test/counter/counter.feature" - last_modified: "2025-05-04T10:27:15.962" + last_modified: "2026-03-31T21:48:45.677" test_file: "test/counter/counter.bdd_test.dart" scenarios: - name: "Increment" hash: "4ac2c8fb0c941d6c8cde8372ba6f6b5f" test_method: "testIncrementScenario" + - name: "Decrement" + hash: "1cd52123967c7db75be605bba60fac33" + test_method: "testDecrementScenario" + - path: "test/login/login.feature" + last_modified: "2026-03-31T21:51:19.959" + test_file: "test/login/login.bdd_test.dart" + scenarios: + - name: "Successful login" + hash: "5fea2322ced5756d9f1c9707548202f6" + test_method: "testSuccessfulLoginScenario" + - name: "Failed login shows error" + hash: "02d5530d9adf441cc17a732d94fd5158" + test_method: "testFailedLoginShowsErrorScenario" + - name: "Logout after login" + hash: "ab40a81506e5b317c5a888164c886324" + test_method: "testLogoutAfterLoginScenario" diff --git a/example/lib/src/auth_provider.dart b/example/lib/src/auth_provider.dart new file mode 100644 index 0000000..d38efb4 --- /dev/null +++ b/example/lib/src/auth_provider.dart @@ -0,0 +1,40 @@ +import 'package:flutter/foundation.dart'; + +import 'auth_repository.dart'; + +class AuthProvider extends ChangeNotifier { + final AuthRepository _repository; + + AuthProvider(this._repository); + + User? _user; + String? _error; + bool _isLoading = false; + + User? get user => _user; + String? get error => _error; + bool get isLoading => _isLoading; + bool get isLoggedIn => _user != null; + + Future login(String email, String password) async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + _user = await _repository.login(email, password); + } catch (e) { + _error = e.toString(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future logout() async { + await _repository.logout(); + _user = null; + _error = null; + notifyListeners(); + } +} diff --git a/example/lib/src/auth_repository.dart b/example/lib/src/auth_repository.dart new file mode 100644 index 0000000..6ae6886 --- /dev/null +++ b/example/lib/src/auth_repository.dart @@ -0,0 +1,28 @@ +class User { + final String name; + final String email; + + User({required this.name, required this.email}); +} + +abstract class AuthRepository { + Future login(String email, String password); + Future logout(); +} + +class AuthRepositoryImpl implements AuthRepository { + @override + Future login(String email, String password) async { + // Simulate API call + await Future.delayed(const Duration(seconds: 1)); + if (email == 'test@test.com' && password == 'password') { + return User(name: 'Test User', email: email); + } + throw Exception('Invalid credentials'); + } + + @override + Future logout() async { + await Future.delayed(const Duration(milliseconds: 500)); + } +} diff --git a/example/lib/src/login_screen.dart b/example/lib/src/login_screen.dart new file mode 100644 index 0000000..9c6fd0f --- /dev/null +++ b/example/lib/src/login_screen.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'auth_provider.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Login')), + body: Consumer( + builder: (context, auth, _) { + if (auth.isLoggedIn) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Welcome, ${auth.user!.name}!'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: auth.logout, + child: const Text('Logout'), + ), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (auth.error != null) + Text(auth.error!, style: const TextStyle(color: Colors.red)), + TextField( + controller: _emailController, + decoration: const InputDecoration(labelText: 'Email'), + ), + TextField( + controller: _passwordController, + decoration: const InputDecoration(labelText: 'Password'), + obscureText: true, + ), + const SizedBox(height: 16), + if (auth.isLoading) + const CircularProgressIndicator() + else + ElevatedButton( + onPressed: () => auth.login( + _emailController.text, + _passwordController.text, + ), + child: const Text('Login'), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index cc6e774..03863b0 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -250,6 +250,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" node_preamble: dependency: transitive description: @@ -282,6 +298,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" pub_semver: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index b9a29c0..c25df6b 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: flutter: sdk: flutter + provider: ^6.0.0 dev_dependencies: flutter_test: @@ -17,6 +18,7 @@ dev_dependencies: bdd_flutter: path: ../ test: ^1.20.0 + mocktail: ^1.0.0 flutter: uses-material-design: true diff --git a/example/test/counter/counter.bdd_scenarios.dart b/example/test/counter/counter.bdd_scenarios.dart index eb3d4eb..d9d6678 100644 --- a/example/test/counter/counter.bdd_scenarios.dart +++ b/example/test/counter/counter.bdd_scenarios.dart @@ -18,3 +18,14 @@ class IncrementScenario { } +class DecrementScenario { + Future iDecrementTheCounterBy1(WidgetTester tester) async { + // TODO: Implement When I decrement the counter by 1 + } + + Future theCounterShouldHaveValue1(WidgetTester tester) async { + // TODO: Implement Then the counter should have value -1 + } + +} + diff --git a/example/test/counter/counter.bdd_test.dart b/example/test/counter/counter.bdd_test.dart index 9885517..760a399 100644 --- a/example/test/counter/counter.bdd_test.dart +++ b/example/test/counter/counter.bdd_test.dart @@ -21,5 +21,16 @@ void main() { await scenario.theCounterShouldHaveValueExpectedValue(tester, example['expectedValue']!); } }); + testWidgets('Decrement', (tester) async { + final scenario = DecrementScenario(); + final background = CounterBackground(); + //Background: I have a counter with value 0 + await background.iHaveACounterWithValue0(); + //Scenario: Decrement + // When I decrement the counter by 1 + await scenario.iDecrementTheCounterBy1(tester); + // Then the counter should have value -1 + await scenario.theCounterShouldHaveValue1(tester); + }); }); } diff --git a/example/test/counter/counter.feature b/example/test/counter/counter.feature index 20bb57d..4d0658c 100644 --- a/example/test/counter/counter.feature +++ b/example/test/counter/counter.feature @@ -9,4 +9,8 @@ Feature: Counter | value | expected_value | | 1 | 1 | | 2 | 2 | - | 3 | 3 | \ No newline at end of file + | 3 | 3 | + + Scenario: Decrement + When I decrement the counter by 1 + Then the counter should have value -1 \ No newline at end of file diff --git a/example/test/login/login.bdd_scenarios.dart b/example/test/login/login.bdd_scenarios.dart new file mode 100644 index 0000000..e3c6013 --- /dev/null +++ b/example/test/login/login.bdd_scenarios.dart @@ -0,0 +1,145 @@ +import 'package:example/src/auth_provider.dart'; +import 'package:example/src/auth_repository.dart'; +import 'package:example/src/login_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:provider/provider.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +class SuccessfulLoginScenario { + late MockAuthRepository mockAuthRepo; + late AuthProvider authProvider; + + Future iHaveAMockAuthRepository(WidgetTester tester) async { + mockAuthRepo = MockAuthRepository(); + authProvider = AuthProvider(mockAuthRepo); + } + + Future theMockReturnsASuccessfulLoginForTestTestCom(WidgetTester tester) async { + when(() => mockAuthRepo.login('test@test.com', 'password')) + .thenAnswer((_) async => User(name: 'Test User', email: 'test@test.com')); + } + + Future iPumpTheLoginScreenWithProviders(WidgetTester tester) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: authProvider, + child: const MaterialApp(home: LoginScreen()), + ), + ); + } + + Future iEnterTestTestComInTheEmailField(WidgetTester tester) async { + await tester.enterText(find.byType(TextField).first, 'test@test.com'); + } + + Future iEnterPasswordInThePasswordField(WidgetTester tester) async { + await tester.enterText(find.byType(TextField).last, 'password'); + } + + Future iTapTheLoginButton(WidgetTester tester) async { + await tester.tap(find.widgetWithText(ElevatedButton, 'Login')); + await tester.pumpAndSettle(); + } + + Future iShouldSeeWelcomeTestUser(WidgetTester tester) async { + expect(find.text('Welcome, Test User!'), findsOneWidget); + } +} + +class FailedLoginShowsErrorScenario { + late MockAuthRepository mockAuthRepo; + late AuthProvider authProvider; + + Future iHaveAMockAuthRepository(WidgetTester tester) async { + mockAuthRepo = MockAuthRepository(); + authProvider = AuthProvider(mockAuthRepo); + } + + Future theMockThrowsAnErrorForLogin(WidgetTester tester) async { + when(() => mockAuthRepo.login(any(), any())) + .thenThrow(Exception('Invalid credentials')); + } + + Future iPumpTheLoginScreenWithProviders(WidgetTester tester) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: authProvider, + child: const MaterialApp(home: LoginScreen()), + ), + ); + } + + Future iEnterWrongTestComInTheEmailField(WidgetTester tester) async { + await tester.enterText(find.byType(TextField).first, 'wrong@test.com'); + } + + Future iEnterWrongInThePasswordField(WidgetTester tester) async { + await tester.enterText(find.byType(TextField).last, 'wrong'); + } + + Future iTapTheLoginButton(WidgetTester tester) async { + await tester.tap(find.widgetWithText(ElevatedButton, 'Login')); + await tester.pumpAndSettle(); + } + + Future iShouldSeeInvalidCredentials(WidgetTester tester) async { + expect(find.textContaining('Invalid credentials'), findsOneWidget); + } +} + +class LogoutAfterLoginScenario { + late MockAuthRepository mockAuthRepo; + late AuthProvider authProvider; + + Future iHaveAMockAuthRepository(WidgetTester tester) async { + mockAuthRepo = MockAuthRepository(); + authProvider = AuthProvider(mockAuthRepo); + } + + Future theMockReturnsASuccessfulLoginForTestTestCom(WidgetTester tester) async { + when(() => mockAuthRepo.login('test@test.com', 'password')) + .thenAnswer((_) async => User(name: 'Test User', email: 'test@test.com')); + } + + Future theMockAllowsLogout(WidgetTester tester) async { + when(() => mockAuthRepo.logout()).thenAnswer((_) async {}); + } + + Future iPumpTheLoginScreenWithProviders(WidgetTester tester) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: authProvider, + child: const MaterialApp(home: LoginScreen()), + ), + ); + } + + Future iEnterTestTestComInTheEmailField(WidgetTester tester) async { + await tester.enterText(find.byType(TextField).first, 'test@test.com'); + } + + Future iEnterPasswordInThePasswordField(WidgetTester tester) async { + await tester.enterText(find.byType(TextField).last, 'password'); + } + + Future iTapTheLoginButton(WidgetTester tester) async { + await tester.tap(find.widgetWithText(ElevatedButton, 'Login')); + await tester.pumpAndSettle(); + } + + Future iShouldSeeWelcomeTestUser(WidgetTester tester) async { + expect(find.text('Welcome, Test User!'), findsOneWidget); + } + + Future iTapTheLogoutButton(WidgetTester tester) async { + await tester.tap(find.text('Logout')); + await tester.pumpAndSettle(); + } + + Future iShouldSeeLogin(WidgetTester tester) async { + expect(find.widgetWithText(ElevatedButton, 'Login'), findsOneWidget); + } +} diff --git a/example/test/login/login.bdd_test.dart b/example/test/login/login.bdd_test.dart new file mode 100644 index 0000000..f037f7e --- /dev/null +++ b/example/test/login/login.bdd_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'login.bdd_scenarios.dart'; + +void main() { + group('Login', () { + testWidgets('Successful login', (tester) async { + final scenario = SuccessfulLoginScenario(); + //Scenario: Successful login + // Given I have a mock auth repository + await scenario.iHaveAMockAuthRepository(tester); + // And the mock returns a successful login for "test@test.com" + await scenario.theMockReturnsASuccessfulLoginForTestTestCom(tester); + // When I pump the login screen with providers + await scenario.iPumpTheLoginScreenWithProviders(tester); + // And I enter "test@test.com" in the email field + await scenario.iEnterTestTestComInTheEmailField(tester); + // And I enter "password" in the password field + await scenario.iEnterPasswordInThePasswordField(tester); + // And I tap the login button + await scenario.iTapTheLoginButton(tester); + // Then I should see "Welcome, Test User!" + await scenario.iShouldSeeWelcomeTestUser(tester); + }); + testWidgets('Failed login shows error', (tester) async { + final scenario = FailedLoginShowsErrorScenario(); + //Scenario: Failed login shows error + // Given I have a mock auth repository + await scenario.iHaveAMockAuthRepository(tester); + // And the mock throws an error for login + await scenario.theMockThrowsAnErrorForLogin(tester); + // When I pump the login screen with providers + await scenario.iPumpTheLoginScreenWithProviders(tester); + // And I enter "wrong@test.com" in the email field + await scenario.iEnterWrongTestComInTheEmailField(tester); + // And I enter "wrong" in the password field + await scenario.iEnterWrongInThePasswordField(tester); + // And I tap the login button + await scenario.iTapTheLoginButton(tester); + // Then I should see "Invalid credentials" + await scenario.iShouldSeeInvalidCredentials(tester); + }); + testWidgets('Logout after login', (tester) async { + final scenario = LogoutAfterLoginScenario(); + //Scenario: Logout after login + // Given I have a mock auth repository + await scenario.iHaveAMockAuthRepository(tester); + // And the mock returns a successful login for "test@test.com" + await scenario.theMockReturnsASuccessfulLoginForTestTestCom(tester); + // And the mock allows logout + await scenario.theMockAllowsLogout(tester); + // When I pump the login screen with providers + await scenario.iPumpTheLoginScreenWithProviders(tester); + // And I enter "test@test.com" in the email field + await scenario.iEnterTestTestComInTheEmailField(tester); + // And I enter "password" in the password field + await scenario.iEnterPasswordInThePasswordField(tester); + // And I tap the login button + await scenario.iTapTheLoginButton(tester); + // Then I should see "Welcome, Test User!" + await scenario.iShouldSeeWelcomeTestUser(tester); + // When I tap the logout button + await scenario.iTapTheLogoutButton(tester); + // Then I should see "Login" + await scenario.iShouldSeeLogin(tester); + }); + }); +} diff --git a/example/test/login/login.feature b/example/test/login/login.feature new file mode 100644 index 0000000..4418c40 --- /dev/null +++ b/example/test/login/login.feature @@ -0,0 +1,31 @@ +Feature: Login + + Scenario: Successful login + Given I have a mock auth repository + And the mock returns a successful login for "test@test.com" + When I pump the login screen with providers + And I enter "test@test.com" in the email field + And I enter "password" in the password field + And I tap the login button + Then I should see "Welcome, Test User!" + + Scenario: Failed login shows error + Given I have a mock auth repository + And the mock throws an error for login + When I pump the login screen with providers + And I enter "wrong@test.com" in the email field + And I enter "wrong" in the password field + And I tap the login button + Then I should see "Invalid credentials" + + Scenario: Logout after login + Given I have a mock auth repository + And the mock returns a successful login for "test@test.com" + And the mock allows logout + When I pump the login screen with providers + And I enter "test@test.com" in the email field + And I enter "password" in the password field + And I tap the login button + Then I should see "Welcome, Test User!" + When I tap the logout button + Then I should see "Login" diff --git a/lib/src/domain/build_options.dart b/lib/src/domain/build_options.dart index af9f673..1b1f4a5 100644 --- a/lib/src/domain/build_options.dart +++ b/lib/src/domain/build_options.dart @@ -1,12 +1,10 @@ class BuildOptions { final bool widgetTest; - final bool reporter; final bool force; final bool newOnly; const BuildOptions({ this.widgetTest = true, - this.reporter = false, this.force = false, this.newOnly = false, }); diff --git a/lib/src/domain/config.dart b/lib/src/domain/config.dart index 3839106..f72b2e0 100644 --- a/lib/src/domain/config.dart +++ b/lib/src/domain/config.dart @@ -1,11 +1,9 @@ class BDDConfig { final bool generateWidgetTests; - final bool enableReporter; final List ignoreFeatures; const BDDConfig({ this.generateWidgetTests = true, - this.enableReporter = false, this.ignoreFeatures = const [], }); } diff --git a/lib/src/domain/decorator.dart b/lib/src/domain/decorator.dart index b2058c0..1104b7e 100644 --- a/lib/src/domain/decorator.dart +++ b/lib/src/domain/decorator.dart @@ -1,44 +1,24 @@ enum Decorator { unitTest, widgetTest, - enableReporter, - disableReporter, - ignore, unknown; static Decorator fromString(String text) { - // Strip the text to handle just the decorator name final trimmed = text.trim(); return switch (trimmed) { '@unitTest' => Decorator.unitTest, '@widgetTest' => Decorator.widgetTest, - '@enableReporter' => Decorator.enableReporter, - '@disableReporter' => Decorator.disableReporter, - '@ignore' => Decorator.ignore, _ => Decorator.unknown, }; } - - /// Check if text is a @className("...") decorator and extract the name - static String? parseClassName(String text) { - final regex = RegExp(r'^@className\("(.+)"\)$'); - final match = regex.firstMatch(text.trim()); - return match?.group(1); - } } extension DecoratorX on Decorator { bool get isUnitTest => this == Decorator.unitTest; bool get isWidgetTest => this == Decorator.widgetTest; - bool get isEnableReporter => this == Decorator.enableReporter; - bool get isDisableReporter => this == Decorator.disableReporter; - bool get isIgnore => this == Decorator.ignore; } extension DecoratorSetX on Set { bool get hasUnitTest => any((e) => e.isUnitTest); bool get hasWidgetTest => any((e) => e.isWidgetTest); - bool get hasEnableReporter => any((e) => e.isEnableReporter); - bool get hasDisableReporter => any((e) => e.isDisableReporter); - bool get hasIgnore => any((e) => e.isIgnore); } diff --git a/lib/src/domain/scenario.dart b/lib/src/domain/scenario.dart index 69b7553..656f9bf 100644 --- a/lib/src/domain/scenario.dart +++ b/lib/src/domain/scenario.dart @@ -7,36 +7,18 @@ import 'step.dart'; /// A scenario is a collection of steps class Scenario { - /// The name of the scenario String name; - - /// The steps of the scenario List steps; - - /// The examples of the scenario List>? examples; - - /// The decorators of the scenario Set decorators; - /// Custom class name from @className("...") decorator - String? customClassName; - Scenario( this.name, this.steps, { this.examples, this.decorators = const {}, - this.customClassName, }); - factory Scenario.init() => Scenario( - '', - [], - examples: [], - decorators: {}, - ); - @override String toString() { return 'Scenario(name: $name, steps: $steps, examples: $examples, decorators: $decorators)'; @@ -44,7 +26,6 @@ class Scenario { } extension ScenarioX on Scenario { - /// Check if unit test โ€” scenario decorator overrides, then fall back to feature bool get isUnitTest => decorators.hasUnitTest; bool get isWidgetTest => decorators.hasWidgetTest; @@ -52,15 +33,11 @@ extension ScenarioX on Scenario { bool isUnitTestWithFeature(Set featureDecorators) { if (decorators.hasUnitTest) return true; if (decorators.hasWidgetTest) return false; - // Fall back to feature-level if (featureDecorators.hasUnitTest) return true; return false; } - String get className { - if (customClassName != null) return customClassName!; - return name.toScenarioClassName; - } + String get className => name.toScenarioClassName; String get getHash { return md5.convert(utf8.encode(toString())).toString(); diff --git a/lib/src/infrastructure/builders/scenario_file_builder.dart b/lib/src/infrastructure/builders/scenario_file_builder.dart index dd5db71..391f4cf 100644 --- a/lib/src/infrastructure/builders/scenario_file_builder.dart +++ b/lib/src/infrastructure/builders/scenario_file_builder.dart @@ -1,4 +1,3 @@ -import '../../domain/decorator.dart'; import '../../extensions/string_x.dart'; import '../../domain/feature.dart'; import '../../domain/scenario.dart'; @@ -27,8 +26,6 @@ class ScenariosFileBuilder { } for (var scenario in feature.scenarios) { - if (scenario.decorators.hasIgnore) continue; - // Resolve unit test considering feature-level decorators final isUnitTest = scenario.isUnitTestWithFeature(feature.decorators); @@ -58,6 +55,40 @@ class ScenariosFileBuilder { return buffer.toString(); } + + /// Build only the specified scenarios (for appending to existing file) + String buildNewScenarios(Feature feature, List newScenarios) { + final buffer = StringBuffer(); + + for (var scenario in newScenarios) { + final isUnitTest = scenario.isUnitTestWithFeature(feature.decorators); + + buffer.writeln("class ${scenario.className} {"); + + for (var step in scenario.steps) { + final methodName = step.methodName; + final params = extractMethodParams(step.text); + + if (!isUnitTest) { + buffer.writeln( + " Future $methodName(WidgetTester tester${params.isNotEmpty ? ', $params' : ''}) async {", + ); + } else { + buffer.writeln( + " Future $methodName(${params.isNotEmpty ? params : ''}) async {", + ); + } + buffer.writeln(" // TODO: Implement ${step.keyword} ${step.text}"); + buffer.writeln(" }"); + buffer.writeln(); + } + + buffer.writeln("}"); + buffer.writeln(); + } + + return buffer.toString(); + } } String extractMethodParams(String stepText) { diff --git a/lib/src/infrastructure/builders/test_file_builder.dart b/lib/src/infrastructure/builders/test_file_builder.dart index 9232a00..931f061 100644 --- a/lib/src/infrastructure/builders/test_file_builder.dart +++ b/lib/src/infrastructure/builders/test_file_builder.dart @@ -1,5 +1,4 @@ import '../../constraints/file_constraint.dart'; -import '../../domain/decorator.dart'; import '../../domain/feature.dart'; import '../../domain/scenario.dart'; import '../../domain/step.dart'; @@ -8,47 +7,25 @@ import '../../extensions/string_x.dart'; class TestFileBuilder { Future buildTestFile(Feature feature) async { final buffer = StringBuffer(); - final useReporter = feature.decorators.hasEnableReporter && - !feature.decorators.hasDisableReporter; buffer.writeln("import 'package:flutter_test/flutter_test.dart';"); - if (useReporter) { - buffer.writeln("import 'package:bdd_flutter/bdd_flutter.dart';"); - } - buffer.writeln("import '${feature.fileName}${FileConstraint.generatedScenarios}';"); buffer.writeln(); buffer.writeln("void main() {"); - if (useReporter) { - buffer.writeln(" final reporter = BDDTestReporter(featureName: '${feature.name}');"); - buffer.writeln(" setUpAll(() {"); - buffer.writeln(" reporter.testStarted(); // start recording"); - buffer.writeln(" });"); - buffer.writeln(" tearDownAll(() {"); - buffer.writeln(" reporter.testFinished(); // stop recording"); - buffer.writeln(" reporter.printReport(); // print report"); - buffer.writeln(" //reporter.saveReportToFile(); //uncomment to save report to file"); - buffer.writeln(" });"); - } - buffer.writeln(" group('${feature.name}', () {"); for (var scenario in feature.scenarios) { - if (scenario.decorators.hasIgnore) continue; - final className = scenario.className; final isUnitTest = scenario.isUnitTestWithFeature(feature.decorators); final testFunction = isUnitTest ? 'test' : 'testWidgets'; - // Generate one test case per scenario if (isUnitTest) { buffer.writeln(" $testFunction('${scenario.name}', () async {"); } else { buffer.writeln(" $testFunction('${scenario.name}', (tester) async {"); } - // Instantiate scenario and background buffer.writeln(" final scenario = $className();"); if (feature.background != null) { @@ -62,11 +39,6 @@ class TestFileBuilder { buffer.writeln(" //Scenario: ${scenario.name}"); - //add start scenario if needed - if (useReporter) { - buffer.writeln(" reporter.startScenario('${scenario.name}');"); - } - if (scenario.examples != null && scenario.examples!.isNotEmpty) { buffer.writeln(" final examples = ["); @@ -80,7 +52,6 @@ class TestFileBuilder { } buffer.writeln(" ];"); - // Get the keys from the first example for parameter generation final exampleKeys = scenario.examples!.first.keys.toList(); buffer.writeln(" for (var example in examples) {"); @@ -92,23 +63,12 @@ class TestFileBuilder { } } - buffer.writeln(_generateStepCall( - step, - useReporter, - isUnitTest, - params, - )); + buffer.writeln(_generateStepCall(step, isUnitTest, params)); } buffer.writeln(" }"); } else { - // For scenarios without examples, just call all steps once for (var step in scenario.steps) { - buffer.writeln(_generateStepCall( - step, - useReporter, - isUnitTest, - [], - )); + buffer.writeln(_generateStepCall(step, isUnitTest, [])); } } buffer.writeln(" });"); @@ -121,22 +81,9 @@ class TestFileBuilder { } } -String _generateStepCall( - Step step, - bool withReporter, - bool isUnitTest, - List params, -) { +String _generateStepCall(Step step, bool isUnitTest, List params) { final methodName = step.methodName; - if (withReporter) { - return ''' - await reporter.guard( - () => scenario.$methodName(${isUnitTest ? '' : 'tester'}${params.isNotEmpty ? "${isUnitTest ? '' : ','} ${params.join(', ')}" : ''}), - '${step.message}', - );'''; - } else { - return ''' + return ''' // ${step.message} await scenario.$methodName(${isUnitTest ? '' : 'tester'}${params.isNotEmpty ? "${isUnitTest ? '' : ','} ${params.join(', ')}" : ''});'''; - } } diff --git a/lib/src/infrastructure/parsers/config_parser.dart b/lib/src/infrastructure/parsers/config_parser.dart index 3d66102..1d9b217 100644 --- a/lib/src/infrastructure/parsers/config_parser.dart +++ b/lib/src/infrastructure/parsers/config_parser.dart @@ -31,7 +31,6 @@ class ConfigParser { return BDDConfig( generateWidgetTests: yaml['generate_widget_tests'] as bool? ?? true, - enableReporter: yaml['enable_reporter'] as bool? ?? false, ignoreFeatures: _parseStringList(yaml['ignore_features']), ); } diff --git a/lib/src/infrastructure/parsers/feature_parser.dart b/lib/src/infrastructure/parsers/feature_parser.dart index dd49818..c2c0967 100644 --- a/lib/src/infrastructure/parsers/feature_parser.dart +++ b/lib/src/infrastructure/parsers/feature_parser.dart @@ -24,7 +24,6 @@ class FeatureParser { // scenario that is being process Scenario? currentScenario; List currentScenarioDecorators = []; - String? currentScenarioClassName; ExampleContent? currentExampleContent; @@ -51,16 +50,9 @@ class FeatureParser { currentExampleContent = null; currentScenario = null; currentScenarioDecorators = []; - currentScenarioClassName = null; } - // Check for @className("...") decorator - final className = Decorator.parseClassName(line); - if (className != null) { - currentScenarioClassName = className; - } else { - currentScenarioDecorators.add(Decorator.fromString(line)); - } + currentScenarioDecorators.add(Decorator.fromString(line)); } } // start parsing background @@ -76,20 +68,10 @@ class FeatureParser { // parsing scenario name else if (line.startsWith('Scenario:')) { isParsingBackground = false; - // isParsingExamples = false; - // currentExampleContent = null; - - // if lines start with Scenario:, it means it's a scenario name final name = line.substring('Scenario:'.length).trim(); if (currentScenario != null) { - // if currentScenario is not null, add it to the list currentScenario.examples = currentExampleContent?.examples; - // currentExampleContent = null; - // add the current scenario to the list scenarios.add(currentScenario); - // reset the examples - // currentExamples = []; - // exampleHeaders = []; currentExampleContent = null; } @@ -97,10 +79,8 @@ class FeatureParser { name, [], decorators: currentScenarioDecorators.toSet(), - customClassName: currentScenarioClassName, ); currentScenarioDecorators = []; - currentScenarioClassName = null; currentExampleContent = null; } // parsing steps diff --git a/lib/src/presentation/cli/bbd_cli.dart b/lib/src/presentation/cli/bbd_cli.dart index a4160f8..fb53f90 100644 --- a/lib/src/presentation/cli/bbd_cli.dart +++ b/lib/src/presentation/cli/bbd_cli.dart @@ -22,7 +22,6 @@ class BDDCLI { case 'build': final options = BuildOptions( widgetTest: !flags.contains('--no-widget-test'), - reporter: flags.contains('--reporter'), force: flags.contains('--force'), newOnly: flags.contains('--new-only'), ); @@ -42,7 +41,6 @@ class BDDCLI { stdout.writeln(''); stdout.writeln('Flags:'); stdout.writeln(' --no-widget-test Generate unit tests instead of widget tests'); - stdout.writeln(' --reporter Enable test reporter'); stdout.writeln(' --force Force regenerate all files'); stdout.writeln(' --new-only Only generate for new feature files'); } diff --git a/lib/src/presentation/controllers/bdd_controller.dart b/lib/src/presentation/controllers/bdd_controller.dart index 76f077f..ae71bac 100644 --- a/lib/src/presentation/controllers/bdd_controller.dart +++ b/lib/src/presentation/controllers/bdd_controller.dart @@ -1,7 +1,7 @@ import 'dart:io'; import '../../domain/build_options.dart'; -import '../../domain/decorator.dart'; +import '../../domain/feature.dart'; import '../../domain/manifest.dart'; import '../../domain/scenario.dart'; import '../../infrastructure/parsers/config_parser.dart'; @@ -30,10 +30,7 @@ class BDDController { _manifestParser = manifestParser ?? ManifestParser(); Future generateFeatureTestCases({BuildOptions options = const BuildOptions()}) async { - // Load config final config = await _configParser.loadConfig(); - - // Load existing manifest final manifest = await _manifestParser.loadManifest(); final featureFiles = Directory('test/') @@ -49,86 +46,110 @@ class BDDController { final updatedFeatures = []; int generated = 0; + int appended = 0; int skipped = 0; for (var featureFile in featureFiles) { final feature = await _featureParser.parseFeature(featureFile.path); - // Skip features with @ignore decorator - if (feature.decorators.hasIgnore) { - stdout.writeln(' Skipped (@ignore): ${featureFile.path}'); - skipped++; - continue; - } - - // Skip features in ignore_features config if (config.ignoreFeatures.any((ignored) => featureFile.path.endsWith(ignored))) { stdout.writeln(' Skipped (config): ${featureFile.path}'); skipped++; continue; } - final existingManifestEntry = _manifestParser.findFeature(manifest, featureFile.path); + final existingEntry = _manifestParser.findFeature(manifest, featureFile.path); + final scenarioPath = feature.path.replaceAll('.feature', '.bdd_scenarios.dart'); + final testPath = feature.path.replaceAll('.feature', '.bdd_test.dart'); - // Check generation mode - if (options.newOnly && existingManifestEntry != null) { + // New-only mode: skip if already in manifest + if (options.newOnly && existingEntry != null) { stdout.writeln(' Skipped (existing): ${featureFile.path}'); skipped++; - // Keep existing manifest entry - updatedFeatures.add(existingManifestEntry); + updatedFeatures.add(existingEntry); continue; } - if (!options.force && existingManifestEntry != null) { - // Incremental mode: check if feature has changed - final fileLastModified = featureFile.statSync().modified.toIso8601String(); - if (existingManifestEntry.lastModified == fileLastModified) { - // Check if all scenario hashes match - final currentHashes = feature.scenarios.map((s) => s.getHash).toSet(); - final manifestHashes = existingManifestEntry.scenarios.map((s) => s.hash).toSet(); - if (currentHashes.length == manifestHashes.length && - currentHashes.containsAll(manifestHashes)) { - stdout.writeln(' Skipped (unchanged): ${featureFile.path}'); - skipped++; - updatedFeatures.add(existingManifestEntry); - continue; - } - } + // Force mode: regenerate everything + if (options.force || existingEntry == null) { + await _generateFull(feature, scenarioPath, testPath); + stdout.writeln(' Generated: $scenarioPath'); + stdout.writeln(' Generated: $testPath'); + generated++; + updatedFeatures.add(_buildManifestEntry(featureFile, feature, testPath)); + continue; } - // Generate files - final scenarioContent = await _scenarioFileBuilder.buildScenarioFile(feature); - final testContent = await _testFileBuilder.buildTestFile(feature); + // Incremental mode: check what changed + final manifestHashes = existingEntry.scenarios.map((s) => s.hash).toSet(); + final currentScenarios = feature.scenarios.toList(); + final newScenarios = currentScenarios.where((s) => !manifestHashes.contains(s.getHash)).toList(); - final scenarioPath = feature.path.replaceAll('.feature', '.bdd_scenarios.dart'); - final testPath = feature.path.replaceAll('.feature', '.bdd_test.dart'); + if (newScenarios.isEmpty) { + stdout.writeln(' Skipped (unchanged): ${featureFile.path}'); + skipped++; + updatedFeatures.add(existingEntry); + continue; + } + + // Append new scenario classes to scenarios file (preserve existing implementations) + final newScenarioContent = _scenarioFileBuilder.buildNewScenarios(feature, newScenarios); + final scenarioFile = File(scenarioPath); + if (scenarioFile.existsSync()) { + await scenarioFile.writeAsString( + '${await scenarioFile.readAsString()}\n$newScenarioContent', + ); + } else { + // File was deleted โ€” full generate + await _generateFull(feature, scenarioPath, testPath); + stdout.writeln(' Generated: $scenarioPath'); + stdout.writeln(' Generated: $testPath'); + generated++; + updatedFeatures.add(_buildManifestEntry(featureFile, feature, testPath)); + continue; + } - await File(scenarioPath).writeAsString(scenarioContent); + // Test file is always fully regenerated (no user code in it) + final testContent = await _testFileBuilder.buildTestFile(feature); await File(testPath).writeAsString(testContent); - stdout.writeln(' Generated: $scenarioPath'); - stdout.writeln(' Generated: $testPath'); - generated++; - - // Build manifest entry for this feature - updatedFeatures.add(ManifestFeature( - path: featureFile.path, - lastModified: featureFile.statSync().modified.toIso8601String(), - testFile: testPath, - scenarios: feature.scenarios.map((s) => ManifestScenario( - name: s.name, - hash: s.getHash, - testMethod: 'test${s.className}', - )).toList(), - )); + final newNames = newScenarios.map((s) => s.name).join(', '); + stdout.writeln(' Appended new scenarios to: $scenarioPath ($newNames)'); + stdout.writeln(' Regenerated: $testPath'); + appended++; + + updatedFeatures.add(_buildManifestEntry(featureFile, feature, testPath)); } - // Save updated manifest - final updatedManifest = Manifest( - features: updatedFeatures, - ); + final updatedManifest = Manifest(features: updatedFeatures); await _manifestParser.saveManifest(updatedManifest); - stdout.writeln('Done. Generated: $generated, Skipped: $skipped.'); + final parts = []; + if (generated > 0) parts.add('Generated: $generated'); + if (appended > 0) parts.add('Appended: $appended'); + if (skipped > 0) parts.add('Skipped: $skipped'); + stdout.writeln('Done. ${parts.join(', ')}.'); + } + + Future _generateFull(Feature feature, String scenarioPath, String testPath) async { + final scenarioContent = await _scenarioFileBuilder.buildScenarioFile(feature); + final testContent = await _testFileBuilder.buildTestFile(feature); + await File(scenarioPath).writeAsString(scenarioContent); + await File(testPath).writeAsString(testContent); + } + + ManifestFeature _buildManifestEntry(FileSystemEntity featureFile, Feature feature, String testPath) { + return ManifestFeature( + path: featureFile.path, + lastModified: featureFile.statSync().modified.toIso8601String(), + testFile: testPath, + scenarios: feature.scenarios + .map((s) => ManifestScenario( + name: s.name, + hash: s.getHash, + testMethod: 'test${s.className}', + )) + .toList(), + ); } } diff --git a/test/builders/scenario_file_builder_test.dart b/test/builders/scenario_file_builder_test.dart index 3721c9e..7c7f5f7 100644 --- a/test/builders/scenario_file_builder_test.dart +++ b/test/builders/scenario_file_builder_test.dart @@ -108,39 +108,6 @@ void main() { expect(result, contains("import 'package:flutter_test/flutter_test.dart';")); }); - test('uses @className for custom class name', () async { - final feature = Feature( - name: 'Login', - path: 'test/login.feature', - scenarios: [ - Scenario('Test', [Step('Given', 'something')], customClassName: 'MyCustomScenario'), - ], - decorators: {}, - ); - - final result = await builder.buildScenarioFile(feature); - - expect(result, contains('class MyCustomScenario {')); - expect(result, isNot(contains('class TestScenario {'))); - }); - - test('skips scenarios with @ignore', () async { - final feature = Feature( - name: 'Login', - path: 'test/login.feature', - scenarios: [ - Scenario('Skipped', [Step('Given', 'something')], decorators: {Decorator.ignore}), - Scenario('Active', [Step('Given', 'something else')]), - ], - decorators: {}, - ); - - final result = await builder.buildScenarioFile(feature); - - expect(result, isNot(contains('class SkippedScenario {'))); - expect(result, contains('class ActiveScenario {')); - }); - test('inherits @unitTest from feature decorators', () async { final feature = Feature( name: 'Calculator', diff --git a/test/builders/test_file_builder_test.dart b/test/builders/test_file_builder_test.dart index 46992ac..fe2b404 100644 --- a/test/builders/test_file_builder_test.dart +++ b/test/builders/test_file_builder_test.dart @@ -146,26 +146,6 @@ void main() { expect(result, contains("import 'counter.bdd_scenarios.dart';")); }); - test('generates reporter setup when @enableReporter', () async { - final feature = Feature( - name: 'Counter', - path: 'test/counter.feature', - scenarios: [ - Scenario('Increment', [ - Step('Given', 'I have a counter'), - ]), - ], - decorators: {Decorator.enableReporter}, - ); - - final result = await builder.buildTestFile(feature); - - expect(result, contains("import 'package:bdd_flutter/bdd_flutter.dart';")); - expect(result, contains("final reporter = BDDTestReporter(featureName: 'Counter');")); - expect(result, contains('reporter.startScenario')); - expect(result, contains('reporter.guard')); - }); - test('wraps group with feature name', () async { final feature = Feature( name: 'My Feature', diff --git a/test/parsers/config_parser_test.dart b/test/parsers/config_parser_test.dart index 4f2648b..3bfd9db 100644 --- a/test/parsers/config_parser_test.dart +++ b/test/parsers/config_parser_test.dart @@ -23,7 +23,6 @@ void main() { final config = await parserInDir().loadConfig(); expect(config.generateWidgetTests, isTrue); - expect(config.enableReporter, isFalse); expect(config.ignoreFeatures, isEmpty); }); @@ -31,7 +30,6 @@ void main() { Directory('${tempDir.path}/.bdd_flutter').createSync(); File('${tempDir.path}/.bdd_flutter/config.yaml').writeAsStringSync(''' generate_widget_tests: false -enable_reporter: true ignore_features: - test/features/login.feature - test/features/signup.feature @@ -40,7 +38,6 @@ ignore_features: final config = await parserInDir().loadConfig(); expect(config.generateWidgetTests, isFalse); - expect(config.enableReporter, isTrue); expect(config.ignoreFeatures, hasLength(2)); expect(config.ignoreFeatures, contains('test/features/login.feature')); expect(config.ignoreFeatures, contains('test/features/signup.feature')); @@ -53,19 +50,17 @@ ignore_features: final config = await parserInDir().loadConfig(); expect(config.generateWidgetTests, isTrue); - expect(config.enableReporter, isFalse); }); test('handles partial config', () async { Directory('${tempDir.path}/.bdd_flutter').createSync(); File('${tempDir.path}/.bdd_flutter/config.yaml').writeAsStringSync(''' -enable_reporter: true +generate_widget_tests: false '''); final config = await parserInDir().loadConfig(); - expect(config.generateWidgetTests, isTrue); - expect(config.enableReporter, isTrue); + expect(config.generateWidgetTests, isFalse); expect(config.ignoreFeatures, isEmpty); }); }); diff --git a/test/parsers/feature_parser_test.dart b/test/parsers/feature_parser_test.dart index a350d28..70c3778 100644 --- a/test/parsers/feature_parser_test.dart +++ b/test/parsers/feature_parser_test.dart @@ -157,17 +157,6 @@ Feature: Counter expect(feature.scenarios.first.decorators.hasWidgetTest, isTrue); }); - test('parses @enableReporter decorator on feature', () async { - final file = createFeatureFile(''' -@enableReporter -Feature: Counter - Scenario: Increment - Given I have a counter -'''); - final feature = await parser.parseFeature(file.path); - expect(feature.decorators.hasEnableReporter, isTrue); - }); - test('parses feature with no scenarios returns empty list', () async { final file = createFeatureFile(''' Feature: Empty @@ -177,55 +166,6 @@ Feature: Empty expect(feature.scenarios, isEmpty); }); - test('parses @ignore decorator on feature', () async { - final file = createFeatureFile(''' -@ignore -Feature: Ignored - Scenario: Test - Given something -'''); - final feature = await parser.parseFeature(file.path); - expect(feature.decorators.hasIgnore, isTrue); - }); - - test('parses @ignore decorator on scenario', () async { - final file = createFeatureFile(''' -Feature: Login - @ignore - Scenario: Skipped - Given something - Scenario: Active - Given something else -'''); - final feature = await parser.parseFeature(file.path); - expect(feature.scenarios[0].decorators.hasIgnore, isTrue); - expect(feature.scenarios[1].decorators.hasIgnore, isFalse); - }); - - test('parses @className decorator', () async { - final file = createFeatureFile(''' -Feature: Login - @className("MyCustomClass") - Scenario: Test - Given something -'''); - final feature = await parser.parseFeature(file.path); - expect(feature.scenarios.first.customClassName, equals('MyCustomClass')); - }); - - test('parses @disableReporter decorator on feature', () async { - final file = createFeatureFile(''' -@enableReporter -@disableReporter -Feature: Counter - Scenario: Test - Given something -'''); - final feature = await parser.parseFeature(file.path); - expect(feature.decorators.hasEnableReporter, isTrue); - expect(feature.decorators.hasDisableReporter, isTrue); - }); - test('parses @unitTest on feature applies to scenarios', () async { final file = createFeatureFile(''' @unitTest From f8d1bbc4fe6df1c2bb3e4b5e745ee6f39e28e905 Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Tue, 31 Mar 2026 22:44:13 -0600 Subject: [PATCH 17/24] feat: add support for custom test directories, additional imports, and configurable scenario class suffixes in bdd_flutter configuration --- CLAUDE.md | 3 + README.md | 26 +++++- example/.bdd_flutter/manifest.yaml | 10 +- .../calculator/calculator.bdd_scenarios.dart | 2 +- .../test/calculator/calculator.bdd_test.dart | 2 +- example/test/sample/sample.bdd_scenarios.dart | 2 +- example/test/sample/sample.bdd_test.dart | 2 +- lib/src/domain/config.dart | 6 ++ lib/src/domain/scenario.dart | 2 + lib/src/extensions/string_x.dart | 6 +- .../builders/scenario_file_builder.dart | 91 +++++++++---------- .../builders/test_file_builder.dart | 7 +- .../infrastructure/parsers/config_parser.dart | 3 + .../controllers/bdd_controller.dart | 66 ++++++++++---- test/builders/scenario_file_builder_test.dart | 41 +++++++++ test/parsers/config_parser_test.dart | 42 +++++++++ 16 files changed, 227 insertions(+), 84 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 958b502..fa18171 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,8 +98,11 @@ await scenario.iHaveACounter(tester); ### Config File `.bdd_flutter/config.yaml`: +- `test_dir` (string, default `test/`) โ€” where to scan for `.feature` files - `generate_widget_tests` (bool, default true) - `ignore_features` (list of paths to skip) +- `additional_imports` (list of imports added to every generated `_scenarios.dart`) +- `scenario_suffix` (string, default `Scenario`) โ€” class name suffix (e.g., `Steps`) ### Manifest File diff --git a/README.md b/README.md index 35b6ea0..dbe9100 100644 --- a/README.md +++ b/README.md @@ -122,10 +122,25 @@ void main() { Configure the generator in `.bdd_flutter/config.yaml`: ```yaml +# Where to scan for .feature files +test_dir: "test/" + +# Generate widget tests or unit tests generate_widget_tests: true + +# Feature files to skip during generation ignore_features: - test/features/login.feature - test/features/registration.feature + +# Imports added to every generated .bdd_scenarios.dart file +additional_imports: + - "package:mocktail/mocktail.dart" + - "test/helpers/test_helpers.dart" + +# Suffix for generated scenario class names (default: "Scenario") +# e.g., "Steps" โ†’ IncrementSteps instead of IncrementScenario +scenario_suffix: "Scenario" ``` Or use CLI flags: @@ -136,10 +151,13 @@ dart run bdd_flutter build --no-widget-test --force ### Config Options -| Option | Type | Default | Description | -| ----------------------- | ---- | ------- | ------------------------------------------------------ | -| `generate_widget_tests` | bool | true | Generate widget tests when true, unit tests when false | -| `ignore_features` | List | [] | List of feature file paths to skip during generation | +| Option | Type | Default | Description | +| ----------------------- | ------ | ------------ | -------------------------------------------------------- | +| `test_dir` | String | `test/` | Directory to scan for `.feature` files | +| `generate_widget_tests` | bool | true | Generate widget tests when true, unit tests when false | +| `ignore_features` | List | [] | Feature file paths to skip during generation | +| `additional_imports` | List | [] | Imports added to every generated scenario file | +| `scenario_suffix` | String | `Scenario` | Class name suffix (e.g., `Steps` for `IncrementSteps`) | ### CLI Flags diff --git a/example/.bdd_flutter/manifest.yaml b/example/.bdd_flutter/manifest.yaml index df52e1a..adfee6a 100644 --- a/example/.bdd_flutter/manifest.yaml +++ b/example/.bdd_flutter/manifest.yaml @@ -1,5 +1,5 @@ version: "1.0" -last_generated: "2026-03-31T21:51:26.620372" +last_generated: "2026-03-31T22:40:54.476380" features: - path: "test/calculator/calculator.feature" last_modified: "2025-08-16T13:55:30.921" @@ -9,8 +9,8 @@ features: hash: "f03974460050014a1179e86546325452" test_method: "testAddTwoNumbersScenario" - name: "Subtract two numbers" - hash: "58dfc2abf3ca9433c7c97ab174300a1d" - test_method: "testSubtract" + hash: "6a3adb8f284f822789c89be0e919c7bf" + test_method: "testSubtractTwoNumbersScenario" - name: "Subtract two numbers2" hash: "f50907ed69f6ec5526f6acf958f01496" test_method: "testSubtractTwoNumbers2Scenario" @@ -67,8 +67,8 @@ features: hash: "55f8a2694d752e1c1cb32c7b9b9a56b2" test_method: "testSampleScenario" - name: "Counter" - hash: "2a009734b4ded7bb14973e86aea9b4eb" - test_method: "testCounterCustomName" + hash: "6fcad53b0190130b41652843710a936f" + test_method: "testCounterScenario" - name: "Counter with examples" hash: "9cba8be6501d2cfc9bfb3586c4714cc9" test_method: "testCounterWithExamplesScenario" diff --git a/example/test/calculator/calculator.bdd_scenarios.dart b/example/test/calculator/calculator.bdd_scenarios.dart index da2fe89..dec73f9 100644 --- a/example/test/calculator/calculator.bdd_scenarios.dart +++ b/example/test/calculator/calculator.bdd_scenarios.dart @@ -19,7 +19,7 @@ class AddTwoNumbersScenario { } -class Subtract { +class SubtractTwoNumbersScenario { Future iHaveTheNumber5(WidgetTester tester) async { // TODO: Implement Given I have the number 5 } diff --git a/example/test/calculator/calculator.bdd_test.dart b/example/test/calculator/calculator.bdd_test.dart index f3e3b59..2c69927 100644 --- a/example/test/calculator/calculator.bdd_test.dart +++ b/example/test/calculator/calculator.bdd_test.dart @@ -16,7 +16,7 @@ void main() { await scenario.theResultShouldBe3(tester); }); testWidgets('Subtract two numbers', (tester) async { - final scenario = Subtract(); + final scenario = SubtractTwoNumbersScenario(); //Scenario: Subtract two numbers // Given I have the number 5 await scenario.iHaveTheNumber5(tester); diff --git a/example/test/sample/sample.bdd_scenarios.dart b/example/test/sample/sample.bdd_scenarios.dart index 25f5445..944e307 100644 --- a/example/test/sample/sample.bdd_scenarios.dart +++ b/example/test/sample/sample.bdd_scenarios.dart @@ -15,7 +15,7 @@ class SampleScenario { } -class CounterCustomName { +class CounterScenario { Future iHaveACounter(WidgetTester tester) async { // TODO: Implement Given I have a counter } diff --git a/example/test/sample/sample.bdd_test.dart b/example/test/sample/sample.bdd_test.dart index 1a86242..ca76f76 100644 --- a/example/test/sample/sample.bdd_test.dart +++ b/example/test/sample/sample.bdd_test.dart @@ -14,7 +14,7 @@ void main() { await scenario.iShouldSeeTheSampleFeature(tester); }); testWidgets('Counter', (tester) async { - final scenario = CounterCustomName(); + final scenario = CounterScenario(); //Scenario: Counter // Given I have a counter await scenario.iHaveACounter(tester); diff --git a/lib/src/domain/config.dart b/lib/src/domain/config.dart index f72b2e0..c465d84 100644 --- a/lib/src/domain/config.dart +++ b/lib/src/domain/config.dart @@ -1,9 +1,15 @@ class BDDConfig { + final String testDir; final bool generateWidgetTests; final List ignoreFeatures; + final List additionalImports; + final String scenarioSuffix; const BDDConfig({ + this.testDir = 'test/', this.generateWidgetTests = true, this.ignoreFeatures = const [], + this.additionalImports = const [], + this.scenarioSuffix = 'Scenario', }); } diff --git a/lib/src/domain/scenario.dart b/lib/src/domain/scenario.dart index 656f9bf..be4563b 100644 --- a/lib/src/domain/scenario.dart +++ b/lib/src/domain/scenario.dart @@ -39,6 +39,8 @@ extension ScenarioX on Scenario { String get className => name.toScenarioClassName; + String classNameWithSuffix(String suffix) => name.toClassName(suffix); + String get getHash { return md5.convert(utf8.encode(toString())).toString(); } diff --git a/lib/src/extensions/string_x.dart b/lib/src/extensions/string_x.dart index 5139363..36e09ce 100644 --- a/lib/src/extensions/string_x.dart +++ b/lib/src/extensions/string_x.dart @@ -3,8 +3,10 @@ extension StringX on String { return split(' ').where((word) => word.isNotEmpty).map((word) => word[0].toUpperCase() + word.substring(1).toLowerCase()).join(''); } - String get toScenarioClassName { - return "${name}Scenario"; + String get toScenarioClassName => toClassName('Scenario'); + + String toClassName(String suffix) { + return '$name$suffix'; } String get snakeCaseToCamelCase { diff --git a/lib/src/infrastructure/builders/scenario_file_builder.dart b/lib/src/infrastructure/builders/scenario_file_builder.dart index 391f4cf..262f2de 100644 --- a/lib/src/infrastructure/builders/scenario_file_builder.dart +++ b/lib/src/infrastructure/builders/scenario_file_builder.dart @@ -4,9 +4,16 @@ import '../../domain/scenario.dart'; import '../../domain/step.dart'; class ScenariosFileBuilder { - Future buildScenarioFile(Feature feature) async { + Future buildScenarioFile( + Feature feature, { + List additionalImports = const [], + String scenarioSuffix = 'Scenario', + }) async { final buffer = StringBuffer(); buffer.writeln("import 'package:flutter_test/flutter_test.dart';"); + for (final imp in additionalImports) { + buffer.writeln("import '$imp';"); + } buffer.writeln(); if (feature.background != null) { @@ -26,68 +33,56 @@ class ScenariosFileBuilder { } for (var scenario in feature.scenarios) { - // Resolve unit test considering feature-level decorators - final isUnitTest = scenario.isUnitTestWithFeature(feature.decorators); - - buffer.writeln("class ${scenario.className} {"); - - for (var step in scenario.steps) { - final methodName = step.methodName; - final params = extractMethodParams(step.text); - - if (!isUnitTest) { - buffer.writeln( - " Future $methodName(WidgetTester tester${params.isNotEmpty ? ', $params' : ''}) async {", - ); - } else { - buffer.writeln( - " Future $methodName(${params.isNotEmpty ? params : ''}) async {", - ); - } - buffer.writeln(" // TODO: Implement ${step.keyword} ${step.text}"); - buffer.writeln(" }"); - buffer.writeln(); - } - - buffer.writeln("}"); - buffer.writeln(); + _writeScenarioClass(buffer, feature, scenario, scenarioSuffix); } return buffer.toString(); } /// Build only the specified scenarios (for appending to existing file) - String buildNewScenarios(Feature feature, List newScenarios) { + String buildNewScenarios( + Feature feature, + List newScenarios, { + String scenarioSuffix = 'Scenario', + }) { final buffer = StringBuffer(); - for (var scenario in newScenarios) { - final isUnitTest = scenario.isUnitTestWithFeature(feature.decorators); + _writeScenarioClass(buffer, feature, scenario, scenarioSuffix); + } + return buffer.toString(); + } - buffer.writeln("class ${scenario.className} {"); + void _writeScenarioClass( + StringBuffer buffer, + Feature feature, + Scenario scenario, + String scenarioSuffix, + ) { + final isUnitTest = scenario.isUnitTestWithFeature(feature.decorators); + final className = scenario.classNameWithSuffix(scenarioSuffix); - for (var step in scenario.steps) { - final methodName = step.methodName; - final params = extractMethodParams(step.text); + buffer.writeln("class $className {"); - if (!isUnitTest) { - buffer.writeln( - " Future $methodName(WidgetTester tester${params.isNotEmpty ? ', $params' : ''}) async {", - ); - } else { - buffer.writeln( - " Future $methodName(${params.isNotEmpty ? params : ''}) async {", - ); - } - buffer.writeln(" // TODO: Implement ${step.keyword} ${step.text}"); - buffer.writeln(" }"); - buffer.writeln(); - } + for (var step in scenario.steps) { + final methodName = step.methodName; + final params = extractMethodParams(step.text); - buffer.writeln("}"); + if (!isUnitTest) { + buffer.writeln( + " Future $methodName(WidgetTester tester${params.isNotEmpty ? ', $params' : ''}) async {", + ); + } else { + buffer.writeln( + " Future $methodName(${params.isNotEmpty ? params : ''}) async {", + ); + } + buffer.writeln(" // TODO: Implement ${step.keyword} ${step.text}"); + buffer.writeln(" }"); buffer.writeln(); } - return buffer.toString(); + buffer.writeln("}"); + buffer.writeln(); } } diff --git a/lib/src/infrastructure/builders/test_file_builder.dart b/lib/src/infrastructure/builders/test_file_builder.dart index 931f061..ce01762 100644 --- a/lib/src/infrastructure/builders/test_file_builder.dart +++ b/lib/src/infrastructure/builders/test_file_builder.dart @@ -5,7 +5,10 @@ import '../../domain/step.dart'; import '../../extensions/string_x.dart'; class TestFileBuilder { - Future buildTestFile(Feature feature) async { + Future buildTestFile( + Feature feature, { + String scenarioSuffix = 'Scenario', + }) async { final buffer = StringBuffer(); buffer.writeln("import 'package:flutter_test/flutter_test.dart';"); @@ -16,7 +19,7 @@ class TestFileBuilder { buffer.writeln(" group('${feature.name}', () {"); for (var scenario in feature.scenarios) { - final className = scenario.className; + final className = scenario.classNameWithSuffix(scenarioSuffix); final isUnitTest = scenario.isUnitTestWithFeature(feature.decorators); final testFunction = isUnitTest ? 'test' : 'testWidgets'; diff --git a/lib/src/infrastructure/parsers/config_parser.dart b/lib/src/infrastructure/parsers/config_parser.dart index 1d9b217..040a0ad 100644 --- a/lib/src/infrastructure/parsers/config_parser.dart +++ b/lib/src/infrastructure/parsers/config_parser.dart @@ -30,8 +30,11 @@ class ConfigParser { } return BDDConfig( + testDir: yaml['test_dir'] as String? ?? 'test/', generateWidgetTests: yaml['generate_widget_tests'] as bool? ?? true, ignoreFeatures: _parseStringList(yaml['ignore_features']), + additionalImports: _parseStringList(yaml['additional_imports']), + scenarioSuffix: yaml['scenario_suffix'] as String? ?? 'Scenario', ); } diff --git a/lib/src/presentation/controllers/bdd_controller.dart b/lib/src/presentation/controllers/bdd_controller.dart index ae71bac..8771e0f 100644 --- a/lib/src/presentation/controllers/bdd_controller.dart +++ b/lib/src/presentation/controllers/bdd_controller.dart @@ -1,6 +1,7 @@ import 'dart:io'; import '../../domain/build_options.dart'; +import '../../domain/config.dart'; import '../../domain/feature.dart'; import '../../domain/manifest.dart'; import '../../domain/scenario.dart'; @@ -33,12 +34,18 @@ class BDDController { final config = await _configParser.loadConfig(); final manifest = await _manifestParser.loadManifest(); - final featureFiles = Directory('test/') + final testDir = Directory(config.testDir); + if (!testDir.existsSync()) { + stdout.writeln('Directory "${config.testDir}" not found.'); + return; + } + + final featureFiles = testDir .listSync(recursive: true) .where((file) => file.path.endsWith('.feature')); if (featureFiles.isEmpty) { - stdout.writeln('No .feature files found in test/ directory.'); + stdout.writeln('No .feature files found in "${config.testDir}".'); return; } @@ -62,7 +69,6 @@ class BDDController { final scenarioPath = feature.path.replaceAll('.feature', '.bdd_scenarios.dart'); final testPath = feature.path.replaceAll('.feature', '.bdd_test.dart'); - // New-only mode: skip if already in manifest if (options.newOnly && existingEntry != null) { stdout.writeln(' Skipped (existing): ${featureFile.path}'); skipped++; @@ -70,13 +76,12 @@ class BDDController { continue; } - // Force mode: regenerate everything if (options.force || existingEntry == null) { - await _generateFull(feature, scenarioPath, testPath); + await _generateFull(feature, scenarioPath, testPath, config); stdout.writeln(' Generated: $scenarioPath'); stdout.writeln(' Generated: $testPath'); generated++; - updatedFeatures.add(_buildManifestEntry(featureFile, feature, testPath)); + updatedFeatures.add(_buildManifestEntry(featureFile, feature, testPath, config)); continue; } @@ -92,25 +97,31 @@ class BDDController { continue; } - // Append new scenario classes to scenarios file (preserve existing implementations) - final newScenarioContent = _scenarioFileBuilder.buildNewScenarios(feature, newScenarios); + // Append new scenario classes to scenarios file + final newScenarioContent = _scenarioFileBuilder.buildNewScenarios( + feature, + newScenarios, + scenarioSuffix: config.scenarioSuffix, + ); final scenarioFile = File(scenarioPath); if (scenarioFile.existsSync()) { await scenarioFile.writeAsString( '${await scenarioFile.readAsString()}\n$newScenarioContent', ); } else { - // File was deleted โ€” full generate - await _generateFull(feature, scenarioPath, testPath); + await _generateFull(feature, scenarioPath, testPath, config); stdout.writeln(' Generated: $scenarioPath'); stdout.writeln(' Generated: $testPath'); generated++; - updatedFeatures.add(_buildManifestEntry(featureFile, feature, testPath)); + updatedFeatures.add(_buildManifestEntry(featureFile, feature, testPath, config)); continue; } - // Test file is always fully regenerated (no user code in it) - final testContent = await _testFileBuilder.buildTestFile(feature); + // Test file is always fully regenerated + final testContent = await _testFileBuilder.buildTestFile( + feature, + scenarioSuffix: config.scenarioSuffix, + ); await File(testPath).writeAsString(testContent); final newNames = newScenarios.map((s) => s.name).join(', '); @@ -118,7 +129,7 @@ class BDDController { stdout.writeln(' Regenerated: $testPath'); appended++; - updatedFeatures.add(_buildManifestEntry(featureFile, feature, testPath)); + updatedFeatures.add(_buildManifestEntry(featureFile, feature, testPath, config)); } final updatedManifest = Manifest(features: updatedFeatures); @@ -131,14 +142,31 @@ class BDDController { stdout.writeln('Done. ${parts.join(', ')}.'); } - Future _generateFull(Feature feature, String scenarioPath, String testPath) async { - final scenarioContent = await _scenarioFileBuilder.buildScenarioFile(feature); - final testContent = await _testFileBuilder.buildTestFile(feature); + Future _generateFull( + Feature feature, + String scenarioPath, + String testPath, + BDDConfig config, + ) async { + final scenarioContent = await _scenarioFileBuilder.buildScenarioFile( + feature, + additionalImports: config.additionalImports, + scenarioSuffix: config.scenarioSuffix, + ); + final testContent = await _testFileBuilder.buildTestFile( + feature, + scenarioSuffix: config.scenarioSuffix, + ); await File(scenarioPath).writeAsString(scenarioContent); await File(testPath).writeAsString(testContent); } - ManifestFeature _buildManifestEntry(FileSystemEntity featureFile, Feature feature, String testPath) { + ManifestFeature _buildManifestEntry( + FileSystemEntity featureFile, + Feature feature, + String testPath, + BDDConfig config, + ) { return ManifestFeature( path: featureFile.path, lastModified: featureFile.statSync().modified.toIso8601String(), @@ -147,7 +175,7 @@ class BDDController { .map((s) => ManifestScenario( name: s.name, hash: s.getHash, - testMethod: 'test${s.className}', + testMethod: 'test${s.classNameWithSuffix(config.scenarioSuffix)}', )) .toList(), ); diff --git a/test/builders/scenario_file_builder_test.dart b/test/builders/scenario_file_builder_test.dart index 7c7f5f7..56f7f2e 100644 --- a/test/builders/scenario_file_builder_test.dart +++ b/test/builders/scenario_file_builder_test.dart @@ -140,5 +140,46 @@ void main() { expect(result, contains('class SuccessfulLoginScenario {')); expect(result, contains('class FailedLoginScenario {')); }); + + test('includes additional imports', () async { + final feature = Feature( + name: 'Login', + path: 'test/login.feature', + scenarios: [ + Scenario('Test', [Step('Given', 'something')]), + ], + decorators: {}, + ); + + final result = await builder.buildScenarioFile( + feature, + additionalImports: [ + 'package:mocktail/mocktail.dart', + 'test/helpers/test_helpers.dart', + ], + ); + + expect(result, contains("import 'package:mocktail/mocktail.dart';")); + expect(result, contains("import 'test/helpers/test_helpers.dart';")); + }); + + test('uses custom scenario suffix', () async { + final feature = Feature( + name: 'Login', + path: 'test/login.feature', + scenarios: [ + Scenario('Successful login', [Step('Given', 'I am logged in')]), + ], + decorators: {}, + ); + + final result = await builder.buildScenarioFile( + feature, + scenarioSuffix: 'Steps', + ); + + expect(result, contains('class SuccessfulLoginSteps {')); + expect(result, isNot(contains('SuccessfulLoginScenario'))); + }); }); } diff --git a/test/parsers/config_parser_test.dart b/test/parsers/config_parser_test.dart index 3bfd9db..0d42335 100644 --- a/test/parsers/config_parser_test.dart +++ b/test/parsers/config_parser_test.dart @@ -22,8 +22,11 @@ void main() { test('returns defaults when config file does not exist', () async { final config = await parserInDir().loadConfig(); + expect(config.testDir, equals('test/')); expect(config.generateWidgetTests, isTrue); expect(config.ignoreFeatures, isEmpty); + expect(config.additionalImports, isEmpty); + expect(config.scenarioSuffix, equals('Scenario')); }); test('parses config file with all options', () async { @@ -62,6 +65,45 @@ generate_widget_tests: false expect(config.generateWidgetTests, isFalse); expect(config.ignoreFeatures, isEmpty); + expect(config.testDir, equals('test/')); + expect(config.scenarioSuffix, equals('Scenario')); + }); + + test('parses test_dir option', () async { + Directory('${tempDir.path}/.bdd_flutter').createSync(); + File('${tempDir.path}/.bdd_flutter/config.yaml').writeAsStringSync(''' +test_dir: "integration_test/" +'''); + + final config = await parserInDir().loadConfig(); + + expect(config.testDir, equals('integration_test/')); + }); + + test('parses additional_imports option', () async { + Directory('${tempDir.path}/.bdd_flutter').createSync(); + File('${tempDir.path}/.bdd_flutter/config.yaml').writeAsStringSync(''' +additional_imports: + - "package:mocktail/mocktail.dart" + - "test/helpers/test_helpers.dart" +'''); + + final config = await parserInDir().loadConfig(); + + expect(config.additionalImports, hasLength(2)); + expect(config.additionalImports, contains('package:mocktail/mocktail.dart')); + expect(config.additionalImports, contains('test/helpers/test_helpers.dart')); + }); + + test('parses scenario_suffix option', () async { + Directory('${tempDir.path}/.bdd_flutter').createSync(); + File('${tempDir.path}/.bdd_flutter/config.yaml').writeAsStringSync(''' +scenario_suffix: "Steps" +'''); + + final config = await parserInDir().loadConfig(); + + expect(config.scenarioSuffix, equals('Steps')); }); }); } From bca9d092a090e0f5300c1fec7f777fc6c4bab70d Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Tue, 31 Mar 2026 22:55:57 -0600 Subject: [PATCH 18/24] feat: add bdd_flutter test command with automated test runner and formatted reporting --- CLAUDE.md | 5 +- README.md | 23 ++- lib/src/presentation/cli/bbd_cli.dart | 18 +- .../reporter/bdd_report_formatter.dart | 115 ++++++++++++ .../reporter/bdd_test_runner.dart | 172 ++++++++++++++++++ 5 files changed, 322 insertions(+), 11 deletions(-) create mode 100644 lib/src/presentation/reporter/bdd_report_formatter.dart create mode 100644 lib/src/presentation/reporter/bdd_test_runner.dart diff --git a/CLAUDE.md b/CLAUDE.md index fa18171..4c4be35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,9 @@ dart test test/parsers/ test/builders/ # Run the CLI locally against the example project cd example && dart run bdd_flutter build +# Run BDD tests with formatted report +cd example && dart run bdd_flutter test + # Force regenerate all cd example && dart run bdd_flutter build --force @@ -49,7 +52,7 @@ BDDCLI.run(args) - **`infrastructure/builders/`** โ€” ScenariosFileBuilder, TestFileBuilder (domain models โ†’ Dart code) - **`presentation/cli/`** โ€” BDDCLI entry point, argument parsing - **`presentation/controllers/`** โ€” BDDController orchestrates config, manifest, parsing, building -- **`presentation/reporter/`** โ€” BDDTestReporter (exported for use in generated tests) +- **`presentation/reporter/`** โ€” BDDTestRunner (CLI `test` command), BDDReportFormatter (output formatting), BDDTestReporter (legacy, exported) ### Domain Models diff --git a/README.md b/README.md index dbe9100..11a5f36 100644 --- a/README.md +++ b/README.md @@ -51,12 +51,14 @@ dart run bdd_flutter build 3. Implement the generated step methods in `counter.bdd_scenarios.dart` -4. Run your tests: +4. Run your tests with BDD report: ```bash -flutter test +dart run bdd_flutter test ``` +Or run normally with `flutter test`. + ## Generated Files The generator creates two files per `.feature` file: @@ -161,11 +163,18 @@ dart run bdd_flutter build --no-widget-test --force ### CLI Flags -| Flag | Description | -| ------------------ | ---------------------------------------- | -| `--no-widget-test` | Generate unit tests instead of widget tests | -| `--force` | Force regenerate all files | -| `--new-only` | Only generate for new feature files | +| Flag | Command | Description | +| ------------------ | ------- | ---------------------------------------- | +| `--no-widget-test` | build | Generate unit tests instead of widget tests | +| `--force` | build | Force regenerate all files | +| `--new-only` | build | Only generate for new feature files | + +### Commands + +| Command | Description | +| ------- | ----------- | +| `build` | Generate test files from `.feature` files | +| `test` | Run BDD tests with formatted Feature/Scenario report | ## Decorators diff --git a/lib/src/presentation/cli/bbd_cli.dart b/lib/src/presentation/cli/bbd_cli.dart index fb53f90..0f8196d 100644 --- a/lib/src/presentation/cli/bbd_cli.dart +++ b/lib/src/presentation/cli/bbd_cli.dart @@ -1,13 +1,19 @@ import 'dart:io'; import '../../domain/build_options.dart'; +import '../../infrastructure/parsers/config_parser.dart'; import '../controllers/bdd_controller.dart'; +import '../reporter/bdd_test_runner.dart'; class BDDCLI { final BDDController _bddController; + final ConfigParser _configParser; - BDDCLI({BDDController? bddController}) - : _bddController = bddController ?? BDDController(); + BDDCLI({ + BDDController? bddController, + ConfigParser? configParser, + }) : _bddController = bddController ?? BDDController(), + _configParser = configParser ?? ConfigParser(); Future run(List arguments) async { if (arguments.isEmpty) { @@ -27,6 +33,11 @@ class BDDCLI { ); await _bddController.generateFeatureTestCases(options: options); break; + case 'test': + final config = await _configParser.loadConfig(); + final runner = BDDTestRunner(testDir: config.testDir); + final exitCode = await runner.run(); + exit(exitCode); default: stdout.writeln('Unknown command: $command'); _printUsage(); @@ -38,8 +49,9 @@ class BDDCLI { stdout.writeln(''); stdout.writeln('Available commands:'); stdout.writeln(' build Generate test files from .feature files'); + stdout.writeln(' test Run BDD tests with formatted report'); stdout.writeln(''); - stdout.writeln('Flags:'); + stdout.writeln('Build flags:'); stdout.writeln(' --no-widget-test Generate unit tests instead of widget tests'); stdout.writeln(' --force Force regenerate all files'); stdout.writeln(' --new-only Only generate for new feature files'); diff --git a/lib/src/presentation/reporter/bdd_report_formatter.dart b/lib/src/presentation/reporter/bdd_report_formatter.dart new file mode 100644 index 0000000..f1124f9 --- /dev/null +++ b/lib/src/presentation/reporter/bdd_report_formatter.dart @@ -0,0 +1,115 @@ +import 'dart:io'; + +const String _red = '\x1B[31m'; +const String _green = '\x1B[32m'; +const String _cyan = '\x1B[36m'; +const String _dim = '\x1B[2m'; +const String _reset = '\x1B[0m'; + +class BDDReportFormatter { + final List features = []; + DateTime? _startTime; + + void start() { + _startTime = DateTime.now(); + } + + FeatureReport getOrCreateFeature(String name) { + for (final feature in features) { + if (feature.name == name) return feature; + } + final feature = FeatureReport(name: name); + features.add(feature); + return feature; + } + + void addTestResult({ + required String featureName, + required String scenarioName, + required bool passed, + String? error, + Duration? duration, + }) { + final feature = getOrCreateFeature(featureName); + feature.scenarios.add(ScenarioResult( + name: scenarioName, + passed: passed, + error: error, + duration: duration, + )); + } + + String formatReport() { + final buffer = StringBuffer(); + final totalDuration = _startTime != null + ? DateTime.now().difference(_startTime!) + : Duration.zero; + + buffer.writeln(); + buffer.writeln('${_cyan}BDD Test Report$_reset'); + buffer.writeln('$_dim${'=' * 50}$_reset'); + + int totalScenarios = 0; + int totalPassed = 0; + int totalFailed = 0; + + for (final feature in features) { + buffer.writeln(); + buffer.writeln(' ${_cyan}Feature: ${feature.name}$_reset'); + + for (final scenario in feature.scenarios) { + totalScenarios++; + final durationStr = scenario.duration != null + ? ' $_dim(${scenario.duration!.inMilliseconds}ms)$_reset' + : ''; + + if (scenario.passed) { + totalPassed++; + buffer.writeln(' $_greenโœ“$_reset ${scenario.name}$durationStr'); + } else { + totalFailed++; + buffer.writeln(' $_redโœ— ${scenario.name}$_reset$durationStr'); + if (scenario.error != null) { + buffer.writeln(' $_red${scenario.error}$_reset'); + } + } + } + } + + buffer.writeln(); + buffer.writeln('$_dim${'โ”€' * 50}$_reset'); + + final passedStr = totalPassed > 0 ? '$_green$totalPassed passed$_reset' : '0 passed'; + final failedStr = totalFailed > 0 ? '$_red$totalFailed failed$_reset' : '0 failed'; + buffer.writeln(' $totalScenarios scenarios: $passedStr, $failedStr'); + buffer.writeln(' Time: ${totalDuration.inMilliseconds}ms'); + buffer.writeln(); + + return buffer.toString(); + } + + void printReport() { + stdout.write(formatReport()); + } +} + +class FeatureReport { + final String name; + final List scenarios = []; + + FeatureReport({required this.name}); +} + +class ScenarioResult { + final String name; + final bool passed; + final String? error; + final Duration? duration; + + ScenarioResult({ + required this.name, + required this.passed, + this.error, + this.duration, + }); +} diff --git a/lib/src/presentation/reporter/bdd_test_runner.dart b/lib/src/presentation/reporter/bdd_test_runner.dart new file mode 100644 index 0000000..3ada73e --- /dev/null +++ b/lib/src/presentation/reporter/bdd_test_runner.dart @@ -0,0 +1,172 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'bdd_report_formatter.dart'; + +class BDDTestRunner { + final BDDReportFormatter _formatter; + final String testDir; + + BDDTestRunner({ + BDDReportFormatter? formatter, + this.testDir = 'test/', + }) : _formatter = formatter ?? BDDReportFormatter(); + + Future run() async { + _formatter.start(); + + // Find all .bdd_test.dart files + final testFiles = Directory(testDir) + .listSync(recursive: true) + .where((f) => f.path.endsWith('.bdd_test.dart')) + .map((f) => f.path) + .toList(); + + if (testFiles.isEmpty) { + stdout.writeln('No .bdd_test.dart files found in "$testDir".'); + return 0; + } + + stdout.writeln('Running ${testFiles.length} BDD test file(s)...'); + stdout.writeln(); + + // Run flutter test --machine with all test files + final process = await Process.start( + 'flutter', + ['test', '--machine', ...testFiles], + mode: ProcessStartMode.normal, + ); + + // Track test state + final testNames = {}; + final testStartTimes = {}; + final testGroups = {}; + final groupNames = {}; + final errorMessages = {}; + + // Parse JSON events line by line + await process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .forEach((line) { + _processJsonLine( + line, + testNames: testNames, + testStartTimes: testStartTimes, + testGroups: testGroups, + groupNames: groupNames, + errorMessages: errorMessages, + ); + }); + + // Capture stderr for unexpected errors + final stderr = await process.stderr.transform(utf8.decoder).join(); + final exitCode = await process.exitCode; + + if (stderr.isNotEmpty && exitCode != 0 && _formatter.features.isEmpty) { + stdout.writeln(stderr); + } + + _formatter.printReport(); + + return exitCode; + } + + void _processJsonLine( + String line, { + required Map testNames, + required Map testStartTimes, + required Map testGroups, + required Map groupNames, + required Map errorMessages, + }) { + if (line.trim().isEmpty) return; + + dynamic json; + try { + json = jsonDecode(line); + } catch (_) { + return; + } + + if (json is! Map) return; + + final type = json['type'] as String?; + + switch (type) { + case 'group': + final group = json['group'] as Map?; + if (group != null) { + final groupId = group['id'] as int?; + final groupName = group['name'] as String?; + if (groupId != null && groupName != null && groupName.isNotEmpty) { + groupNames[groupId] = groupName; + } + } + break; + + case 'testStart': + final test = json['test'] as Map?; + if (test != null) { + final testId = test['id'] as int?; + final testName = test['name'] as String?; + final groupIds = (test['groupIDs'] as List?)?.cast() ?? []; + + if (testId != null && testName != null) { + // Skip loading tests (they have metadata) + final metadata = test['metadata'] as Map?; + if (metadata != null && (metadata['skip'] == true)) break; + + testNames[testId] = testName; + testStartTimes[testId] = DateTime.now(); + + // Find the parent group (feature name) + if (groupIds.length >= 2) { + final featureGroupId = groupIds[1]; // first real group after root + testGroups[testId] = groupNames[featureGroupId] ?? ''; + } + } + } + break; + + case 'error': + final testId = json['testID'] as int?; + final error = json['error'] as String?; + if (testId != null && error != null) { + errorMessages[testId] = error; + } + break; + + case 'testDone': + final testId = json['testID'] as int?; + final result = json['result'] as String?; + final hidden = json['hidden'] as bool? ?? false; + + if (testId != null && result != null && !hidden) { + final fullName = testNames[testId] ?? ''; + final featureName = testGroups[testId] ?? ''; + final startTime = testStartTimes[testId]; + final duration = startTime != null + ? DateTime.now().difference(startTime) + : null; + + // Extract scenario name by removing the feature group prefix + String scenarioName = fullName; + if (featureName.isNotEmpty && fullName.startsWith(featureName)) { + scenarioName = fullName.substring(featureName.length).trim(); + } + + if (scenarioName.isNotEmpty && featureName.isNotEmpty) { + _formatter.addTestResult( + featureName: featureName, + scenarioName: scenarioName, + passed: result == 'success', + error: errorMessages[testId], + duration: duration, + ); + } + } + break; + } + } +} From 98bb8047218e4a8961c35685ef70e889bdd1822c Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Tue, 31 Mar 2026 22:59:08 -0600 Subject: [PATCH 19/24] refactor: remove deprecated --no-widget-test and --new-only CLI flags and options --- CLAUDE.md | 11 ++++---- README.md | 28 +++++-------------- lib/src/domain/build_options.dart | 4 --- lib/src/presentation/cli/bbd_cli.dart | 6 +--- .../controllers/bdd_controller.dart | 7 ----- 5 files changed, 13 insertions(+), 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4c4be35..d989910 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,20 +83,19 @@ await scenario.iHaveACounter(tester); ### Generation Modes -- **Incremental (default)** โ€” compares file timestamps + scenario hashes against manifest, skips unchanged +- **Incremental (default)** โ€” compares scenario hashes against manifest, skips unchanged, appends new scenarios - **Force (`--force`)** โ€” regenerates everything -- **New-only (`--new-only`)** โ€” only generates for features not in manifest ### Decorators - `@unitTest` / `@widgetTest` โ€” on feature or scenario (scenario overrides feature) - Feature files only contain behavior-relevant tags; tooling config lives in `.bdd_flutter/config.yaml` -### CLI Flags +### CLI Commands -- `--no-widget-test` โ€” Generate unit tests instead of widget tests -- `--force` โ€” Regenerate all files regardless of changes -- `--new-only` โ€” Only generate for new feature files +- `build` โ€” Generate test files (incremental by default) +- `build --force` โ€” Regenerate all files regardless of changes +- `test` โ€” Run BDD tests with formatted Feature/Scenario report ### Config File diff --git a/README.md b/README.md index 11a5f36..19a380b 100644 --- a/README.md +++ b/README.md @@ -145,10 +145,10 @@ additional_imports: scenario_suffix: "Scenario" ``` -Or use CLI flags: +Or use `--force` to regenerate everything: ```bash -dart run bdd_flutter build --no-widget-test --force +dart run bdd_flutter build --force ``` ### Config Options @@ -163,18 +163,13 @@ dart run bdd_flutter build --no-widget-test --force ### CLI Flags -| Flag | Command | Description | -| ------------------ | ------- | ---------------------------------------- | -| `--no-widget-test` | build | Generate unit tests instead of widget tests | -| `--force` | build | Force regenerate all files | -| `--new-only` | build | Only generate for new feature files | - ### Commands -| Command | Description | -| ------- | ----------- | -| `build` | Generate test files from `.feature` files | -| `test` | Run BDD tests with formatted Feature/Scenario report | +| Command | Description | +| --------------- | ---------------------------------------------------- | +| `build` | Generate test files from `.feature` files | +| `build --force` | Force regenerate all files (overwrites existing) | +| `test` | Run BDD tests with formatted Feature/Scenario report | ## Decorators @@ -210,15 +205,6 @@ dart run bdd_flutter build --force - Regenerates all files from scratch - **Overwrites** existing scenario implementations โ€” use with caution -### New Files Only - -```bash -dart run bdd_flutter build --new-only -``` - -- Only generates for feature files not yet in the manifest -- Skips all existing features entirely - ## Project Structure ``` diff --git a/lib/src/domain/build_options.dart b/lib/src/domain/build_options.dart index 1b1f4a5..92dfcd4 100644 --- a/lib/src/domain/build_options.dart +++ b/lib/src/domain/build_options.dart @@ -1,11 +1,7 @@ class BuildOptions { - final bool widgetTest; final bool force; - final bool newOnly; const BuildOptions({ - this.widgetTest = true, this.force = false, - this.newOnly = false, }); } diff --git a/lib/src/presentation/cli/bbd_cli.dart b/lib/src/presentation/cli/bbd_cli.dart index 0f8196d..ff268df 100644 --- a/lib/src/presentation/cli/bbd_cli.dart +++ b/lib/src/presentation/cli/bbd_cli.dart @@ -27,9 +27,7 @@ class BDDCLI { switch (command) { case 'build': final options = BuildOptions( - widgetTest: !flags.contains('--no-widget-test'), force: flags.contains('--force'), - newOnly: flags.contains('--new-only'), ); await _bddController.generateFeatureTestCases(options: options); break; @@ -52,8 +50,6 @@ class BDDCLI { stdout.writeln(' test Run BDD tests with formatted report'); stdout.writeln(''); stdout.writeln('Build flags:'); - stdout.writeln(' --no-widget-test Generate unit tests instead of widget tests'); - stdout.writeln(' --force Force regenerate all files'); - stdout.writeln(' --new-only Only generate for new feature files'); + stdout.writeln(' --force Force regenerate all files'); } } diff --git a/lib/src/presentation/controllers/bdd_controller.dart b/lib/src/presentation/controllers/bdd_controller.dart index 8771e0f..1c06583 100644 --- a/lib/src/presentation/controllers/bdd_controller.dart +++ b/lib/src/presentation/controllers/bdd_controller.dart @@ -69,13 +69,6 @@ class BDDController { final scenarioPath = feature.path.replaceAll('.feature', '.bdd_scenarios.dart'); final testPath = feature.path.replaceAll('.feature', '.bdd_test.dart'); - if (options.newOnly && existingEntry != null) { - stdout.writeln(' Skipped (existing): ${featureFile.path}'); - skipped++; - updatedFeatures.add(existingEntry); - continue; - } - if (options.force || existingEntry == null) { await _generateFull(feature, scenarioPath, testPath, config); stdout.writeln(' Generated: $scenarioPath'); From 6452ec9944f6f73f92124ce0c06b931d74fb783d Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Tue, 31 Mar 2026 23:10:15 -0600 Subject: [PATCH 20/24] refactor: add comprehensive documentation to domain, infrastructure, and presentation layers --- lib/bdd_flutter.dart | 93 ++-------- lib/src/constraints/file_constraint.dart | 8 +- lib/src/domain/background.dart | 12 ++ lib/src/domain/build_options.dart | 2 + lib/src/domain/config.dart | 19 ++ lib/src/domain/decorator.dart | 20 ++ lib/src/domain/feature.dart | 17 ++ lib/src/domain/manifest.dart | 23 +++ lib/src/domain/scenario.dart | 26 ++- lib/src/domain/step.dart | 19 +- lib/src/extensions/list_x.dart | 2 + lib/src/extensions/string_x.dart | 16 ++ .../builders/scenario_file_builder.dart | 17 +- .../builders/test_file_builder.dart | 5 + .../infrastructure/parsers/config_parser.dart | 5 + .../parsers/feature_parser.dart | 7 +- .../parsers/manifest_parser.dart | 13 ++ lib/src/presentation/cli/bbd_cli.dart | 7 + .../controllers/bdd_controller.dart | 10 + .../reporter/bdd_report_formatter.dart | 5 + .../reporter/bdd_test_runner.dart | 8 + .../presentation/reporter/test_reporter.dart | 171 ------------------ 22 files changed, 247 insertions(+), 258 deletions(-) delete mode 100644 lib/src/presentation/reporter/test_reporter.dart diff --git a/lib/bdd_flutter.dart b/lib/bdd_flutter.dart index 2806f50..17b27d2 100644 --- a/lib/bdd_flutter.dart +++ b/lib/bdd_flutter.dart @@ -1,90 +1,33 @@ -library bdd_flutter; - -export 'src/presentation/reporter/test_reporter.dart' show BDDTestReporter; - /// A Flutter package for Behavior-Driven Development (BDD) testing. /// -/// This package allows you to write tests in a BDD style using feature files -/// and automatically generates the corresponding Dart test files. -/// -/// ## Features -/// -/// * Parse Gherkin-style feature files -/// * Generate test files automatically -/// * Support for both widget and non-widget tests -/// * Customizable test generation options -/// * Incremental generation to preserve user-written code -/// * Configuration in .bdd_flutter/config.yaml -/// -/// ## Getting Started -/// -/// 1. Add the package to your `pubspec.yaml`: -/// ```yaml -/// dev_dependencies: -/// bdd_flutter: ^1.0.0 -/// ``` +/// BDD Flutter generates Dart test files from Gherkin `.feature` files. +/// Write scenarios in plain English using Given/When/Then syntax, then run: /// -/// 2. Create a feature file (e.g., `test/features/login.feature`): -/// ```gherkin -/// Feature: Login -/// As a user -/// I want to log in to the application -/// So that I can access my account -/// -/// Scenario: Successful login -/// Given I am on the login screen -/// When I enter valid credentials -/// And I tap the login button -/// Then I should see the home screen -/// ``` -/// -/// 3. Run the generator to create test files: /// ```bash -/// # Default: Incremental update (process new and modified scenarios) /// dart run bdd_flutter build +/// ``` /// -/// # Force regenerate all files -/// dart run bdd_flutter build --force +/// This generates `.bdd_scenarios.dart` (step stubs) and `.bdd_test.dart` +/// (test orchestration) files alongside each `.feature` file. /// -/// # Only process new feature files -/// dart run bdd_flutter build --new-only -/// ``` +/// ## Quick Start +/// +/// 1. Create a `.feature` file in your test folder +/// 2. Run `dart run bdd_flutter build` to generate test files +/// 3. Implement the step methods in the generated scenario classes +/// 4. Run `dart run bdd_flutter test` for a BDD-formatted test report /// /// ## Configuration /// -/// Configuration is stored in `.bdd_flutter/config.yaml`: +/// Configure via `.bdd_flutter/config.yaml`: +/// /// ```yaml +/// test_dir: "test/" /// generate_widget_tests: true -/// enable_reporter: false /// ignore_features: /// - test/features/login.feature -/// - test/features/registration.feature -/// ``` -/// -/// Command-line arguments override config file settings: -/// ```bash -/// dart run bdd_flutter build --no-widget-tests --enable-reporter --ignore login.feature +/// additional_imports: +/// - "package:mocktail/mocktail.dart" +/// scenario_suffix: "Scenario" /// ``` -/// -/// ## Generation Modes -/// -/// The package supports three generation modes: -/// -/// 1. **Incremental Update** (default): -/// - Processes new and modified scenarios -/// - Preserves user-written code -/// - Tracks changes in .bdd_flutter/manifest.yaml -/// -/// 2. **Force Regenerate** (--force): -/// - Regenerates all test files -/// - Overwrites existing files -/// - Use with caution -/// -/// 3. **New Files Only** (--new-only): -/// - Only processes new feature files -/// - Skips existing files -/// - Useful for initial setup -/// -/// ## Additional Information -/// -/// For more information, visit the [documentation](https://example.com/bdd_flutter). +library bdd_flutter; diff --git a/lib/src/constraints/file_constraint.dart b/lib/src/constraints/file_constraint.dart index 9059ccb..52a5032 100644 --- a/lib/src/constraints/file_constraint.dart +++ b/lib/src/constraints/file_constraint.dart @@ -1,7 +1,11 @@ +/// File extension constants used by the generator. class FileConstraint { + /// Extension for Gherkin feature files. static const String feature = '.feature'; + + /// Extension for generated test orchestration files. static const String generatedTest = '.bdd_test.dart'; + + /// Extension for generated scenario class files. static const String generatedScenarios = '.bdd_scenarios.dart'; - // static const String renamedTest = '.bdd_test.dart'; - // static const String renamedScenarios = '.bdd_scenarios.dart'; } diff --git a/lib/src/domain/background.dart b/lib/src/domain/background.dart index c7121a4..b24af4e 100644 --- a/lib/src/domain/background.dart +++ b/lib/src/domain/background.dart @@ -1,7 +1,19 @@ import 'step.dart'; +/// Represents a Background block in a Gherkin feature file. +/// +/// Background steps run before every scenario in the feature. +/// +/// ```gherkin +/// Feature: Counter +/// Background: Setup +/// Given I have a counter with value 0 +/// ``` class Background { + /// A description of the background (text after `Background:`). String description; + + /// The steps that make up the background setup. List steps; Background({required this.description, required this.steps}); diff --git a/lib/src/domain/build_options.dart b/lib/src/domain/build_options.dart index 92dfcd4..c36e42f 100644 --- a/lib/src/domain/build_options.dart +++ b/lib/src/domain/build_options.dart @@ -1,4 +1,6 @@ +/// Options passed from the CLI to control build behavior. class BuildOptions { + /// When true, regenerates all files regardless of manifest state. final bool force; const BuildOptions({ diff --git a/lib/src/domain/config.dart b/lib/src/domain/config.dart index c465d84..428d891 100644 --- a/lib/src/domain/config.dart +++ b/lib/src/domain/config.dart @@ -1,8 +1,27 @@ +/// Configuration loaded from `.bdd_flutter/config.yaml`. +/// +/// Controls how the generator finds feature files, generates test code, +/// and names scenario classes. class BDDConfig { + /// Directory to scan for `.feature` files. final String testDir; + + /// When true, generates `testWidgets` with `WidgetTester`. + /// When false, generates `test` without `WidgetTester`. final bool generateWidgetTests; + + /// Feature file paths to skip during generation. final List ignoreFeatures; + + /// Import statements added to every generated `.bdd_scenarios.dart` file. + /// + /// Useful for shared test helpers, mock packages, etc. final List additionalImports; + + /// Suffix appended to scenario class names. + /// + /// Default is `Scenario` (e.g., `IncrementScenario`). + /// Set to `Steps` for `IncrementSteps`. final String scenarioSuffix; const BDDConfig({ diff --git a/lib/src/domain/decorator.dart b/lib/src/domain/decorator.dart index 1104b7e..0d7ac1b 100644 --- a/lib/src/domain/decorator.dart +++ b/lib/src/domain/decorator.dart @@ -1,8 +1,26 @@ +/// Gherkin tag decorators that control test generation behavior. +/// +/// Decorators are placed above `Feature:` or `Scenario:` lines in `.feature` files: +/// +/// ```gherkin +/// @unitTest +/// Feature: Calculator +/// @widgetTest +/// Scenario: Increment +/// ``` +/// +/// Scenario-level decorators override feature-level ones. enum Decorator { + /// Generate a unit test using `test()` without `WidgetTester`. unitTest, + + /// Generate a widget test using `testWidgets()` with `WidgetTester`. widgetTest, + + /// Unrecognized decorator tag. unknown; + /// Parses a decorator string (e.g., `@unitTest`) into a [Decorator] value. static Decorator fromString(String text) { final trimmed = text.trim(); return switch (trimmed) { @@ -13,11 +31,13 @@ enum Decorator { } } +/// Convenience getters for a single [Decorator]. extension DecoratorX on Decorator { bool get isUnitTest => this == Decorator.unitTest; bool get isWidgetTest => this == Decorator.widgetTest; } +/// Convenience getters for a set of [Decorator]s. extension DecoratorSetX on Set { bool get hasUnitTest => any((e) => e.isUnitTest); bool get hasWidgetTest => any((e) => e.isWidgetTest); diff --git a/lib/src/domain/feature.dart b/lib/src/domain/feature.dart index 86e71d2..c41b60f 100644 --- a/lib/src/domain/feature.dart +++ b/lib/src/domain/feature.dart @@ -4,11 +4,24 @@ import 'background.dart'; import 'decorator.dart'; import 'scenario.dart'; +/// Represents a parsed Gherkin feature file. +/// +/// A feature contains a name, file path, optional background, a list of +/// scenarios, and any decorators applied at the feature level. class Feature { + /// The feature name (text after `Feature:`). String name; + + /// The file path of the `.feature` file. String path; + + /// The scenarios defined in this feature. List scenarios; + + /// Optional background steps shared by all scenarios. Background? background; + + /// Decorators applied at the feature level (e.g., `@unitTest`). Set decorators; Feature({ @@ -20,15 +33,19 @@ class Feature { }); } +/// Extension methods for [Feature]. extension FeatureX on Feature { + /// The generated scenarios file name (e.g., `counter.bdd_scenarios.dart`). String get scenariosFileName { return '${fileName.replaceAll('.feature', '')}${FileConstraint.generatedScenarios}'; } + /// The generated test file name (e.g., `counter.bdd_test.dart`). String get testFileName { return '${fileName.replaceAll('.feature', '')}${FileConstraint.generatedTest}'; } + /// The base file name without directory path or `.feature` extension. String get fileName { return path.split('/').last.replaceAll('.feature', ''); } diff --git a/lib/src/domain/manifest.dart b/lib/src/domain/manifest.dart index 32745b0..9fc8eb4 100644 --- a/lib/src/domain/manifest.dart +++ b/lib/src/domain/manifest.dart @@ -1,6 +1,15 @@ +/// Tracks the state of generated files in `.bdd_flutter/manifest.yaml`. +/// +/// The manifest enables incremental builds by storing hashes of each scenario. +/// On subsequent builds, only new or changed scenarios trigger generation. class Manifest { + /// Manifest format version. final String version; + + /// Timestamp of the last generation run. final DateTime lastGenerated; + + /// List of tracked feature files and their scenarios. final List features; Manifest({ @@ -10,10 +19,18 @@ class Manifest { }) : lastGenerated = lastGenerated ?? DateTime.now(); } +/// A feature entry in the manifest, tracking its file and scenarios. class ManifestFeature { + /// Path to the `.feature` file. final String path; + + /// ISO 8601 timestamp of the feature file's last modification. final String lastModified; + + /// Path to the generated `.bdd_test.dart` file. final String testFile; + + /// Tracked scenarios with their content hashes. final List scenarios; ManifestFeature({ @@ -24,9 +41,15 @@ class ManifestFeature { }); } +/// A scenario entry in the manifest, identified by its content hash. class ManifestScenario { + /// The scenario name. final String name; + + /// MD5 hash of the scenario's content (name, steps, examples, decorators). final String hash; + + /// The generated test method name. final String testMethod; ManifestScenario({ diff --git a/lib/src/domain/scenario.dart b/lib/src/domain/scenario.dart index be4563b..8c3bb94 100644 --- a/lib/src/domain/scenario.dart +++ b/lib/src/domain/scenario.dart @@ -5,11 +5,23 @@ import 'package:crypto/crypto.dart'; import 'decorator.dart'; import 'step.dart'; -/// A scenario is a collection of steps +/// Represents a Gherkin scenario with its steps, examples, and decorators. +/// +/// Each scenario generates an instance-based class with step methods. +/// Users implement the step logic; the test file instantiates and calls them. class Scenario { + /// The scenario name (text after `Scenario:`). String name; + + /// The steps in this scenario (Given/When/Then/And). List steps; + + /// Example rows for parameterized scenarios, if any. + /// + /// Each entry maps column header to cell value. List>? examples; + + /// Decorators applied to this scenario (e.g., `@unitTest`). Set decorators; Scenario( @@ -25,11 +37,18 @@ class Scenario { } } +/// Extension methods for [Scenario]. extension ScenarioX on Scenario { + /// Whether this scenario has the `@unitTest` decorator. bool get isUnitTest => decorators.hasUnitTest; + + /// Whether this scenario has the `@widgetTest` decorator. bool get isWidgetTest => decorators.hasWidgetTest; - /// Resolve whether this is a unit test considering feature-level decorators + /// Resolves whether this is a unit test, considering feature-level decorators. + /// + /// Scenario decorators take precedence. If the scenario has no test type + /// decorator, falls back to [featureDecorators]. bool isUnitTestWithFeature(Set featureDecorators) { if (decorators.hasUnitTest) return true; if (decorators.hasWidgetTest) return false; @@ -37,10 +56,13 @@ extension ScenarioX on Scenario { return false; } + /// The generated class name using the default "Scenario" suffix. String get className => name.toScenarioClassName; + /// The generated class name using a custom [suffix]. String classNameWithSuffix(String suffix) => name.toClassName(suffix); + /// MD5 hash of the scenario's content for change detection. String get getHash { return md5.convert(utf8.encode(toString())).toString(); } diff --git a/lib/src/domain/step.dart b/lib/src/domain/step.dart index 3b7075a..c628d24 100644 --- a/lib/src/domain/step.dart +++ b/lib/src/domain/step.dart @@ -1,9 +1,12 @@ -/// A step is a keyword and a text +/// Represents a single step in a Gherkin scenario. +/// +/// A step has a [keyword] (Given, When, Then, And) and [text] describing +/// the action. Parameters are denoted with angle brackets (e.g., ``). class Step { - /// The keyword of the step + /// The Gherkin keyword: `Given`, `When`, `Then`, or `And`. final String keyword; - /// The text of the step + /// The step text, potentially containing `` placeholders. final String text; Step(this.keyword, this.text); @@ -14,10 +17,16 @@ class Step { } } +/// Extension methods for [Step]. extension StepX on Step { + /// The full step message (keyword + text). String get message => '$keyword $text'; + + /// Converts the step text to a camelCase method name. + /// + /// Parameters like `` are included as part of the name. + /// Non-alphanumeric characters are stripped. String get methodName { - // First, replace parameters with their names var processedText = text; final paramRegex = RegExp(r'<(\w+)>'); final paramMatches = paramRegex.allMatches(text); @@ -26,12 +35,10 @@ extension StepX on Step { processedText = processedText.replaceAll(match.group(0)!, paramName); } - // Split into words and process final words = processedText.replaceAll(RegExp(r'[^a-zA-Z0-9\s]'), ' ').split(' ').where((word) => word.isNotEmpty).toList(); if (words.isEmpty) return ''; - // Convert to camelCase return words[0].toLowerCase() + words .skip(1) diff --git a/lib/src/extensions/list_x.dart b/lib/src/extensions/list_x.dart index a7fb961..7605211 100644 --- a/lib/src/extensions/list_x.dart +++ b/lib/src/extensions/list_x.dart @@ -1,4 +1,6 @@ +/// List utilities. extension ListX on List { + /// Returns the first element matching [test], or `null` if none found. T? firstWhereOrNull(bool Function(T) test) { for (var element in this) { if (test(element)) return element; diff --git a/lib/src/extensions/string_x.dart b/lib/src/extensions/string_x.dart index 36e09ce..486f87e 100644 --- a/lib/src/extensions/string_x.dart +++ b/lib/src/extensions/string_x.dart @@ -1,14 +1,27 @@ +/// String utilities for name conversion in code generation. extension StringX on String { + /// Converts a space-separated string to PascalCase. + /// + /// Example: `"successful login"` -> `"SuccessfulLogin"`. String get name { return split(' ').where((word) => word.isNotEmpty).map((word) => word[0].toUpperCase() + word.substring(1).toLowerCase()).join(''); } + /// Converts to a scenario class name with the default "Scenario" suffix. + /// + /// Example: `"Increment"` -> `"IncrementScenario"`. String get toScenarioClassName => toClassName('Scenario'); + /// Converts to a class name with a custom [suffix]. + /// + /// Example: `"Increment".toClassName("Steps")` -> `"IncrementSteps"`. String toClassName(String suffix) { return '$name$suffix'; } + /// Converts a snake_case string to camelCase. + /// + /// Example: `"first_name"` -> `"firstName"`. String get snakeCaseToCamelCase { final parts = split('_'); if (parts.isEmpty) return this; @@ -16,6 +29,9 @@ extension StringX on String { parts.skip(1).map((word) => word[0].toUpperCase() + word.substring(1).toLowerCase()).join(''); } + /// Converts a space-separated string to snake_case. + /// + /// Example: `"Hello World"` -> `"hello_world"`. String get toSnakeCase { return split(' ').map((word) => word.toLowerCase()).join('_'); } diff --git a/lib/src/infrastructure/builders/scenario_file_builder.dart b/lib/src/infrastructure/builders/scenario_file_builder.dart index 262f2de..db62f61 100644 --- a/lib/src/infrastructure/builders/scenario_file_builder.dart +++ b/lib/src/infrastructure/builders/scenario_file_builder.dart @@ -3,7 +3,16 @@ import '../../domain/feature.dart'; import '../../domain/scenario.dart'; import '../../domain/step.dart'; +/// Generates `.bdd_scenarios.dart` files containing scenario classes. +/// +/// Each scenario in a feature becomes an instance-based class with +/// step methods that users implement. Background steps get their own class. class ScenariosFileBuilder { + /// Builds a full scenario file for all scenarios in [feature]. + /// + /// Includes imports, optional background class, and a class per scenario. + /// [additionalImports] are added after the flutter_test import. + /// [scenarioSuffix] controls the class name suffix (default: "Scenario"). Future buildScenarioFile( Feature feature, { List additionalImports = const [], @@ -39,7 +48,10 @@ class ScenariosFileBuilder { return buffer.toString(); } - /// Build only the specified scenarios (for appending to existing file) + /// Builds only the specified [newScenarios] for appending to an existing file. + /// + /// Used in incremental mode when new scenarios are added to a feature + /// without touching existing implementations. String buildNewScenarios( Feature feature, List newScenarios, { @@ -86,6 +98,9 @@ class ScenariosFileBuilder { } } +/// Extracts method parameters from `` placeholders in step text. +/// +/// Returns a comma-separated parameter list (e.g., `String firstName, String lastName`). String extractMethodParams(String stepText) { final params = []; final regex = RegExp(r'<(\w+)>'); diff --git a/lib/src/infrastructure/builders/test_file_builder.dart b/lib/src/infrastructure/builders/test_file_builder.dart index ce01762..911b8e5 100644 --- a/lib/src/infrastructure/builders/test_file_builder.dart +++ b/lib/src/infrastructure/builders/test_file_builder.dart @@ -4,7 +4,12 @@ import '../../domain/scenario.dart'; import '../../domain/step.dart'; import '../../extensions/string_x.dart'; +/// Generates `.bdd_test.dart` files containing test orchestration code. +/// +/// The test file instantiates scenario classes and calls their step methods. +/// This file is always fully regenerated โ€” it contains no user code. class TestFileBuilder { + /// Builds a test file for all scenarios in [feature]. Future buildTestFile( Feature feature, { String scenarioSuffix = 'Scenario', diff --git a/lib/src/infrastructure/parsers/config_parser.dart b/lib/src/infrastructure/parsers/config_parser.dart index 040a0ad..44c8065 100644 --- a/lib/src/infrastructure/parsers/config_parser.dart +++ b/lib/src/infrastructure/parsers/config_parser.dart @@ -4,6 +4,10 @@ import 'package:yaml/yaml.dart'; import '../../domain/config.dart'; +/// Reads configuration from `.bdd_flutter/config.yaml`. +/// +/// Returns [BDDConfig] with defaults for any missing options. +/// If the config file does not exist, all defaults are used. class ConfigParser { static const String defaultConfigDir = '.bdd_flutter'; static const String defaultConfigFile = '$defaultConfigDir/config.yaml'; @@ -12,6 +16,7 @@ class ConfigParser { ConfigParser({String? configFile}) : configFile = configFile ?? defaultConfigFile; + /// Loads the config file and returns a [BDDConfig]. Future loadConfig() async { final file = File(configFile); diff --git a/lib/src/infrastructure/parsers/feature_parser.dart b/lib/src/infrastructure/parsers/feature_parser.dart index c2c0967..2b94c9f 100644 --- a/lib/src/infrastructure/parsers/feature_parser.dart +++ b/lib/src/infrastructure/parsers/feature_parser.dart @@ -7,9 +7,13 @@ import '../../domain/feature.dart'; import '../../domain/scenario.dart'; import '../../domain/step.dart'; +/// Parses Gherkin `.feature` files into [Feature] domain models. +/// +/// Handles Feature/Scenario/Background blocks, Given/When/Then/And steps, +/// Examples tables, and `@unitTest`/`@widgetTest` decorators. class FeatureParser { + /// Parses a `.feature` file at [filePath] and returns a [Feature] model. Future parseFeature(String filePath) async { - // read files and parse the content final file = File(filePath); final fileContent = await file.readAsString(); @@ -128,6 +132,7 @@ class FeatureParser { } } +/// Accumulates example table headers and rows during parsing. class ExampleContent { List headers = []; List> values = []; diff --git a/lib/src/infrastructure/parsers/manifest_parser.dart b/lib/src/infrastructure/parsers/manifest_parser.dart index da1ffe9..c0c3e2d 100644 --- a/lib/src/infrastructure/parsers/manifest_parser.dart +++ b/lib/src/infrastructure/parsers/manifest_parser.dart @@ -4,6 +4,10 @@ import 'package:yaml/yaml.dart'; import '../../domain/manifest.dart'; +/// Reads and writes the manifest file (`.bdd_flutter/manifest.yaml`). +/// +/// The manifest tracks generated features and their scenario hashes, +/// enabling incremental builds that skip unchanged content. class ManifestParser { static const String defaultManifestDir = '.bdd_flutter'; static const String defaultManifestFile = '$defaultManifestDir/manifest.yaml'; @@ -15,6 +19,9 @@ class ManifestParser { : manifestDir = manifestDir ?? defaultManifestDir, manifestFile = manifestFile ?? defaultManifestFile; + /// Loads the manifest file and returns a [Manifest]. + /// + /// Returns an empty manifest if the file does not exist. Future loadManifest() async { final file = File(manifestFile); @@ -65,6 +72,9 @@ class ManifestParser { ); } + /// Saves the [manifest] to the manifest file. + /// + /// Creates the `.bdd_flutter` directory if it does not exist. Future saveManifest(Manifest manifest) async { final dir = Directory(manifestDir); if (!dir.existsSync()) { @@ -91,6 +101,9 @@ class ManifestParser { await File(manifestFile).writeAsString(buffer.toString()); } + /// Finds a feature entry in the [manifest] by file [path]. + /// + /// Returns `null` if the feature is not tracked. ManifestFeature? findFeature(Manifest manifest, String path) { for (final feature in manifest.features) { if (feature.path == path) return feature; diff --git a/lib/src/presentation/cli/bbd_cli.dart b/lib/src/presentation/cli/bbd_cli.dart index ff268df..6226261 100644 --- a/lib/src/presentation/cli/bbd_cli.dart +++ b/lib/src/presentation/cli/bbd_cli.dart @@ -5,6 +5,12 @@ import '../../infrastructure/parsers/config_parser.dart'; import '../controllers/bdd_controller.dart'; import '../reporter/bdd_test_runner.dart'; +/// CLI entry point for `dart run bdd_flutter`. +/// +/// Supports commands: +/// - `build` โ€” generate test files from `.feature` files +/// - `build --force` โ€” force regenerate all files +/// - `test` โ€” run BDD tests with formatted report class BDDCLI { final BDDController _bddController; final ConfigParser _configParser; @@ -15,6 +21,7 @@ class BDDCLI { }) : _bddController = bddController ?? BDDController(), _configParser = configParser ?? ConfigParser(); + /// Parses [arguments] and executes the corresponding command. Future run(List arguments) async { if (arguments.isEmpty) { _printUsage(); diff --git a/lib/src/presentation/controllers/bdd_controller.dart b/lib/src/presentation/controllers/bdd_controller.dart index 1c06583..b211821 100644 --- a/lib/src/presentation/controllers/bdd_controller.dart +++ b/lib/src/presentation/controllers/bdd_controller.dart @@ -11,6 +11,11 @@ import '../../infrastructure/parsers/manifest_parser.dart'; import '../../infrastructure/builders/scenario_file_builder.dart'; import '../../infrastructure/builders/test_file_builder.dart'; +/// Orchestrates the BDD test generation pipeline. +/// +/// Loads config and manifest, parses feature files, generates scenario +/// and test files, and updates the manifest. Supports incremental and +/// force generation modes. class BDDController { final FeatureParser _featureParser; final ScenariosFileBuilder _scenarioFileBuilder; @@ -30,6 +35,11 @@ class BDDController { _configParser = configParser ?? ConfigParser(), _manifestParser = manifestParser ?? ManifestParser(); + /// Generates test files from `.feature` files. + /// + /// In incremental mode (default), new scenarios are appended to existing + /// scenario files and test files are regenerated. With [options.force], + /// all files are regenerated from scratch. Future generateFeatureTestCases({BuildOptions options = const BuildOptions()}) async { final config = await _configParser.loadConfig(); final manifest = await _manifestParser.loadManifest(); diff --git a/lib/src/presentation/reporter/bdd_report_formatter.dart b/lib/src/presentation/reporter/bdd_report_formatter.dart index f1124f9..9e5a487 100644 --- a/lib/src/presentation/reporter/bdd_report_formatter.dart +++ b/lib/src/presentation/reporter/bdd_report_formatter.dart @@ -6,6 +6,9 @@ const String _cyan = '\x1B[36m'; const String _dim = '\x1B[2m'; const String _reset = '\x1B[0m'; +/// Formats BDD test results into a colored Feature/Scenario report. +/// +/// Used by [BDDTestRunner] to display results from `flutter test --machine`. class BDDReportFormatter { final List features = []; DateTime? _startTime; @@ -93,6 +96,7 @@ class BDDReportFormatter { } } +/// Aggregated test results for a single feature. class FeatureReport { final String name; final List scenarios = []; @@ -100,6 +104,7 @@ class FeatureReport { FeatureReport({required this.name}); } +/// The result of a single scenario test execution. class ScenarioResult { final String name; final bool passed; diff --git a/lib/src/presentation/reporter/bdd_test_runner.dart b/lib/src/presentation/reporter/bdd_test_runner.dart index 3ada73e..47fe1ac 100644 --- a/lib/src/presentation/reporter/bdd_test_runner.dart +++ b/lib/src/presentation/reporter/bdd_test_runner.dart @@ -3,6 +3,11 @@ import 'dart:io'; import 'bdd_report_formatter.dart'; +/// Runs BDD tests via `flutter test --machine` and formats the output. +/// +/// Finds all `.bdd_test.dart` files, runs them through Flutter's test runner, +/// parses the JSON event stream, and prints a BDD-formatted report grouped +/// by Feature with pass/fail per Scenario. class BDDTestRunner { final BDDReportFormatter _formatter; final String testDir; @@ -12,6 +17,9 @@ class BDDTestRunner { this.testDir = 'test/', }) : _formatter = formatter ?? BDDReportFormatter(); + /// Runs all `.bdd_test.dart` files and prints the BDD report. + /// + /// Returns the exit code from `flutter test` (0 = all passed). Future run() async { _formatter.start(); diff --git a/lib/src/presentation/reporter/test_reporter.dart b/lib/src/presentation/reporter/test_reporter.dart deleted file mode 100644 index 0774ba0..0000000 --- a/lib/src/presentation/reporter/test_reporter.dart +++ /dev/null @@ -1,171 +0,0 @@ -import 'dart:io'; - -const String red = '\x1B[31m'; -const String green = '\x1B[32m'; -const String reset = '\x1B[0m'; - -/// A class that handles test reporting for BDD tests -class BDDTestReporter { - /// The name of the feature being tested - final String featureName; - - /// Whether to export the test results to a file - final bool exportToFile; - - /// Whether to rethrow the error after reporting it - /// in production code, this should be true - /// so that it wont effect the test run on CICD - final bool shouldRethrow; - - final List _scenarios = []; - - final Function(String)? logCallback; - - FeatureTestOverview? _overview; - DateTime? _startTime; - - StringBuffer? _reportBuffer; - - BDDTestReporter({ - required this.featureName, - this.exportToFile = false, - this.shouldRethrow = false, - this.logCallback, - }); - - void startScenario(String name) { - _scenarios.add(ScenarioReport(name: name.noSpace, steps: {})); - } - - void reportStep(String name, bool status, String? error) { - _scenarios.last.addStep( - StepReport(name: name, status: status, error: error), - ); - } - - void _log(String message) { - if (logCallback != null) { - logCallback!(message); - } else { - stdout.writeln(message); - } - } - - void printReport() { - _log(_reportBuffer?.toString() ?? ''); - } - - void testStarted() { - _startTime = DateTime.now(); - } - - void testFinished() { - final totalTime = DateTime.now().difference(_startTime!); - _overview = FeatureTestOverview( - featureName: featureName, - totalScenarios: _scenarios.length, - totalSteps: _scenarios.fold(0, (sum, scenario) => sum + scenario.steps.length), - totalPassed: _scenarios.fold(0, (sum, scenario) => sum + scenario.steps.values.where((step) => step.status).length), - totalFailed: _scenarios.fold(0, (sum, scenario) => sum + scenario.steps.values.where((step) => !step.status).length), - totalTime: totalTime, - ); - _generateReport(); - } - - Future guard(Future Function() step, String name) async { - try { - await step(); - reportStep(name, true, null); - } catch (e) { - reportStep(name, false, e.toString()); - if (shouldRethrow) { - rethrow; - } - } - } - - void _generateReport() { - _reportBuffer = StringBuffer(); - - _reportBuffer!.writeln(_overview?.toOverviewString() ?? ''); - _reportBuffer!.writeln('--------------------------------'); - for (var scenario in _scenarios) { - _reportBuffer!.writeln('\tScenario: ${scenario.name}'); - for (var step in scenario.steps.values) { - _reportBuffer!.writeln('\t\t${step.status ? green : red}${step.name}: ${step.status ? 'โœ“' : 'โœ—'}$reset'); - } - } - final errorSteps = _scenarios.expand((scenario) => scenario.steps.values.where((step) => step.error != null).map((step) => step)).toList(); - if (errorSteps.isNotEmpty) { - _reportBuffer!.writeln('--------------------------------'); - _reportBuffer!.writeln('ERROR:'); - //get all errors - for (var step in errorSteps) { - _reportBuffer!.writeln('\t\t${step.name}: ${step.error}'); - } - } - } - - void saveReportToFile([String dir = '/']) { - if (_reportBuffer == null || _reportBuffer!.isEmpty) { - return; - } - - final fileName = '${featureName}_report_${DateTime.now().toIso8601String()}.txt'; - final filePath = '$dir/$fileName'; - final file = File(filePath); - file.writeAsStringSync(_reportBuffer?.toString() ?? ''); - _log('Report saved to $filePath'); - } -} - -class ScenarioReport { - final NoSpaceString name; - final Map steps; - - ScenarioReport({required this.name, required this.steps}); - - void addStep(StepReport step) { - steps[step.name] = step; - } -} - -class StepReport { - final NoSpaceString name; - final bool status; - final String? error; - - StepReport({required this.name, required this.status, this.error}); -} - -typedef NoSpaceString = String; - -extension NoSpaceStringX on String { - NoSpaceString get noSpace => replaceAll(' ', '_'); -} - -class FeatureTestOverview { - final String featureName; - final int totalScenarios; - final int totalSteps; - final int totalPassed; - final int totalFailed; - final Duration totalTime; - FeatureTestOverview({ - required this.featureName, - required this.totalScenarios, - required this.totalSteps, - required this.totalPassed, - required this.totalFailed, - required this.totalTime, - }); - - String toOverviewString() { - return 'Feature: $featureName\n' - 'Total Scenarios: $totalScenarios\n' - 'Total Steps: $totalSteps\n' - 'Total Passed: $totalPassed\n' - 'Total Failed: $totalFailed\n' - 'Total Time: ${totalTime.inMilliseconds}ms\n'; - } -} From b05cfec0d41db6e4f5d428cc59c6a43ecff8f45c Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Tue, 31 Mar 2026 23:31:30 -0600 Subject: [PATCH 21/24] plan mcp --- plan/mcp.md | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 plan/mcp.md diff --git a/plan/mcp.md b/plan/mcp.md new file mode 100644 index 0000000..4435397 --- /dev/null +++ b/plan/mcp.md @@ -0,0 +1,113 @@ +# MCP Server Implementation Plan for bdd_flutter + +## Overview + +Add an `mcp` command to the existing CLI that starts an MCP server over **stdio**. AI agents can then parse features, generate tests, validate syntax, and get a syntax guide โ€” all through MCP tools. + +## New Dependency + +```yaml +# pubspec.yaml +dependencies: + dart_mcp: ^0.1.0 # or use raw JSON-RPC over stdin/stdout (zero deps) +``` + +> If `dart_mcp` is too heavy or unstable, we can implement the MCP protocol manually with just `dart:io` + `dart:convert` (stdin/stdout JSON-RPC). It's ~200 lines of boilerplate. + +## Files to Create/Modify + +| File | Action | Purpose | +|------|--------|---------| +| `lib/src/presentation/mcp/bdd_mcp_server.dart` | **New** | MCP server class, registers tools | +| `lib/src/presentation/cli/bbd_cli.dart` | **Edit** | Add `mcp` command to switch statement | +| `bin/bdd_flutter.dart` | No change | Already delegates to CLI | +| `pubspec.yaml` | **Edit** | Add MCP dependency (if using a package) | + +## MCP Tools to Expose + +### 1. `bdd_parse_feature` +- **Input**: `{ "file_path": "test/login/login.feature" }` or `{ "content": "Feature: Login\n..." }` +- **Output**: JSON representation of the parsed Feature model (scenarios, steps, decorators) +- **Uses**: `FeatureParser.parseFeature()` โ€” already exists + +### 2. `bdd_build_tests` +- **Input**: `{ "file_path": "test/login/login.feature", "force": false }` +- **Output**: Success message with paths of generated files +- **Uses**: `BDDController.generateFeatureTestCases()` โ€” already exists + +### 3. `bdd_validate_feature` +- **Input**: `{ "content": "Feature: Login\n..." }` or `{ "file_path": "..." }` +- **Output**: List of errors/warnings (missing Feature:, empty scenarios, etc.) +- **Uses**: New lightweight validation on top of `FeatureParser` + +### 4. `bdd_preview_generated_code` +- **Input**: `{ "content": "Feature: Login\n..." }` or `{ "file_path": "..." }` +- **Output**: The generated `.bdd_scenarios.dart` and `.bdd_test.dart` content (without writing files) +- **Uses**: `ScenariosFileBuilder` + `TestFileBuilder` โ€” already exist + +### 5. `bdd_get_syntax_guide` +- **Input**: none +- **Output**: Gherkin syntax guide with bdd_flutter-specific decorators and examples +- **Uses**: Returns a static string (embedded documentation) + +## Architecture + +``` +bin/bdd_flutter.dart + โ†’ BDDCLI.run(['mcp']) + โ†’ BDDMcpServer.start() # NEW + โ”œโ”€ listens on stdin (JSON-RPC) + โ”œโ”€ registers 5 tools + โ””โ”€ responds on stdout +``` + +The server reuses existing classes โ€” no duplication of parsing/building logic. + +## How Users Configure It + +**In Claude Code** (`~/.claude/settings.json`): + +```json +{ + "mcpServers": { + "bdd_flutter": { + "command": "dart", + "args": ["run", "bdd_flutter", "mcp"] + } + } +} +``` + +Or per-project in `.claude/settings.local.json` (same format). + +> **Note**: The server runs in the current working directory by default (usually the project root). If needed, you can add `"cwd": "/path/to/project"` to override. + +## How an AI Agent Uses It + +Once configured, Claude (or any MCP-compatible agent) can: + +``` +1. User: "Write a BDD test for user registration" + +2. Agent calls bdd_get_syntax_guide โ†’ learns the .feature syntax + +3. Agent writes a .feature file: + Feature: User Registration + Scenario: Successful registration + Given I am on the registration page + When I enter valid credentials + Then I should see the dashboard + +4. Agent calls bdd_preview_generated_code with the content + โ†’ sees the generated scenario class + test file + +5. Agent calls bdd_build_tests to generate the actual files + +6. Agent fills in the TODO stubs in the .bdd_scenarios.dart file +``` + +## Estimated Effort + +- **1 new file** (~150-250 lines): `bdd_mcp_server.dart` +- **2 small edits**: CLI + pubspec +- The protocol handling is the only new code; all domain logic is reused From 330df52f575af0e26f908efa1799b426ec738d22 Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Tue, 31 Mar 2026 23:35:51 -0600 Subject: [PATCH 22/24] feat: release major version 1.0.0 with standalone CLI, incremental generation, and architectural refactor --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70974ea..8f1714d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +## 1.0.0 + +### BREAKING CHANGES + +- Removed `build_runner` dependency entirely โ€” use `dart run bdd_flutter build` instead +- Removed `--no-widget-test` and `--new-only` CLI flags +- Configuration moved from `build.yaml` to `.bdd_flutter/config.yaml` +- Generated file extensions changed from `.bdd_scenarios.g.dart` to `.bdd_scenarios.dart` and `.bdd_test.g.dart` to `.bdd_test.dart` + +### Features + +- New standalone CLI: `dart run bdd_flutter build` and `dart run bdd_flutter test` +- Incremental test generation with manifest tracking (only regenerates changed scenarios) +- `--force` flag to regenerate all files +- `test` command with formatted Feature/Scenario report output +- Custom test directory via `test_dir` config option +- Custom scenario class suffix via `scenario_suffix` config option (e.g., `Steps` instead of `Scenario`) +- Additional imports support via `additional_imports` config option +- Background steps support +- `@unitTest` / `@widgetTest` decorators at feature and scenario level +- Scenario-level Examples tables for parameterized tests + +### Improvements + +- Clean Architecture restructure (domain, infrastructure, presentation layers) +- Instance-based scenario classes (supports `late` fields for shared state) +- Comprehensive documentation across all layers + ## 0.3.0 - remove `build_runner` dependency diff --git a/pubspec.yaml b/pubspec.yaml index bb0f7aa..02a7557 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: bdd_flutter description: A Flutter package for Behavior-Driven Development (BDD) testing. -version: 0.0.1 +version: 1.0.0 homepage: https://github.com/samderlust/bdd_flutter environment: From 6b65a7c5d864a7f53caa35edd8ca08e587e89dd8 Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Tue, 31 Mar 2026 23:36:27 -0600 Subject: [PATCH 23/24] chore: remove obsolete flutter-rules.mdc cursor rules file --- .cursor/rules/flutter-rules.mdc | 134 -------------------------------- 1 file changed, 134 deletions(-) delete mode 100644 .cursor/rules/flutter-rules.mdc diff --git a/.cursor/rules/flutter-rules.mdc b/.cursor/rules/flutter-rules.mdc deleted file mode 100644 index 181a4a2..0000000 --- a/.cursor/rules/flutter-rules.mdc +++ /dev/null @@ -1,134 +0,0 @@ ---- -description: -globs: -alwaysApply: false ---- - -You are a senior Dart programmer with experience in the Flutter framework and a preference for clean programming and design patterns. - -Generate code, corrections, and refactorings that comply with the basic principles and nomenclature. - -## Dart General Guidelines - -### Basic Principles - -- Use English for all code and documentation. -- Always declare the type of each variable and function (parameters and return value). - - Avoid using any. - - Create necessary types. -- Don't leave blank lines within a function. -- One export per file. - -### Nomenclature - -- Use PascalCase for classes. -- Use camelCase for variables, functions, and methods. -- Use underscores_case for file and directory names. -- Use UPPERCASE for environment variables. - - Avoid magic numbers and define constants. -- Start each function with a verb. -- Use verbs for boolean variables. Example: isLoading, hasError, canDelete, etc. -- Use complete words instead of abbreviations and correct spelling. - - Except for standard abbreviations like API, URL, etc. - - Except for well-known abbreviations: - - i, j for loops - - err for errors - - ctx for contexts - - req, res, next for middleware function parameters - -### Functions - -- In this context, what is understood as a function will also apply to a method. -- Write short functions with a single purpose. Less than 20 instructions. -- Name functions with a verb and something else. - - If it returns a boolean, use isX or hasX, canX, etc. - - If it doesn't return anything, use executeX or saveX, etc. -- Avoid nesting blocks by: - - Early checks and returns. - - Extraction to utility functions. -- Use higher-order functions (map, filter, reduce, etc.) to avoid function nesting. - - Use arrow functions for simple functions (less than 3 instructions). - - Use named functions for non-simple functions. -- Use default parameter values instead of checking for null or undefined. -- Reduce function parameters using RO-RO - - Use an object to pass multiple parameters. - - Use an object to return results. - - Declare necessary types for input arguments and output. -- Use a single level of abstraction. - -### Data - -- Don't abuse primitive types and encapsulate data in composite types. -- Avoid data validations in functions and use classes with internal validation. -- Prefer immutability for data. - - Use readonly for data that doesn't change. - - Use as const for literals that don't change. - -### Classes - -- Follow SOLID principles. -- Prefer composition over inheritance. -- Declare interfaces to define contracts. -- Write small classes with a single purpose. - - Less than 200 instructions. - - Less than 10 public methods. - - Less than 10 properties. - -### Exceptions - -- Use exceptions to handle errors you don't expect. -- If you catch an exception, it should be to: - - Fix an expected problem. - - Add context. - - Otherwise, use a global handler. - -### Testing - -- Follow the Arrange-Act-Assert convention for tests. -- Name test variables clearly. - - Follow the convention: inputX, mockX, actualX, expectedX, etc. -- Write unit tests for each public function. - - Use test doubles to simulate dependencies. - - Except for third-party dependencies that are not expensive to execute. -- Write acceptance tests for each module. - - Follow the Given-When-Then convention. - -## Specific to Flutter - -### Basic Principles - -- Use clean architecture - - see modules if you need to organize code into modules - - see controllers if you need to organize code into controllers - - see services if you need to organize code into services - - see repositories if you need to organize code into repositories - - see entities if you need to organize code into entities -- Use repository pattern for data persistence - - see cache if you need to cache data -- Use controller pattern for business logic with Riverpod -- Use Riverpod to manage state - - see keepAlive if you need to keep the state alive -- Use freezed to manage UI states -- Controller always takes methods as input and updates the UI state that effects the UI -- Use getIt to manage dependencies - - Use singleton for services and repositories - - Use factory for use cases - - Use lazy singleton for controllers -- Use AutoRoute to manage routes - - Use extras to pass data between pages -- Use extensions to manage reusable code -- Use ThemeData to manage themes -- Use AppLocalizations to manage translations -- Use constants to manage constants values -- When a widget tree becomes too deep, it can lead to longer build times and increased memory usage. Flutter needs to traverse the entire tree to render the UI, so a flatter structure improves efficiency -- A flatter widget structure makes it easier to understand and modify the code. Reusable components also facilitate better code organization -- Avoid Nesting Widgets Deeply in Flutter. Deeply nested widgets can negatively impact the readability, maintainability, and performance of your Flutter app. Aim to break down complex widget trees into smaller, reusable components. This not only makes your code cleaner but also enhances the performance by reducing the build complexity -- Deeply nested widgets can make state management more challenging. By keeping the tree shallow, it becomes easier to manage state and pass data between widgets -- Break down large widgets into smaller, focused widgets -- Utilize const constructors wherever possible to reduce rebuilds - -### Testing - -- Use the standard widget testing for flutter -- Use integration tests for each api module. - \ No newline at end of file From 4aef4bc8a7217407a5273b27e5b5ff451641622c Mon Sep 17 00:00:00 2001 From: Sang Nguyen Date: Tue, 31 Mar 2026 23:38:35 -0600 Subject: [PATCH 24/24] format --- example/lib/src/login_screen.dart | 18 ++------- .../calculator/calculator.bdd_scenarios.dart | 7 ---- .../test/calculator/calculator.bdd_test.dart | 40 +++++++++---------- .../test/counter/counter.bdd_scenarios.dart | 4 -- example/test/counter/counter.bdd_test.dart | 14 +++---- .../test/features/feature1.bdd_scenarios.dart | 6 --- .../test/features/feature2.bdd_scenarios.dart | 3 -- .../test/features/feature3.bdd_scenarios.dart | 2 - example/test/login/login.bdd_scenarios.dart | 28 +++++-------- example/test/sample/sample.bdd_scenarios.dart | 6 --- example/test/sample/sample.bdd_test.dart | 36 ++++++++--------- lib/src/domain/step.dart | 3 +- lib/src/extensions/string_x.dart | 5 ++- .../parsers/feature_parser.dart | 6 ++- .../controllers/bdd_controller.dart | 4 +- .../reporter/bdd_report_formatter.dart | 8 +--- .../reporter/bdd_test_runner.dart | 9 +---- test/builders/scenario_file_builder_test.dart | 9 ++++- test/builders/test_file_builder_test.dart | 4 +- 19 files changed, 84 insertions(+), 128 deletions(-) diff --git a/example/lib/src/login_screen.dart b/example/lib/src/login_screen.dart index 9c6fd0f..5ff62ff 100644 --- a/example/lib/src/login_screen.dart +++ b/example/lib/src/login_screen.dart @@ -34,10 +34,7 @@ class _LoginScreenState extends State { children: [ Text('Welcome, ${auth.user!.name}!'), const SizedBox(height: 16), - ElevatedButton( - onPressed: auth.logout, - child: const Text('Logout'), - ), + ElevatedButton(onPressed: auth.logout, child: const Text('Logout')), ], ), ); @@ -48,12 +45,8 @@ class _LoginScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (auth.error != null) - Text(auth.error!, style: const TextStyle(color: Colors.red)), - TextField( - controller: _emailController, - decoration: const InputDecoration(labelText: 'Email'), - ), + if (auth.error != null) Text(auth.error!, style: const TextStyle(color: Colors.red)), + TextField(controller: _emailController, decoration: const InputDecoration(labelText: 'Email')), TextField( controller: _passwordController, decoration: const InputDecoration(labelText: 'Password'), @@ -64,10 +57,7 @@ class _LoginScreenState extends State { const CircularProgressIndicator() else ElevatedButton( - onPressed: () => auth.login( - _emailController.text, - _passwordController.text, - ), + onPressed: () => auth.login(_emailController.text, _passwordController.text), child: const Text('Login'), ), ], diff --git a/example/test/calculator/calculator.bdd_scenarios.dart b/example/test/calculator/calculator.bdd_scenarios.dart index dec73f9..4fc99e4 100644 --- a/example/test/calculator/calculator.bdd_scenarios.dart +++ b/example/test/calculator/calculator.bdd_scenarios.dart @@ -16,7 +16,6 @@ class AddTwoNumbersScenario { Future theResultShouldBe3(WidgetTester tester) async { // TODO: Implement Then the result should be 3 } - } class SubtractTwoNumbersScenario { @@ -35,7 +34,6 @@ class SubtractTwoNumbersScenario { Future theResultShouldBe2(WidgetTester tester) async { // TODO: Implement Then the result should be 2 } - } class SubtractTwoNumbers2Scenario { @@ -54,7 +52,6 @@ class SubtractTwoNumbers2Scenario { Future theResultShouldBe2(WidgetTester tester) async { // TODO: Implement Then the result should be -2 } - } class MultiplyTwoNumbersScenario { @@ -73,7 +70,6 @@ class MultiplyTwoNumbersScenario { Future theResultShouldBe6(WidgetTester tester) async { // TODO: Implement Then the result should be 6 } - } class DivideTwoNumbersScenario { @@ -92,7 +88,6 @@ class DivideTwoNumbersScenario { Future theResultShouldBeResult(WidgetTester tester, String result) async { // TODO: Implement Then the result should be } - } class DivideTwoNumbers2Scenario { @@ -107,6 +102,4 @@ class DivideTwoNumbers2Scenario { Future theResultShouldBeResult(WidgetTester tester, String result) async { // TODO: Implement Then the result should be } - } - diff --git a/example/test/calculator/calculator.bdd_test.dart b/example/test/calculator/calculator.bdd_test.dart index 2c69927..2e7ca75 100644 --- a/example/test/calculator/calculator.bdd_test.dart +++ b/example/test/calculator/calculator.bdd_test.dart @@ -55,36 +55,36 @@ void main() { final scenario = DivideTwoNumbersScenario(); //Scenario: Divide two numbers final examples = [ - {'number1': '10','number2': '2','result': '5',}, - {'number1': '10','number2': '1','result': '10',}, - {'number1': '10','number2': '10','result': '1',}, + {'number1': '10', 'number2': '2', 'result': '5'}, + {'number1': '10', 'number2': '1', 'result': '10'}, + {'number1': '10', 'number2': '10', 'result': '1'}, ]; for (var example in examples) { - // Given I have the number - await scenario.iHaveTheNumberNumber1(tester, example['number1']!); - // And I have the number - await scenario.iHaveTheNumberNumber2(tester, example['number2']!); - // When I divide them - await scenario.iDivideThem(tester); - // Then the result should be - await scenario.theResultShouldBeResult(tester, example['result']!); + // Given I have the number + await scenario.iHaveTheNumberNumber1(tester, example['number1']!); + // And I have the number + await scenario.iHaveTheNumberNumber2(tester, example['number2']!); + // When I divide them + await scenario.iDivideThem(tester); + // Then the result should be + await scenario.theResultShouldBeResult(tester, example['result']!); } }); testWidgets('Divide two numbers2', (tester) async { final scenario = DivideTwoNumbers2Scenario(); //Scenario: Divide two numbers2 final examples = [ - {'number1': '10','number2': '2','result': '5',}, - {'number1': '10','number2': '1','result': '10',}, - {'number1': '10','number2': '10','result': '1',}, + {'number1': '10', 'number2': '2', 'result': '5'}, + {'number1': '10', 'number2': '1', 'result': '10'}, + {'number1': '10', 'number2': '10', 'result': '1'}, ]; for (var example in examples) { - // Given I have and - await scenario.iHaveNumber1AndNumber2(tester, example['number1']!, example['number2']!); - // When I divide them to each other - await scenario.iDivideThemToEachOther(tester); - // Then the result should be - await scenario.theResultShouldBeResult(tester, example['result']!); + // Given I have and + await scenario.iHaveNumber1AndNumber2(tester, example['number1']!, example['number2']!); + // When I divide them to each other + await scenario.iDivideThemToEachOther(tester); + // Then the result should be + await scenario.theResultShouldBeResult(tester, example['result']!); } }); }); diff --git a/example/test/counter/counter.bdd_scenarios.dart b/example/test/counter/counter.bdd_scenarios.dart index d9d6678..4f9dead 100644 --- a/example/test/counter/counter.bdd_scenarios.dart +++ b/example/test/counter/counter.bdd_scenarios.dart @@ -4,7 +4,6 @@ class CounterBackground { Future iHaveACounterWithValue0() async { // TODO: Implement Given I have a counter with value 0 } - } class IncrementScenario { @@ -15,7 +14,6 @@ class IncrementScenario { Future theCounterShouldHaveValueExpectedValue(WidgetTester tester, String expectedValue) async { // TODO: Implement Then the counter should have value } - } class DecrementScenario { @@ -26,6 +24,4 @@ class DecrementScenario { Future theCounterShouldHaveValue1(WidgetTester tester) async { // TODO: Implement Then the counter should have value -1 } - } - diff --git a/example/test/counter/counter.bdd_test.dart b/example/test/counter/counter.bdd_test.dart index 760a399..2aeebbb 100644 --- a/example/test/counter/counter.bdd_test.dart +++ b/example/test/counter/counter.bdd_test.dart @@ -10,15 +10,15 @@ void main() { await background.iHaveACounterWithValue0(); //Scenario: Increment final examples = [ - {'value': '1','expectedValue': '1',}, - {'value': '2','expectedValue': '2',}, - {'value': '3','expectedValue': '3',}, + {'value': '1', 'expectedValue': '1'}, + {'value': '2', 'expectedValue': '2'}, + {'value': '3', 'expectedValue': '3'}, ]; for (var example in examples) { - // When I increment the counter by - await scenario.iIncrementTheCounterByValue(tester, example['value']!); - // Then the counter should have value - await scenario.theCounterShouldHaveValueExpectedValue(tester, example['expectedValue']!); + // When I increment the counter by + await scenario.iIncrementTheCounterByValue(tester, example['value']!); + // Then the counter should have value + await scenario.theCounterShouldHaveValueExpectedValue(tester, example['expectedValue']!); } }); testWidgets('Decrement', (tester) async { diff --git a/example/test/features/feature1.bdd_scenarios.dart b/example/test/features/feature1.bdd_scenarios.dart index 0c15c3c..aba6d79 100644 --- a/example/test/features/feature1.bdd_scenarios.dart +++ b/example/test/features/feature1.bdd_scenarios.dart @@ -12,7 +12,6 @@ class Scenario1Scenario { Future theCounterShouldHaveValue1(WidgetTester tester) async { // TODO: Implement Then the counter should have value 1 } - } class Scenario2Scenario { @@ -27,7 +26,6 @@ class Scenario2Scenario { Future theCounterShouldHaveValue1(WidgetTester tester) async { // TODO: Implement Then the counter should have value 1 } - } class Scenario3Scenario { @@ -42,7 +40,6 @@ class Scenario3Scenario { Future theCounterShouldHaveValue1(WidgetTester tester) async { // TODO: Implement Then the counter should have value 1 } - } class Scenario4Scenario { @@ -57,7 +54,6 @@ class Scenario4Scenario { Future theCounterShouldHaveValue1(WidgetTester tester) async { // TODO: Implement Then the counter should have value 1 } - } class Scenario5Scenario { @@ -72,6 +68,4 @@ class Scenario5Scenario { Future theCounterShouldHaveValue1(WidgetTester tester) async { // TODO: Implement Then the counter should have value 1 } - } - diff --git a/example/test/features/feature2.bdd_scenarios.dart b/example/test/features/feature2.bdd_scenarios.dart index 7459d9c..361ac4a 100644 --- a/example/test/features/feature2.bdd_scenarios.dart +++ b/example/test/features/feature2.bdd_scenarios.dart @@ -12,7 +12,6 @@ class Scenario2Scenario { Future theCounterShouldHaveValue1(WidgetTester tester) async { // TODO: Implement Then the counter should have value 1 } - } class Scenario3Scenario { @@ -27,6 +26,4 @@ class Scenario3Scenario { Future theCounterShouldHaveValue1(WidgetTester tester) async { // TODO: Implement Then the counter should have value 1 } - } - diff --git a/example/test/features/feature3.bdd_scenarios.dart b/example/test/features/feature3.bdd_scenarios.dart index 682dd72..658320b 100644 --- a/example/test/features/feature3.bdd_scenarios.dart +++ b/example/test/features/feature3.bdd_scenarios.dart @@ -12,6 +12,4 @@ class Scenario3Scenario { Future theCounterShouldHaveValue1(WidgetTester tester) async { // TODO: Implement Then the counter should have value 1 } - } - diff --git a/example/test/login/login.bdd_scenarios.dart b/example/test/login/login.bdd_scenarios.dart index e3c6013..80898e0 100644 --- a/example/test/login/login.bdd_scenarios.dart +++ b/example/test/login/login.bdd_scenarios.dart @@ -18,16 +18,14 @@ class SuccessfulLoginScenario { } Future theMockReturnsASuccessfulLoginForTestTestCom(WidgetTester tester) async { - when(() => mockAuthRepo.login('test@test.com', 'password')) - .thenAnswer((_) async => User(name: 'Test User', email: 'test@test.com')); + when( + () => mockAuthRepo.login('test@test.com', 'password'), + ).thenAnswer((_) async => User(name: 'Test User', email: 'test@test.com')); } Future iPumpTheLoginScreenWithProviders(WidgetTester tester) async { await tester.pumpWidget( - ChangeNotifierProvider.value( - value: authProvider, - child: const MaterialApp(home: LoginScreen()), - ), + ChangeNotifierProvider.value(value: authProvider, child: const MaterialApp(home: LoginScreen())), ); } @@ -59,16 +57,12 @@ class FailedLoginShowsErrorScenario { } Future theMockThrowsAnErrorForLogin(WidgetTester tester) async { - when(() => mockAuthRepo.login(any(), any())) - .thenThrow(Exception('Invalid credentials')); + when(() => mockAuthRepo.login(any(), any())).thenThrow(Exception('Invalid credentials')); } Future iPumpTheLoginScreenWithProviders(WidgetTester tester) async { await tester.pumpWidget( - ChangeNotifierProvider.value( - value: authProvider, - child: const MaterialApp(home: LoginScreen()), - ), + ChangeNotifierProvider.value(value: authProvider, child: const MaterialApp(home: LoginScreen())), ); } @@ -100,8 +94,9 @@ class LogoutAfterLoginScenario { } Future theMockReturnsASuccessfulLoginForTestTestCom(WidgetTester tester) async { - when(() => mockAuthRepo.login('test@test.com', 'password')) - .thenAnswer((_) async => User(name: 'Test User', email: 'test@test.com')); + when( + () => mockAuthRepo.login('test@test.com', 'password'), + ).thenAnswer((_) async => User(name: 'Test User', email: 'test@test.com')); } Future theMockAllowsLogout(WidgetTester tester) async { @@ -110,10 +105,7 @@ class LogoutAfterLoginScenario { Future iPumpTheLoginScreenWithProviders(WidgetTester tester) async { await tester.pumpWidget( - ChangeNotifierProvider.value( - value: authProvider, - child: const MaterialApp(home: LoginScreen()), - ), + ChangeNotifierProvider.value(value: authProvider, child: const MaterialApp(home: LoginScreen())), ); } diff --git a/example/test/sample/sample.bdd_scenarios.dart b/example/test/sample/sample.bdd_scenarios.dart index 944e307..c9d2f7c 100644 --- a/example/test/sample/sample.bdd_scenarios.dart +++ b/example/test/sample/sample.bdd_scenarios.dart @@ -12,7 +12,6 @@ class SampleScenario { Future iShouldSeeTheSampleFeature(WidgetTester tester) async { // TODO: Implement Then I should see the sample feature } - } class CounterScenario { @@ -27,7 +26,6 @@ class CounterScenario { Future iShouldSeeTheCounterIncremented(WidgetTester tester) async { // TODO: Implement Then I should see the counter incremented } - } class CounterWithExamplesScenario { @@ -42,7 +40,6 @@ class CounterWithExamplesScenario { Future iShouldSeeTheCounterIncremented() async { // TODO: Implement Then I should see the counter incremented } - } class CounterWithParametersScenario { @@ -57,7 +54,6 @@ class CounterWithParametersScenario { Future iShouldSeeTheResultResult(String result) async { // TODO: Implement Then I should see the result } - } class CounterWithWidgetTestScenario { @@ -72,6 +68,4 @@ class CounterWithWidgetTestScenario { Future iShouldSeeTheCounterIncremented(WidgetTester tester) async { // TODO: Implement Then I should see the counter incremented } - } - diff --git a/example/test/sample/sample.bdd_test.dart b/example/test/sample/sample.bdd_test.dart index ca76f76..b12a403 100644 --- a/example/test/sample/sample.bdd_test.dart +++ b/example/test/sample/sample.bdd_test.dart @@ -27,34 +27,34 @@ void main() { final scenario = CounterWithExamplesScenario(); //Scenario: Counter with examples final examples = [ - {'counter': '1',}, - {'counter': '2',}, - {'counter': '3',}, + {'counter': '1'}, + {'counter': '2'}, + {'counter': '3'}, ]; for (var example in examples) { - // Given I have a counter - await scenario.iHaveACounter(); - // When I increment the - await scenario.iIncrementTheCounter( example['counter']!); - // Then I should see the counter incremented - await scenario.iShouldSeeTheCounterIncremented(); + // Given I have a counter + await scenario.iHaveACounter(); + // When I increment the + await scenario.iIncrementTheCounter(example['counter']!); + // Then I should see the counter incremented + await scenario.iShouldSeeTheCounterIncremented(); } }); test('Counter with parameters', () async { final scenario = CounterWithParametersScenario(); //Scenario: Counter with parameters final examples = [ - {'counter': '1','result': '2',}, - {'counter': '2','result': '3',}, - {'counter': '3','result': '4',}, + {'counter': '1', 'result': '2'}, + {'counter': '2', 'result': '3'}, + {'counter': '3', 'result': '4'}, ]; for (var example in examples) { - // Given I have a counter - await scenario.iHaveACounter(); - // When I increment the counter - await scenario.iIncrementTheCounterCounter( example['counter']!); - // Then I should see the result - await scenario.iShouldSeeTheResultResult( example['result']!); + // Given I have a counter + await scenario.iHaveACounter(); + // When I increment the counter + await scenario.iIncrementTheCounterCounter(example['counter']!); + // Then I should see the result + await scenario.iShouldSeeTheResultResult(example['result']!); } }); testWidgets('Counter with widget test', (tester) async { diff --git a/lib/src/domain/step.dart b/lib/src/domain/step.dart index c628d24..615f34a 100644 --- a/lib/src/domain/step.dart +++ b/lib/src/domain/step.dart @@ -35,7 +35,8 @@ extension StepX on Step { processedText = processedText.replaceAll(match.group(0)!, paramName); } - final words = processedText.replaceAll(RegExp(r'[^a-zA-Z0-9\s]'), ' ').split(' ').where((word) => word.isNotEmpty).toList(); + final words = + processedText.replaceAll(RegExp(r'[^a-zA-Z0-9\s]'), ' ').split(' ').where((word) => word.isNotEmpty).toList(); if (words.isEmpty) return ''; diff --git a/lib/src/extensions/string_x.dart b/lib/src/extensions/string_x.dart index 486f87e..fb4db96 100644 --- a/lib/src/extensions/string_x.dart +++ b/lib/src/extensions/string_x.dart @@ -4,7 +4,10 @@ extension StringX on String { /// /// Example: `"successful login"` -> `"SuccessfulLogin"`. String get name { - return split(' ').where((word) => word.isNotEmpty).map((word) => word[0].toUpperCase() + word.substring(1).toLowerCase()).join(''); + return split(' ') + .where((word) => word.isNotEmpty) + .map((word) => word[0].toUpperCase() + word.substring(1).toLowerCase()) + .join(''); } /// Converts to a scenario class name with the default "Scenario" suffix. diff --git a/lib/src/infrastructure/parsers/feature_parser.dart b/lib/src/infrastructure/parsers/feature_parser.dart index 2b94c9f..d070df0 100644 --- a/lib/src/infrastructure/parsers/feature_parser.dart +++ b/lib/src/infrastructure/parsers/feature_parser.dart @@ -14,7 +14,6 @@ import '../../domain/step.dart'; class FeatureParser { /// Parses a `.feature` file at [filePath] and returns a [Feature] model. Future parseFeature(String filePath) async { - final file = File(filePath); final fileContent = await file.readAsString(); final lines = fileContent.split('\n').map((line) => line.trim()).toList(); @@ -88,7 +87,10 @@ class FeatureParser { currentExampleContent = null; } // parsing steps - else if (line.startsWith('Given') || line.startsWith('When') || line.startsWith('Then') || line.startsWith('And')) { + else if (line.startsWith('Given') || + line.startsWith('When') || + line.startsWith('Then') || + line.startsWith('And')) { // if lines start with Given, When, Then or And, it means it's a step final parts = line.split(' '); final stepType = parts[0]; diff --git a/lib/src/presentation/controllers/bdd_controller.dart b/lib/src/presentation/controllers/bdd_controller.dart index b211821..1c6c9db 100644 --- a/lib/src/presentation/controllers/bdd_controller.dart +++ b/lib/src/presentation/controllers/bdd_controller.dart @@ -50,9 +50,7 @@ class BDDController { return; } - final featureFiles = testDir - .listSync(recursive: true) - .where((file) => file.path.endsWith('.feature')); + final featureFiles = testDir.listSync(recursive: true).where((file) => file.path.endsWith('.feature')); if (featureFiles.isEmpty) { stdout.writeln('No .feature files found in "${config.testDir}".'); diff --git a/lib/src/presentation/reporter/bdd_report_formatter.dart b/lib/src/presentation/reporter/bdd_report_formatter.dart index 9e5a487..3feec7f 100644 --- a/lib/src/presentation/reporter/bdd_report_formatter.dart +++ b/lib/src/presentation/reporter/bdd_report_formatter.dart @@ -44,9 +44,7 @@ class BDDReportFormatter { String formatReport() { final buffer = StringBuffer(); - final totalDuration = _startTime != null - ? DateTime.now().difference(_startTime!) - : Duration.zero; + final totalDuration = _startTime != null ? DateTime.now().difference(_startTime!) : Duration.zero; buffer.writeln(); buffer.writeln('${_cyan}BDD Test Report$_reset'); @@ -62,9 +60,7 @@ class BDDReportFormatter { for (final scenario in feature.scenarios) { totalScenarios++; - final durationStr = scenario.duration != null - ? ' $_dim(${scenario.duration!.inMilliseconds}ms)$_reset' - : ''; + final durationStr = scenario.duration != null ? ' $_dim(${scenario.duration!.inMilliseconds}ms)$_reset' : ''; if (scenario.passed) { totalPassed++; diff --git a/lib/src/presentation/reporter/bdd_test_runner.dart b/lib/src/presentation/reporter/bdd_test_runner.dart index 47fe1ac..fdc1260 100644 --- a/lib/src/presentation/reporter/bdd_test_runner.dart +++ b/lib/src/presentation/reporter/bdd_test_runner.dart @@ -53,10 +53,7 @@ class BDDTestRunner { final errorMessages = {}; // Parse JSON events line by line - await process.stdout - .transform(utf8.decoder) - .transform(const LineSplitter()) - .forEach((line) { + await process.stdout.transform(utf8.decoder).transform(const LineSplitter()).forEach((line) { _processJsonLine( line, testNames: testNames, @@ -154,9 +151,7 @@ class BDDTestRunner { final fullName = testNames[testId] ?? ''; final featureName = testGroups[testId] ?? ''; final startTime = testStartTimes[testId]; - final duration = startTime != null - ? DateTime.now().difference(startTime) - : null; + final duration = startTime != null ? DateTime.now().difference(startTime) : null; // Extract scenario name by removing the feature group prefix String scenarioName = fullName; diff --git a/test/builders/scenario_file_builder_test.dart b/test/builders/scenario_file_builder_test.dart index 56f7f2e..b179217 100644 --- a/test/builders/scenario_file_builder_test.dart +++ b/test/builders/scenario_file_builder_test.dart @@ -43,7 +43,9 @@ void main() { scenarios: [ Scenario('Add', [ Step('Given', 'I have a calculator'), - ], decorators: {Decorator.unitTest}), + ], decorators: { + Decorator.unitTest + }), ], decorators: {}, ); @@ -68,7 +70,10 @@ void main() { final result = await builder.buildScenarioFile(feature); - expect(result, contains('Future iAddFirstNumberAndSecondNumber(WidgetTester tester, String firstNumber, String secondNumber) async {')); + expect( + result, + contains( + 'Future iAddFirstNumberAndSecondNumber(WidgetTester tester, String firstNumber, String secondNumber) async {')); }); test('generates Background class with instance methods', () async { diff --git a/test/builders/test_file_builder_test.dart b/test/builders/test_file_builder_test.dart index fe2b404..c2f6514 100644 --- a/test/builders/test_file_builder_test.dart +++ b/test/builders/test_file_builder_test.dart @@ -77,7 +77,9 @@ void main() { scenarios: [ Scenario('Add', [ Step('Given', 'I have a calculator'), - ], decorators: {Decorator.unitTest}), + ], decorators: { + Decorator.unitTest + }), ], decorators: {}, );