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/.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/CHANGELOG.md b/CHANGELOG.md index d88db84..8f1714d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +## 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 +- 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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d989910 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,121 @@ +# 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 package tests (not example — example uses flutter_test) +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 + +# Analyze code +dart analyze + +# Get dependencies +dart pub get +``` + +## Architecture + +Clean Architecture in `lib/src/`. The CLI entry point is `bin/bdd_flutter.dart`. + +### Code Generation Pipeline + +``` +BDDCLI.run(args) + → 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, BDDConfig, Manifest, BuildOptions +- **`infrastructure/parsers/`** — FeatureParser, ConfigParser, ManifestParser +- **`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/`** — BDDTestRunner (CLI `test` command), BDDReportFormatter (output formatting), BDDTestReporter (legacy, exported) + +### Domain Models + +- **Feature** — name, path, scenarios, decorators, optional background +- **Scenario** — name, steps, optional examples table, decorators, optional customClassName +- **Step** — keyword (Given/When/Then/And) + text with `` placeholders +- **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 + +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); +``` + +### Generation Modes + +- **Incremental (default)** — compares scenario hashes against manifest, skips unchanged, appends new scenarios +- **Force (`--force`)** — regenerates everything + +### 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 Commands + +- `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 + +`.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 + +`.bdd_flutter/manifest.yaml` — auto-generated, tracks per-feature paths, timestamps, and scenario hashes for incremental builds. + +## 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 acd73ea..19a380b 100644 --- a/README.md +++ b/README.md @@ -1,32 +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. -## ✨ Features +## Features -- 📝 Parse `.feature` files written in Gherkin syntax -- ⚡ Generate boilerplate test files automatically -- 🧪 Support for both widget tests and unit tests -- ⚙️ Configurable test generation +- 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` -## 📦 Installation +## Installation -Add the following dependencies to your package's `pubspec.yaml` file: +Add to your `pubspec.yaml`: ```yaml dev_dependencies: - bdd_flutter: any - build_runner: any + bdd_flutter: latest ``` -## 🚀 Quick Start +## Quick Start -1. Create a `.feature` file in your project: +1. Create a `.feature` file in your test folder: ```gherkin Feature: Counter @@ -44,150 +46,192 @@ Feature: Counter 2. Generate test files: ```bash -flutter pub run build_runner build +dart run bdd_flutter build ``` -3. Run your tests: +3. Implement the generated step methods in `counter.bdd_scenarios.dart` -```bash -flutter test -``` - -## 💡 Recommendations - -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`) - - After implementing your tests, it's recommended to: - - Remove the `.g` extension from the file name - - 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 +4. Run your tests with BDD report: -## ⚙️ Configuration - -Configure test generation in your `build.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" +```bash +dart run bdd_flutter test ``` -### Configuration Options +Or run normally with `flutter test`. -| 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 | +## Generated Files -## 🏷️ Decorators +The generator creates two files per `.feature` file: -Control test generation with decorators: +- **`.bdd_scenarios.dart`** — Scenario classes with step method stubs (your implementation goes here) +- **`.bdd_test.dart`** — Test orchestration file (auto-generated, do not edit) -| 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 | +### How It Works -> 💡 Decorators follow a hierarchy: Scenario-level decorators override Feature-level ones. +- 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) -## 📝 Complete Example +### Example Output -### 1. Feature File (`counter.feature`) - -```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 | -``` - -### 2. Generated Files - -#### `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 + +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 `--force` to regenerate everything: + +```bash +dart run bdd_flutter build --force +``` + +### Config Options + +| 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 + +### Commands + +| 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 + +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 + +### Incremental (Default) + +```bash +dart run bdd_flutter build +``` + +- 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 + +### Force Regenerate + +```bash +dart run bdd_flutter build --force +``` + +- Regenerates all files from scratch +- **Overwrites** existing scenario implementations — use with caution + +## 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** — Keep both `config.yaml` and `manifest.yaml` in version control. The manifest prevents incremental builds from overwriting implemented code on fresh clones. + +2. **Scenario Files** — Implement your test logic in `.bdd_scenarios.dart`. Use `late` fields for shared state between steps (mocks, providers, widgets). + +3. **Test Files** — Do not edit `.bdd_test.dart` files. They are regenerated automatically and contain no user code. -We welcome contributions! Please feel free to: +4. **Adding Scenarios** — When you add new scenarios to a `.feature` file, run `build` — new scenario classes are appended without touching existing ones. -- Open an issue -- Submit a pull request -- Share your feedback +5. **Feature Files** — Keep feature files clean. Only use `@unitTest`/`@widgetTest` decorators. All other configuration belongs in `.bdd_flutter/config.yaml`. -## 📄 License +## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 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 new file mode 100644 index 0000000..6f54556 --- /dev/null +++ b/bin/bdd_flutter.dart @@ -0,0 +1,6 @@ +import 'package:bdd_flutter/src/presentation/cli/bbd_cli.dart'; + +void main(List arguments) async { + final cli = BDDCLI(); + await cli.run(arguments); +} 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/.bdd_flutter/manifest.yaml b/example/.bdd_flutter/manifest.yaml new file mode 100644 index 0000000..adfee6a --- /dev/null +++ b/example/.bdd_flutter/manifest.yaml @@ -0,0 +1,103 @@ +version: "1.0" +last_generated: "2026-03-31T22:40:54.476380" +features: + - path: "test/calculator/calculator.feature" + last_modified: "2025-08-16T13:55:30.921" + test_file: "test/calculator/calculator.bdd_test.dart" + scenarios: + - name: "Add two numbers" + hash: "f03974460050014a1179e86546325452" + test_method: "testAddTwoNumbersScenario" + - name: "Subtract two numbers" + hash: "6a3adb8f284f822789c89be0e919c7bf" + test_method: "testSubtractTwoNumbersScenario" + - name: "Subtract two numbers2" + hash: "f50907ed69f6ec5526f6acf958f01496" + test_method: "testSubtractTwoNumbers2Scenario" + - name: "Multiply two numbers" + hash: "8dba45ac682e9fa560bc6eefa73bc1f4" + test_method: "testMultiplyTwoNumbersScenario" + - name: "Divide two numbers" + hash: "415425b012fac2d176e72560dac0b8e1" + test_method: "testDivideTwoNumbersScenario" + - name: "Divide two numbers2" + hash: "737c00e721ef5df3f8d9eeb338a51b79" + test_method: "testDivideTwoNumbers2Scenario" + - path: "test/features/feature2.feature" + last_modified: "2025-05-19T14:28:03.988" + test_file: "test/features/feature2.bdd_test.dart" + scenarios: + - name: "Scenario 2" + hash: "8023fb9d6a1d88eadee8e007b8f8e967" + test_method: "testScenario2Scenario" + - name: "Scenario 3" + hash: "995fc3dbd32f20d5657d97ba82c0f467" + test_method: "testScenario3Scenario" + - path: "test/features/feature3.feature" + last_modified: "2025-05-14T21:04:20.415" + test_file: "test/features/feature3.bdd_test.dart" + scenarios: + - name: "Scenario 3" + hash: "995fc3dbd32f20d5657d97ba82c0f467" + test_method: "testScenario3Scenario" + - path: "test/features/feature1.feature" + last_modified: "2025-05-20T22:28:54.794" + test_file: "test/features/feature1.bdd_test.dart" + scenarios: + - name: "Scenario 1" + hash: "0872c6351cf96b32a10cde4c40b50082" + test_method: "testScenario1Scenario" + - name: "Scenario 2" + hash: "8023fb9d6a1d88eadee8e007b8f8e967" + test_method: "testScenario2Scenario" + - name: "Scenario 3" + hash: "995fc3dbd32f20d5657d97ba82c0f467" + test_method: "testScenario3Scenario" + - name: "Scenario 4" + hash: "3616c7a4f65b5c618759cbf467ef932c" + test_method: "testScenario4Scenario" + - name: "Scenario 5" + hash: "8dbde53f132f931117b624fb9df580b0" + test_method: "testScenario5Scenario" + - path: "test/sample/sample.feature" + last_modified: "2025-05-11T21:52:29.585" + test_file: "test/sample/sample.bdd_test.dart" + scenarios: + - name: "Sample" + hash: "55f8a2694d752e1c1cb32c7b9b9a56b2" + test_method: "testSampleScenario" + - name: "Counter" + hash: "6fcad53b0190130b41652843710a936f" + test_method: "testCounterScenario" + - name: "Counter with examples" + hash: "9cba8be6501d2cfc9bfb3586c4714cc9" + test_method: "testCounterWithExamplesScenario" + - name: "Counter with parameters" + hash: "bcdc61902c339891d57cf97da815b9c5" + test_method: "testCounterWithParametersScenario" + - name: "Counter with widget test" + hash: "c6e6af68b963a51031605f70a444b308" + test_method: "testCounterWithWidgetTestScenario" + - path: "test/counter/counter.feature" + 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/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/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/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..5ff62ff --- /dev/null +++ b/example/lib/src/login_screen.dart @@ -0,0 +1,70 @@ +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 bb999fb..03863b0 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -5,23 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" url: "https://pub.dev" source: hosted - version: "76.0.0" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.3" + version: "93.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b url: "https://pub.dev" source: hosted - version: "6.11.0" + version: "10.0.1" args: dependency: transitive description: @@ -44,7 +39,7 @@ packages: path: ".." relative: true source: path - version: "0.2.0" + version: "0.0.1" boolean_selector: dependency: transitive description: @@ -53,86 +48,22 @@ 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: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" - checked_yaml: + version: "1.4.1" + cli_config: dependency: transitive description: - name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "0.2.0" clock: dependency: transitive description: @@ -141,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: @@ -169,10 +92,10 @@ packages: dependency: transitive description: name: coverage - sha256: e3493833ea012784c740e341952298f1cc77f1f01b1bbc3eb4eecf6984fb7f43 + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.15.0" crypto: dependency: transitive description: @@ -181,22 +104,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820" - url: "https://pub.dev" - source: hosted - version: "2.3.8" fake_async: 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: @@ -205,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 @@ -247,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: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f - url: "https://pub.dev" - source: hosted - version: "1.3.0" http_multi_server: dependency: transitive description: @@ -287,46 +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" - 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: 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: @@ -343,38 +218,30 @@ 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: 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: @@ -383,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: @@ -415,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: @@ -423,14 +314,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: @@ -508,14 +391,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: @@ -536,34 +411,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" - url: "https://pub.dev" - source: hosted - version: "0.6.8" - timing: - dependency: transitive - description: - name: timing - sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "0.6.16" typed_data: dependency: transitive description: @@ -576,10 +443,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: @@ -608,18 +475,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: @@ -637,5 +504,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/pubspec.yaml b/example/pubspec.yaml index 25bb512..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: @@ -16,8 +17,8 @@ dev_dependencies: flutter_lints: ^5.0.0 bdd_flutter: path: ../ - build_runner: ^2.4.11 test: ^1.20.0 + mocktail: ^1.0.0 flutter: uses-material-design: true diff --git a/example/test/calculator/calculator.bdd_scenarios.dart b/example/test/calculator/calculator.bdd_scenarios.dart index 12fad21..4fc99e4 100644 --- a/example/test/calculator/calculator.bdd_scenarios.dart +++ b/example/test/calculator/calculator.bdd_scenarios.dart @@ -1,57 +1,105 @@ 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 iAddThemTogether(WidgetTester tester) async { + Future iHaveTheNumber2(WidgetTester tester) async { + // TODO: Implement And I have the number 2 + } + + 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 SubtractTwoNumbersScenario { - static Future iHaveTheNumber5(WidgetTester tester) async { + Future iHaveTheNumber5(WidgetTester tester) async { // TODO: Implement Given I have the number 5 } - static Future iSubtractThem(WidgetTester tester) async { + Future iHaveTheNumber3(WidgetTester tester) async { + // TODO: Implement And I have the number 3 + } + + 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 { + Future iHaveTheNumber6(WidgetTester tester) async { + // TODO: Implement Given I have the number 6 + } + + Future iHaveTheNumber8(WidgetTester tester) async { + // TODO: Implement And I have the number 8 + } + + Future iSubtractThem(WidgetTester tester) async { + // TODO: Implement When I subtract them + } + + 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 iMultiplyThem(WidgetTester tester) async { + Future iHaveTheNumber3(WidgetTester tester) async { + // TODO: Implement And I have the number 3 + } + + 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 iHaveTheNumber(WidgetTester tester, String number1) async { + Future iHaveTheNumberNumber1(WidgetTester tester, String number1) async { // TODO: Implement Given I have the number } - static Future iDivideThem(WidgetTester tester) async { + Future iHaveTheNumberNumber2(WidgetTester tester, String number2) async { + // TODO: Implement And I have the number + } + + Future iDivideThem(WidgetTester tester) async { // TODO: Implement When I divide them } - static Future theResultShouldBe(WidgetTester tester, String result) async { + Future theResultShouldBeResult(WidgetTester tester, String result) async { + // TODO: Implement Then the result should be + } +} + +class DivideTwoNumbers2Scenario { + Future iHaveNumber1AndNumber2(WidgetTester tester, String number1, String number2) async { + // TODO: Implement Given I have and + } + + Future iDivideThemToEachOther(WidgetTester tester) async { + // TODO: Implement When I divide them to each other + } + + 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 b23e37b..2e7ca75 100644 --- a/example/test/calculator/calculator.bdd_test.dart +++ b/example/test/calculator/calculator.bdd_test.dart @@ -4,33 +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 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 SubtractTwoNumbersScenario.iHaveTheNumber5(tester); + await scenario.iHaveTheNumber5(tester); + // And I have the number 3 + await scenario.iHaveTheNumber3(tester); // When I subtract them - await SubtractTwoNumbersScenario.iSubtractThem(tester); + await scenario.iSubtractThem(tester); // Then the result should be 2 - await SubtractTwoNumbersScenario.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 scenario.iHaveTheNumber6(tester); + // And I have the number 8 + await scenario.iHaveTheNumber8(tester); + // When I subtract them + await scenario.iSubtractThem(tester); + // Then the result should be -2 + 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 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'}, @@ -39,11 +61,30 @@ void main() { ]; for (var example in examples) { // Given I have the number - await DivideTwoNumbersScenario.iHaveTheNumber(tester, example['number1']!); + await scenario.iHaveTheNumberNumber1(tester, example['number1']!); + // And I have the number + await scenario.iHaveTheNumberNumber2(tester, example['number2']!); // When I divide them - await DivideTwoNumbersScenario.iDivideThem(tester); + 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'}, + ]; + 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 DivideTwoNumbersScenario.theResultShouldBe(tester, example['result']!); + await scenario.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 new file mode 100644 index 0000000..4f9dead --- /dev/null +++ b/example/test/counter/counter.bdd_scenarios.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; + +class CounterBackground { + Future iHaveACounterWithValue0() async { + // TODO: Implement Given I have a counter with value 0 + } +} + +class IncrementScenario { + Future iIncrementTheCounterByValue(WidgetTester tester, String value) async { + // TODO: Implement When I increment the counter by + } + + Future theCounterShouldHaveValueExpectedValue(WidgetTester tester, String expectedValue) async { + // TODO: Implement Then the counter should have value + } +} + +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_scenarios.g.dart b/example/test/counter/counter.bdd_scenarios.g.dart deleted file mode 100644 index eaeabd3..0000000 --- a/example/test/counter/counter.bdd_scenarios.g.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -class CounterBackground { - static Future iHaveACounterWithValue0() async { - // TODO: Implement Given I have a counter with value 0 - } - -} - -class IncrementScenario { - static Future iIncrementTheCounterBy(WidgetTester tester, String value) async { - // TODO: Implement When I increment the counter by - } - - static Future theCounterShouldHaveValue(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 new file mode 100644 index 0000000..2aeebbb --- /dev/null +++ b/example/test/counter/counter.bdd_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'counter.bdd_scenarios.dart'; + +void main() { + group('Counter', () { + 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'}, + ]; + 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']!); + } + }); + 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.bdd_test.g.dart b/example/test/counter/counter.bdd_test.g.dart deleted file mode 100644 index 8b1940c..0000000 --- a/example/test/counter/counter.bdd_test.g.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'counter.bdd_scenarios.g.dart'; - -void main() { - group('Counter', () { - //Background: I have a counter with value 0 - CounterBackground.iHaveACounterWithValue0(); - testWidgets('Increment', (tester) async { - //Scenario: Increment - final examples = [ - {'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']!); - } - }); - }); -} 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/features/feature1.bdd_scenarios.dart b/example/test/features/feature1.bdd_scenarios.dart new file mode 100644 index 0000000..aba6d79 --- /dev/null +++ b/example/test/features/feature1.bdd_scenarios.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; + +class Scenario1Scenario { + 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 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 new file mode 100644 index 0000000..079c613 --- /dev/null +++ b/example/test/features/feature1.bdd_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; +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 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 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 new file mode 100644 index 0000000..68adb7f --- /dev/null +++ b/example/test/features/feature1.feature @@ -0,0 +1,27 @@ +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 + + 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 new file mode 100644 index 0000000..361ac4a --- /dev/null +++ b/example/test/features/feature2.bdd_scenarios.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; + +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 + } +} diff --git a/example/test/features/feature2.bdd_test.dart b/example/test/features/feature2.bdd_test.dart new file mode 100644 index 0000000..066027d --- /dev/null +++ b/example/test/features/feature2.bdd_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +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 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); + }); + }); +} diff --git a/example/test/features/feature2.feature b/example/test/features/feature2.feature new file mode 100644 index 0000000..7d0456a --- /dev/null +++ b/example/test/features/feature2.feature @@ -0,0 +1,12 @@ +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 + + 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/features/feature3.bdd_scenarios.dart b/example/test/features/feature3.bdd_scenarios.dart new file mode 100644 index 0000000..658320b --- /dev/null +++ b/example/test/features/feature3.bdd_scenarios.dart @@ -0,0 +1,15 @@ +import 'package:flutter_test/flutter_test.dart'; + +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 + } +} diff --git a/example/test/features/feature3.bdd_test.dart b/example/test/features/feature3.bdd_test.dart new file mode 100644 index 0000000..52a30dd --- /dev/null +++ b/example/test/features/feature3.bdd_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; +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 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/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/login/login.bdd_scenarios.dart b/example/test/login/login.bdd_scenarios.dart new file mode 100644 index 0000000..80898e0 --- /dev/null +++ b/example/test/login/login.bdd_scenarios.dart @@ -0,0 +1,137 @@ +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/example/test/sample/sample.bdd_scenarios.dart b/example/test/sample/sample.bdd_scenarios.dart new file mode 100644 index 0000000..c9d2f7c --- /dev/null +++ b/example/test/sample/sample.bdd_scenarios.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; + +class SampleScenario { + Future iHaveASampleFeature(WidgetTester tester) async { + // TODO: Implement Given I have a sample feature + } + + Future iRunTheSampleFeature(WidgetTester tester) async { + // TODO: Implement When I run the sample feature + } + + Future iShouldSeeTheSampleFeature(WidgetTester tester) async { + // TODO: Implement Then I should see the sample feature + } +} + +class CounterScenario { + Future iHaveACounter(WidgetTester tester) async { + // TODO: Implement Given I have a counter + } + + Future iIncrementTheCounter(WidgetTester tester) async { + // TODO: Implement When I increment the counter + } + + Future iShouldSeeTheCounterIncremented(WidgetTester tester) async { + // TODO: Implement Then I should see the counter incremented + } +} + +class CounterWithExamplesScenario { + Future iHaveACounter() async { + // TODO: Implement Given I have a counter + } + + Future iIncrementTheCounter(String counter) async { + // TODO: Implement When I increment the + } + + Future iShouldSeeTheCounterIncremented() async { + // TODO: Implement Then I should see the counter incremented + } +} + +class CounterWithParametersScenario { + Future iHaveACounter() async { + // TODO: Implement Given I have a counter + } + + Future iIncrementTheCounterCounter(String counter) async { + // TODO: Implement When I increment the counter + } + + Future iShouldSeeTheResultResult(String result) async { + // TODO: Implement Then I should see the result + } +} + +class CounterWithWidgetTestScenario { + Future iHaveACounter(WidgetTester tester) async { + // TODO: Implement Given I have a counter + } + + Future iIncrementTheCounter(WidgetTester tester) async { + // TODO: Implement When I increment the counter + } + + Future iShouldSeeTheCounterIncremented(WidgetTester tester) async { + // TODO: Implement Then I should see the counter incremented + } +} diff --git a/example/test/sample/sample.bdd_scenarios.g.dart b/example/test/sample/sample.bdd_scenarios.g.dart deleted file mode 100644 index 2ea77d9..0000000 --- a/example/test/sample/sample.bdd_scenarios.g.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -class SampleScenario { - static Future iHaveASampleFeature(WidgetTester tester) async { - // TODO: Implement Given I have a sample feature - } - - static Future iRunTheSampleFeature(WidgetTester tester) async { - // TODO: Implement When I run the sample feature - } - - static Future iShouldSeeTheSampleFeature(WidgetTester tester) async { - // TODO: Implement Then I should see the sample feature - } - -} - -class CounterCustomName { - 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 - } - -} - -class CounterWithExamplesScenario { - static Future iHaveACounter() async { - // TODO: Implement Given I have a counter - } - - static Future iIncrementThe(String counter) async { - // TODO: Implement When I increment the - } - - static Future iShouldSeeTheCounterIncremented() async { - // TODO: Implement Then I should see the counter incremented - } - -} - -class CounterWithParametersScenario { - static Future iHaveACounter() async { - // TODO: Implement Given I have a counter - } - - static Future iIncrementTheCounter(String counter) async { - // TODO: Implement When I increment the counter - } - - static Future iShouldSeeTheResult(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 new file mode 100644 index 0000000..b12a403 --- /dev/null +++ b/example/test/sample/sample.bdd_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +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 scenario.iHaveASampleFeature(tester); + // When I run the sample feature + await scenario.iRunTheSampleFeature(tester); + // Then I should see the sample feature + await scenario.iShouldSeeTheSampleFeature(tester); + }); + testWidgets('Counter', (tester) async { + final scenario = CounterScenario(); + //Scenario: Counter + // Given I have a counter + await scenario.iHaveACounter(tester); + // When I increment the counter + await scenario.iIncrementTheCounter(tester); + // Then I should see the counter incremented + await scenario.iShouldSeeTheCounterIncremented(tester); + }); + test('Counter with examples', () async { + final scenario = CounterWithExamplesScenario(); + //Scenario: Counter with examples + final examples = [ + {'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(); + } + }); + 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'}, + ]; + 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']!); + } + }); + testWidgets('Counter with widget test', (tester) async { + final scenario = CounterWithWidgetTestScenario(); + //Scenario: Counter with widget test + // Given I have a counter + await scenario.iHaveACounter(tester); + // When I increment the counter + await scenario.iIncrementTheCounter(tester); + // Then I should see the counter incremented + await scenario.iShouldSeeTheCounterIncremented(tester); + }); + }); +} diff --git a/example/test/sample/sample.bdd_test.g.dart b/example/test/sample/sample.bdd_test.g.dart deleted file mode 100644 index 2e514fa..0000000 --- a/example/test/sample/sample.bdd_test.g.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'sample.bdd_scenarios.g.dart'; - -void main() { - group('Sample', () { - testWidgets('Sample', (tester) async { - //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 - await SampleScenario.iShouldSeeTheSampleFeature(tester); - }); - testWidgets('Counter', (tester) async { - //Scenario: Counter - // Given I have a counter - await CounterCustomName.iHaveACounter(tester); - // When I increment the counter - await CounterCustomName.iIncrementTheCounter(tester); - // Then I should see the counter incremented - await CounterCustomName.iShouldSeeTheCounterIncremented(tester); - }); - test('Counter with examples', () async { - //Scenario: Counter with examples - final examples = [ - {'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(); - } - }); - test('Counter with parameters', () async { - //Scenario: Counter with parameters - final examples = [ - {'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']!); - } - }); - }); -} 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 diff --git a/lib/bdd_flutter.dart b/lib/bdd_flutter.dart index 0eaff16..17b27d2 100644 --- a/lib/bdd_flutter.dart +++ b/lib/bdd_flutter.dart @@ -1,70 +1,33 @@ -export 'src/feature/report/test_reporter.dart' show BDDTestReporter; -export 'src/builder.dart' show bddTestBuilder; - /// 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. +/// BDD Flutter generates Dart test files from Gherkin `.feature` files. +/// Write scenarios in plain English using Given/When/Then syntax, then run: /// -/// ## Features -/// -/// * Parse Gherkin-style feature files -/// * Generate test files automatically -/// * Support for both widget and non-widget tests -/// * Customizable test generation options -/// * Ignore specific generated files using bdd_ignore.yaml -/// -/// ## Getting Started -/// -/// 1. Add the package to your `pubspec.yaml`: -/// ```yaml -/// dev_dependencies: -/// bdd_flutter: ^1.0.0 +/// ```bash +/// dart run bdd_flutter build /// ``` /// -/// 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 +/// This generates `.bdd_scenarios.dart` (step stubs) and `.bdd_test.dart` +/// (test orchestration) files alongside each `.feature` file. /// -/// 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 -/// ``` +/// ## Quick Start /// -/// 3. Run the build_runner to generate test files: -/// ```bash -/// flutter pub run build_runner build -/// ``` +/// 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 /// -/// You can configure the builder in your `build.yaml`: -/// ```yaml -/// targets: -/// $default: -/// builders: -/// bdd_flutter|bdd_test_builder: -/// options: -/// generate_widget_tests: true -/// ``` -/// -/// ## Ignoring Generated Files +/// Configure via `.bdd_flutter/config.yaml`: /// -/// 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 +/// test_dir: "test/" +/// generate_widget_tests: true +/// ignore_features: +/// - test/features/login.feature +/// additional_imports: +/// - "package:mocktail/mocktail.dart" +/// scenario_suffix: "Scenario" /// ``` -/// -/// 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). +library bdd_flutter; diff --git a/lib/src/builder.dart b/lib/src/builder.dart deleted file mode 100644 index 9b4495e..0000000 --- a/lib/src/builder.dart +++ /dev/null @@ -1,62 +0,0 @@ -// 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'; - -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 { - await BDDIgnore.initialize(); - - // 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); - - // 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; - } - - 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_constraint.dart b/lib/src/constraints/file_constraint.dart new file mode 100644 index 0000000..52a5032 --- /dev/null +++ b/lib/src/constraints/file_constraint.dart @@ -0,0 +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'; +} diff --git a/lib/src/domain/background.dart b/lib/src/domain/background.dart new file mode 100644 index 0000000..b24af4e --- /dev/null +++ b/lib/src/domain/background.dart @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000..c36e42f --- /dev/null +++ b/lib/src/domain/build_options.dart @@ -0,0 +1,9 @@ +/// 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({ + this.force = false, + }); +} diff --git a/lib/src/domain/config.dart b/lib/src/domain/config.dart new file mode 100644 index 0000000..428d891 --- /dev/null +++ b/lib/src/domain/config.dart @@ -0,0 +1,34 @@ +/// 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({ + this.testDir = 'test/', + this.generateWidgetTests = true, + this.ignoreFeatures = const [], + this.additionalImports = const [], + this.scenarioSuffix = 'Scenario', + }); +} diff --git a/lib/src/domain/decorator.dart b/lib/src/domain/decorator.dart new file mode 100644 index 0000000..0d7ac1b --- /dev/null +++ b/lib/src/domain/decorator.dart @@ -0,0 +1,44 @@ +/// 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) { + '@unitTest' => Decorator.unitTest, + '@widgetTest' => Decorator.widgetTest, + _ => Decorator.unknown, + }; + } +} + +/// 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 new file mode 100644 index 0000000..c41b60f --- /dev/null +++ b/lib/src/domain/feature.dart @@ -0,0 +1,52 @@ +import '../constraints/file_constraint.dart'; + +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({ + required this.name, + required this.path, + required this.scenarios, + required this.decorators, + this.background, + }); +} + +/// 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 new file mode 100644 index 0000000..9fc8eb4 --- /dev/null +++ b/lib/src/domain/manifest.dart @@ -0,0 +1,60 @@ +/// 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({ + this.version = '1.0', + DateTime? lastGenerated, + this.features = const [], + }) : 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({ + required this.path, + required this.lastModified, + required this.testFile, + this.scenarios = const [], + }); +} + +/// 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({ + required this.name, + required this.hash, + required this.testMethod, + }); +} diff --git a/lib/src/domain/scenario.dart b/lib/src/domain/scenario.dart new file mode 100644 index 0000000..8c3bb94 --- /dev/null +++ b/lib/src/domain/scenario.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; +import '../extensions/string_x.dart'; +import 'package:crypto/crypto.dart'; + +import 'decorator.dart'; +import 'step.dart'; + +/// 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( + this.name, + this.steps, { + this.examples, + this.decorators = const {}, + }); + + @override + String toString() { + return 'Scenario(name: $name, steps: $steps, examples: $examples, decorators: $decorators)'; + } +} + +/// 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; + + /// 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; + if (featureDecorators.hasUnitTest) return true; + 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 new file mode 100644 index 0000000..615f34a --- /dev/null +++ b/lib/src/domain/step.dart @@ -0,0 +1,51 @@ +/// 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 Gherkin keyword: `Given`, `When`, `Then`, or `And`. + final String keyword; + + /// The step text, potentially containing `` placeholders. + final String text; + + Step(this.keyword, this.text); + + @override + String toString() { + return '$keyword $text'; + } +} + +/// 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 { + 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); + } + + final words = + processedText.replaceAll(RegExp(r'[^a-zA-Z0-9\s]'), ' ').split(' ').where((word) => word.isNotEmpty).toList(); + + if (words.isEmpty) return ''; + + return words[0].toLowerCase() + + words + .skip(1) + .map( + (word) => word[0].toUpperCase() + word.substring(1).toLowerCase(), + ) + .join(''); + } +} diff --git a/lib/src/extensions/list_x.dart b/lib/src/extensions/list_x.dart new file mode 100644 index 0000000..7605211 --- /dev/null +++ b/lib/src/extensions/list_x.dart @@ -0,0 +1,10 @@ +/// 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; + } + return null; + } +} diff --git a/lib/src/extensions/string_x.dart b/lib/src/extensions/string_x.dart index 4de0f71..fb4db96 100644 --- a/lib/src/extensions/string_x.dart +++ b/lib/src/extensions/string_x.dart @@ -1,30 +1,40 @@ +/// String utilities for name conversion in code generation. extension StringX on String { - String get toMethodName { - final words = replaceAll(RegExp(r'<[^>]+>'), '').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(''); - } - + /// 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(''); + return split(' ') + .where((word) => word.isNotEmpty) + .map((word) => word[0].toUpperCase() + word.substring(1).toLowerCase()) + .join(''); } - String get toScenarioClassName { - return "${name}Scenario"; + /// 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 { - 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(''); } + /// 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/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 d9387a3..0000000 --- a/lib/src/feature/builder/bdd_builders/bdd_feature_builder.dart +++ /dev/null @@ -1,190 +0,0 @@ -import 'package:build/build.dart'; - -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}); - - /// 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; - 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')) { - 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/bdd_builders/bdd_test_file_builder.dart b/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart deleted file mode 100644 index e7145d5..0000000 --- a/lib/src/feature/builder/bdd_builders/bdd_test_file_builder.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'package:bdd_flutter/src/feature/builder/domain/decorator.dart'; -import 'package:build/build.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';"); - //add reporter import if needed - if (feature.decorators.hasEnableReporter) { - buffer.writeln("import 'package:bdd_flutter/bdd_flutter.dart';"); - } - - buffer.writeln("import '${feature.name.toSnakeCase}.bdd_scenarios.g.dart';"); - buffer.writeln(); - - buffer.writeln("void main() {"); - //add reporter initialization if needed - if (feature.decorators.hasEnableReporter) { - 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}', () {"); - - if (feature.background != null) { - buffer.writeln(" //Background: ${feature.background!.description}"); - for (var step in feature.background!.steps) { - final methodName = step.text.toMethodName; - buffer.writeln(" ${feature.name}Background.$methodName();"); - } - } - - for (var scenario in feature.scenarios) { - final className = scenario.className; - final isUnitTest = scenario.isUnitTest; - 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 {"); - } - - buffer.writeln(" //Scenario: ${scenario.name}"); - - //add start scenario if needed - if (feature.decorators.hasEnableReporter) { - buffer.writeln(" reporter.startScenario('${scenario.name}');"); - } - - if (scenario.examples != null && scenario.examples!.isNotEmpty) { - buffer.writeln(" final examples = ["); - - for (var example in scenario.examples!) { - buffer.write(" {"); - for (var entry in example.entries) { - buffer.write("'${entry.key.snakeCaseToCamelCase}': '${entry.value}',"); - } - buffer.write("},"); - buffer.writeln(); - } - 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) {"); - - for (var step in scenario.steps) { - final params = []; - for (var key in exampleKeys) { - if (step.text.contains('<$key>')) { - params.add("example['$key']!"); - } - } - - buffer.writeln(_generateTestFunction( - buffer, - testFunction, - scenario.name, - className, - step, - feature.decorators.hasEnableReporter, - isUnitTest, - params, - )); - } - buffer.writeln(" }"); - } else { - // For scenarios without examples, just call all steps once - for (var step in scenario.steps) { - buffer.writeln(_generateTestFunction( - buffer, - testFunction, - scenario.name, - className, - step, - feature.decorators.hasEnableReporter, - isUnitTest, - [], - )); - } - } - buffer.writeln(" });"); - } - - buffer.writeln(" });"); - buffer.writeln("}"); - - return buffer.toString(); - } -} - -String _generateTestFunction( - StringBuffer buffer, - String testFunction, - String scenarioName, - String className, - Step step, - bool withReporter, - bool isUnitTest, - List params, -) { - final methodName = step.text.toMethodName; - if (withReporter) { - return ''' - await reporter.guard( - () => $className.$methodName(${isUnitTest ? '' : 'tester'}${params.isNotEmpty ? "${isUnitTest ? '' : ','} ${params.join(', ')}" : ''}), - '${step.message}', - );'''; - } else { - return ''' - // ${step.message} - await $className.$methodName(${isUnitTest ? '' : 'tester'}${params.isNotEmpty ? "${isUnitTest ? '' : ','} ${params.join(', ')}" : ''});'''; - } -} diff --git a/lib/src/feature/builder/bdd_builders/scenario_file_builder.dart b/lib/src/feature/builder/bdd_builders/scenario_file_builder.dart deleted file mode 100644 index 36d67de..0000000 --- a/lib/src/feature/builder/bdd_builders/scenario_file_builder.dart +++ /dev/null @@ -1,80 +0,0 @@ -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';"); - buffer.writeln(); - - if (feature.background != null) { - buffer.writeln("class ${feature.name}Background {"); - for (var step in feature.background!.steps) { - final methodName = step.text.toMethodName; - final params = _extractMethodParams(step.text); - buffer.writeln( - " static Future $methodName(${params.isNotEmpty ? params : ''}) async {", - ); - buffer.writeln(" // TODO: Implement ${step.keyword} ${step.text}"); - buffer.writeln(" }"); - buffer.writeln(); - } - 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 - for (var step in scenario.steps) { - final methodName = step.text.toMethodName; - final params = _extractMethodParams(step.text); - - if (!isUnitTest) { - buffer.writeln( - " static Future $methodName(WidgetTester tester${params.isNotEmpty ? ', $params' : ''}) async {", - ); - } else { - buffer.writeln( - " static 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) { - 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}'); - } - - return params.join(', '); - } -} 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_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/lib/src/feature/builder/domain/bdd_options.dart b/lib/src/feature/builder/domain/bdd_options.dart deleted file mode 100644 index 651da36..0000000 --- a/lib/src/feature/builder/domain/bdd_options.dart +++ /dev/null @@ -1,11 +0,0 @@ -class BDDOptions { - final bool generateWidgetTests; - final bool enableReporter; - final List ignoreFeatures; - - BDDOptions({ - this.generateWidgetTests = true, - this.enableReporter = false, - this.ignoreFeatures = const [], - }); -} 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 442e089..0000000 --- a/lib/src/feature/builder/domain/feature.dart +++ /dev/null @@ -1,25 +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; - - Feature( - this.name, - this.scenarios, { - this.decorators = const {}, - this.background, - }); -} diff --git a/lib/src/feature/builder/domain/scenario.dart b/lib/src/feature/builder/domain/scenario.dart deleted file mode 100644 index db58264..0000000 --- a/lib/src/feature/builder/domain/scenario.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:bdd_flutter/src/extensions/string_x.dart'; - -import 'decorator.dart'; -import 'step.dart'; - -/// A scenario is a collection of steps -class Scenario { - /// The name of the scenario - final String name; - - /// The steps of the scenario - final List steps; - - /// The examples of the scenario - final List>? examples; - - /// The decorators of the scenario - final Set decorators; - - Scenario(this.name, this.steps, {this.examples, this.decorators = const {}}); - - @override - String toString() { - return 'Scenario(name: $name, steps: $steps, examples: $examples, decorators: $decorators)'; - } -} - -extension ScenarioX on Scenario { - bool get isUnitTest => decorators.hasUnitTest; - bool get isWidgetTest => decorators.hasWidgetTest; - - String get className { - if (decorators.hasClassName) { - return decorators.firstWhere((e) => e.isClassName).value!; - } - return name.toScenarioClassName; - } -} diff --git a/lib/src/feature/builder/domain/step.dart b/lib/src/feature/builder/domain/step.dart deleted file mode 100644 index 6181065..0000000 --- a/lib/src/feature/builder/domain/step.dart +++ /dev/null @@ -1,14 +0,0 @@ -/// A step is a keyword and a text -class Step { - /// The keyword of the step - final String keyword; - - /// The text of the step - final String text; - - Step(this.keyword, this.text); -} - -extension StepX on Step { - String get message => '$keyword $text'; -} 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/report/test_reporter.dart b/lib/src/feature/report/test_reporter.dart deleted file mode 100644 index 0774ba0..0000000 --- a/lib/src/feature/report/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'; - } -} diff --git a/lib/src/infrastructure/builders/scenario_file_builder.dart b/lib/src/infrastructure/builders/scenario_file_builder.dart new file mode 100644 index 0000000..db62f61 --- /dev/null +++ b/lib/src/infrastructure/builders/scenario_file_builder.dart @@ -0,0 +1,115 @@ +import '../../extensions/string_x.dart'; +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 [], + 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) { + buffer.writeln("class ${feature.name}Background {"); + for (var step in feature.background!.steps) { + final methodName = step.methodName; + final params = extractMethodParams(step.text); + buffer.writeln( + " Future $methodName(${params.isNotEmpty ? params : ''}) async {", + ); + buffer.writeln(" // TODO: Implement ${step.keyword} ${step.text}"); + buffer.writeln(" }"); + buffer.writeln(); + } + buffer.writeln("}"); + buffer.writeln(); + } + + for (var scenario in feature.scenarios) { + _writeScenarioClass(buffer, feature, scenario, scenarioSuffix); + } + + return buffer.toString(); + } + + /// 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, { + String scenarioSuffix = 'Scenario', + }) { + final buffer = StringBuffer(); + for (var scenario in newScenarios) { + _writeScenarioClass(buffer, feature, scenario, scenarioSuffix); + } + return buffer.toString(); + } + + void _writeScenarioClass( + StringBuffer buffer, + Feature feature, + Scenario scenario, + String scenarioSuffix, + ) { + final isUnitTest = scenario.isUnitTestWithFeature(feature.decorators); + final className = scenario.classNameWithSuffix(scenarioSuffix); + + buffer.writeln("class $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(); + } +} + +/// 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+)>'); + final matches = regex.allMatches(stepText); + + for (var match in matches) { + final paramName = match.group(1)!; + params.add('String ${paramName.snakeCaseToCamelCase}'); + } + + return params.join(', '); +} diff --git a/lib/src/infrastructure/builders/test_file_builder.dart b/lib/src/infrastructure/builders/test_file_builder.dart new file mode 100644 index 0000000..911b8e5 --- /dev/null +++ b/lib/src/infrastructure/builders/test_file_builder.dart @@ -0,0 +1,97 @@ +import '../../constraints/file_constraint.dart'; +import '../../domain/feature.dart'; +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', + }) async { + final buffer = StringBuffer(); + + buffer.writeln("import 'package:flutter_test/flutter_test.dart';"); + buffer.writeln("import '${feature.fileName}${FileConstraint.generatedScenarios}';"); + buffer.writeln(); + + buffer.writeln("void main() {"); + buffer.writeln(" group('${feature.name}', () {"); + + for (var scenario in feature.scenarios) { + final className = scenario.classNameWithSuffix(scenarioSuffix); + final isUnitTest = scenario.isUnitTestWithFeature(feature.decorators); + final testFunction = isUnitTest ? 'test' : 'testWidgets'; + + if (isUnitTest) { + buffer.writeln(" $testFunction('${scenario.name}', () async {"); + } else { + buffer.writeln(" $testFunction('${scenario.name}', (tester) async {"); + } + + 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}"); + + if (scenario.examples != null && scenario.examples!.isNotEmpty) { + buffer.writeln(" final examples = ["); + + for (var example in scenario.examples!) { + buffer.write(" {"); + for (var entry in example.entries) { + buffer.write("'${entry.key.snakeCaseToCamelCase}': '${entry.value}',"); + } + buffer.write("},"); + buffer.writeln(); + } + buffer.writeln(" ];"); + + final exampleKeys = scenario.examples!.first.keys.toList(); + buffer.writeln(" for (var example in examples) {"); + + for (var step in scenario.steps) { + final params = []; + for (var key in exampleKeys) { + if (step.text.contains('<$key>')) { + params.add("example['${key.snakeCaseToCamelCase}']!"); + } + } + + buffer.writeln(_generateStepCall(step, isUnitTest, params)); + } + buffer.writeln(" }"); + } else { + for (var step in scenario.steps) { + buffer.writeln(_generateStepCall(step, isUnitTest, [])); + } + } + buffer.writeln(" });"); + } + + buffer.writeln(" });"); + buffer.writeln("}"); + + return buffer.toString(); + } +} + +String _generateStepCall(Step step, bool isUnitTest, List params) { + final methodName = step.methodName; + 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 new file mode 100644 index 0000000..44c8065 --- /dev/null +++ b/lib/src/infrastructure/parsers/config_parser.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +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'; + + final String configFile; + + ConfigParser({String? configFile}) : configFile = configFile ?? defaultConfigFile; + + /// Loads the config file and returns a [BDDConfig]. + 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( + 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', + ); + } + + List _parseStringList(dynamic value) { + if (value is YamlList) { + return value.map((e) => e.toString()).toList(); + } + return []; + } +} diff --git a/lib/src/infrastructure/parsers/feature_parser.dart b/lib/src/infrastructure/parsers/feature_parser.dart new file mode 100644 index 0000000..d070df0 --- /dev/null +++ b/lib/src/infrastructure/parsers/feature_parser.dart @@ -0,0 +1,145 @@ +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'; + +/// 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 { + 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; + List currentScenarioDecorators = []; + + 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) { + featureDecorators.add(Decorator.fromString(line)); + } + //parsing scenario decorators + else { + isParsingBackground = false; + if (currentScenario != null) { + currentScenario.examples = currentExampleContent?.examples; + scenarios.add(currentScenario); + currentExampleContent = null; + currentScenario = null; + currentScenarioDecorators = []; + } + + 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; + final name = line.substring('Scenario:'.length).trim(); + if (currentScenario != null) { + currentScenario.examples = currentExampleContent?.examples; + scenarios.add(currentScenario); + currentExampleContent = null; + } + + currentScenario = Scenario( + name, + [], + decorators: currentScenarioDecorators.toSet(), + ); + 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, + ); + } +} + +/// Accumulates example table headers and rows during parsing. +class ExampleContent { + List headers = []; + List> values = []; + + List> get examples { + return values.map((value) => Map.fromIterables(headers, value)).toList(); + } +} diff --git a/lib/src/infrastructure/parsers/manifest_parser.dart b/lib/src/infrastructure/parsers/manifest_parser.dart new file mode 100644 index 0000000..c0c3e2d --- /dev/null +++ b/lib/src/infrastructure/parsers/manifest_parser.dart @@ -0,0 +1,113 @@ +import 'dart:io'; + +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'; + + final String manifestDir; + final String manifestFile; + + ManifestParser({String? manifestDir, String? manifestFile}) + : 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); + + 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, + ); + } + + /// 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()) { + 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()); + } + + /// 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; + } + return null; + } +} diff --git a/lib/src/presentation/cli/bbd_cli.dart b/lib/src/presentation/cli/bbd_cli.dart new file mode 100644 index 0000000..6226261 --- /dev/null +++ b/lib/src/presentation/cli/bbd_cli.dart @@ -0,0 +1,62 @@ +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'; + +/// 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; + + BDDCLI({ + BDDController? bddController, + ConfigParser? configParser, + }) : _bddController = bddController ?? BDDController(), + _configParser = configParser ?? ConfigParser(); + + /// Parses [arguments] and executes the corresponding command. + Future run(List arguments) async { + if (arguments.isEmpty) { + _printUsage(); + return; + } + + final command = arguments.first; + final flags = arguments.skip(1).toSet(); + + switch (command) { + case 'build': + final options = BuildOptions( + force: flags.contains('--force'), + ); + 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(); + } + } + + void _printUsage() { + 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(' test Run BDD tests with formatted report'); + stdout.writeln(''); + stdout.writeln('Build flags:'); + 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 new file mode 100644 index 0000000..1c6c9db --- /dev/null +++ b/lib/src/presentation/controllers/bdd_controller.dart @@ -0,0 +1,184 @@ +import 'dart:io'; + +import '../../domain/build_options.dart'; +import '../../domain/config.dart'; +import '../../domain/feature.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'; + +/// 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; + 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(), + _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(); + + 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 "${config.testDir}".'); + return; + } + + stdout.writeln('Found ${featureFiles.length} feature file(s).'); + + final updatedFeatures = []; + int generated = 0; + int appended = 0; + int skipped = 0; + + for (var featureFile in featureFiles) { + final feature = await _featureParser.parseFeature(featureFile.path); + + if (config.ignoreFeatures.any((ignored) => featureFile.path.endsWith(ignored))) { + stdout.writeln(' Skipped (config): ${featureFile.path}'); + skipped++; + continue; + } + + 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'); + + if (options.force || existingEntry == null) { + await _generateFull(feature, scenarioPath, testPath, config); + stdout.writeln(' Generated: $scenarioPath'); + stdout.writeln(' Generated: $testPath'); + generated++; + updatedFeatures.add(_buildManifestEntry(featureFile, feature, testPath, config)); + continue; + } + + // 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(); + + if (newScenarios.isEmpty) { + stdout.writeln(' Skipped (unchanged): ${featureFile.path}'); + skipped++; + updatedFeatures.add(existingEntry); + continue; + } + + // 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 { + await _generateFull(feature, scenarioPath, testPath, config); + stdout.writeln(' Generated: $scenarioPath'); + stdout.writeln(' Generated: $testPath'); + generated++; + updatedFeatures.add(_buildManifestEntry(featureFile, feature, testPath, config)); + continue; + } + + // 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(', '); + stdout.writeln(' Appended new scenarios to: $scenarioPath ($newNames)'); + stdout.writeln(' Regenerated: $testPath'); + appended++; + + updatedFeatures.add(_buildManifestEntry(featureFile, feature, testPath, config)); + } + + final updatedManifest = Manifest(features: updatedFeatures); + await _manifestParser.saveManifest(updatedManifest); + + 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, + 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, + BDDConfig config, + ) { + 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.classNameWithSuffix(config.scenarioSuffix)}', + )) + .toList(), + ); + } +} 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..3feec7f --- /dev/null +++ b/lib/src/presentation/reporter/bdd_report_formatter.dart @@ -0,0 +1,116 @@ +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'; + +/// 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; + + 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()); + } +} + +/// Aggregated test results for a single feature. +class FeatureReport { + final String name; + final List scenarios = []; + + FeatureReport({required this.name}); +} + +/// The result of a single scenario test execution. +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..fdc1260 --- /dev/null +++ b/lib/src/presentation/reporter/bdd_test_runner.dart @@ -0,0 +1,175 @@ +import 'dart:convert'; +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; + + BDDTestRunner({ + BDDReportFormatter? formatter, + 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(); + + // 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; + } + } +} 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/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 diff --git a/plan/refactor.md b/plan/refactor.md new file mode 100644 index 0000000..781ccb7 --- /dev/null +++ b/plan/refactor.md @@ -0,0 +1,217 @@ +# 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: Polish + +**Goal**: Clean up, final docs. + +~~Rename command removed~~ — manifest tracking makes it unnecessary. The `.bdd_` prefix signals generated files for `.gitignore`, and incremental builds avoid overwriting implemented steps. + +### 4.1 Cleanup +- Remove all remaining debug `print()` statements +- Update CLAUDE.md to reflect final architecture +- Update README to remove `rename` command references + +### 4.2 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 1c1997c..02a7557 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,21 +1,24 @@ 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: 1.0.0 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 + - test + - widgettest + - gherkin dependencies: - flutter: - sdk: flutter - yaml: ^3.0.0 - build: ^2.3.0 + crypto: ^3.0.0 + yaml: ^3.1.0 dev_dependencies: + flutter_lints: ^2.0.1 + test: ^1.24.0 flutter_test: sdk: flutter - flutter_lints: ^5.0.0 - build_runner: ^2.3.0 - test: ^1.20.0 diff --git a/test/builders/scenario_file_builder_test.dart b/test/builders/scenario_file_builder_test.dart new file mode 100644 index 0000000..b179217 --- /dev/null +++ b/test/builders/scenario_file_builder_test.dart @@ -0,0 +1,190 @@ +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('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', + 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 {')); + }); + + 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/builders/test_file_builder_test.dart b/test/builders/test_file_builder_test.dart new file mode 100644 index 0000000..c2f6514 --- /dev/null +++ b/test/builders/test_file_builder_test.dart @@ -0,0 +1,166 @@ +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('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/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/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/parsers/config_parser_test.dart b/test/parsers/config_parser_test.dart new file mode 100644 index 0000000..0d42335 --- /dev/null +++ b/test/parsers/config_parser_test.dart @@ -0,0 +1,109 @@ +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.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 { + Directory('${tempDir.path}/.bdd_flutter').createSync(); + File('${tempDir.path}/.bdd_flutter/config.yaml').writeAsStringSync(''' +generate_widget_tests: false +ignore_features: + - test/features/login.feature + - test/features/signup.feature +'''); + + final config = await parserInDir().loadConfig(); + + expect(config.generateWidgetTests, isFalse); + 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); + }); + + test('handles partial config', () async { + Directory('${tempDir.path}/.bdd_flutter').createSync(); + File('${tempDir.path}/.bdd_flutter/config.yaml').writeAsStringSync(''' +generate_widget_tests: false +'''); + + final config = await parserInDir().loadConfig(); + + 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')); + }); + }); +} diff --git a/test/parsers/feature_parser_test.dart b/test/parsers/feature_parser_test.dart new file mode 100644 index 0000000..70c3778 --- /dev/null +++ b/test/parsers/feature_parser_test.dart @@ -0,0 +1,221 @@ +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'; + +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 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 @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 + 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/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')); + }); + }); +}