diff --git a/CHANGELOG.md b/CHANGELOG.md index e27cda4..268bb00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [2.1.3] - Data table special characters fix + +* Make placeholder replacement function more context-aware to avoid replacing special characters inside data table cells + ## [2.1.2] - Gherkin comments support * Add support for Gherkin comments (lines starting with `#`) diff --git a/lib/src/scenario_generator.dart b/lib/src/scenario_generator.dart index b5c9354..55fcc32 100644 --- a/lib/src/scenario_generator.dart +++ b/lib/src/scenario_generator.dart @@ -152,10 +152,59 @@ String _replacePlaceholders( return headReplaced + tailReplaced; } } - // Default behaviour: placeholders become parameters - var replaced = line; - for (final e in example.keys) { - replaced = replaced.replaceAll('<$e>', '{${example[e]}}'); + + return _replacePlaceholdersWithContext(line, example); +} + +// Placeholders inside {} blocks become raw values, +// Placeholders outside {} blocks become parameters (wrapped with {}) +String _replacePlaceholdersWithContext( + String line, + Map example, +) { + final result = StringBuffer(); + var i = 0; + var braceDepth = 0; + + while (i < line.length) { + // Track brace depth to know if we're inside a parameter block + if (line[i] == '{') { + braceDepth++; + result.write('{'); + i++; + } else if (line[i] == '}') { + braceDepth--; + result.write('}'); + i++; + } else if (line[i] == '<') { + // Check if this is a placeholder + var foundPlaceholder = false; + for (final key in example.keys) { + final placeholder = '<$key>'; + if (i + placeholder.length <= line.length && + line.substring(i, i + placeholder.length) == placeholder) { + // Found a placeholder + if (braceDepth > 0) { + // Inside a parameter block - use raw value + result.write(example[key]); + } else { + // Outside parameter blocks - wrap with {} + result.write('{${example[key]}}'); + } + i += placeholder.length; + foundPlaceholder = true; + break; + } + } + if (!foundPlaceholder) { + result.write(line[i]); + i++; + } + } else { + result.write(line[i]); + i++; + } } - return replaced; + + return result.toString(); } diff --git a/pubspec.yaml b/pubspec.yaml index cc4d5b9..8975534 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: bdd_widget_test description: A BDD-style widget testing library. Generates Flutter widget tests from *.feature files. -version: 2.1.2 +version: 2.1.3 repository: https://github.com/olexale/bdd_widget_test issue_tracker: https://github.com/olexale/bdd_widget_test/issues diff --git a/test/data_tables_test.dart b/test/data_tables_test.dart index 5d53648..e2cefff 100644 --- a/test/data_tables_test.dart +++ b/test/data_tables_test.dart @@ -568,6 +568,118 @@ void main() { }); }); } +'''; + + final feature = FeatureFile( + featureDir: 'test.feature', + package: 'test', + input: featureFile, + ); + expect(feature.dartContent, expectedFeatureDart); + }); + + test('Data table with single row (headers only)', () { + const featureFile = ''' +Feature: Testing feature + Scenario: Testing scenario + Given the following songs + | 'artist' | 'title' | +'''; + + const expectedFeatureDart = ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +import 'package:bdd_widget_test/data_table.dart' as bdd; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './step/the_following_songs.dart'; + +void main() { + group(\'\'\'Testing feature\'\'\', () { + testWidgets(\'\'\'Testing scenario\'\'\', (tester) async { + await theFollowingSongs(tester, const bdd.DataTable([['artist', 'title']])); + }); + }); +} +'''; + + final feature = FeatureFile( + featureDir: 'test.feature', + package: 'test', + input: featureFile, + ); + expect(feature.dartContent, expectedFeatureDart); + }); + + test('Data table with special characters in cells', () { + const featureFile = ''' +Feature: Testing feature + Scenario: Testing scenario + Given the following data + | 'name' | 'description' | + | 'Test "One"' | 'Has quotes' | + | 'Test ' | 'Has angle brackets' | + | 'Test {3}' | 'Has braces' | + | 'Test, Four' | 'Has comma' | +'''; + + const expectedFeatureDart = ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +import 'package:bdd_widget_test/data_table.dart' as bdd; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './step/the_following_data.dart'; + +void main() { + group(\'\'\'Testing feature\'\'\', () { + testWidgets(\'\'\'Testing scenario\'\'\', (tester) async { + await theFollowingData(tester, const bdd.DataTable([['name', 'description'], ['Test "One"', 'Has quotes'], ['Test ', 'Has angle brackets'], ['Test {3}', 'Has braces'], ['Test, Four', 'Has comma']])); + }); + }); +} +'''; + + final feature = FeatureFile( + featureDir: 'test.feature', + package: 'test', + input: featureFile, + ); + expect(feature.dartContent, expectedFeatureDart); + }); + + test('Data table with unicode and emoji', () { + const featureFile = ''' +Feature: Testing feature + Scenario: Testing scenario + Given the following items + | 'emoji' | 'description' | + | 'šŸš€' | 'Rocket' | + | 'šŸ’Æ' | 'Perfect' | + | 'ƑoƱo' | 'Spanish' | +'''; + + const expectedFeatureDart = ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +import 'package:bdd_widget_test/data_table.dart' as bdd; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './step/the_following_items.dart'; + +void main() { + group(\'\'\'Testing feature\'\'\', () { + testWidgets(\'\'\'Testing scenario\'\'\', (tester) async { + await theFollowingItems(tester, const bdd.DataTable([['emoji', 'description'], ['šŸš€', 'Rocket'], ['šŸ’Æ', 'Perfect'], ['ƑoƱo', 'Spanish']])); + }); + }); +} '''; final feature = FeatureFile( diff --git a/test/feature_test.dart b/test/feature_test.dart index 1263057..78bfb1c 100644 --- a/test/feature_test.dart +++ b/test/feature_test.dart @@ -1,5 +1,4 @@ import 'package:bdd_widget_test/src/feature_file.dart'; -import 'package:bdd_widget_test/src/generator_options.dart'; import 'package:test/test.dart'; import 'util/testing_data.dart'; @@ -108,18 +107,49 @@ void main() { ); expect(feature.dartContent, expectedFeatureDart); }); - test('custom headers replace default imports in feature file', () async { + + test('Feature with special characters in names', () { const expectedFeatureDart = ''' -${expectedComment}import 'package:patrol/patrol.dart'; -// Import flutter_test for compatibility -import 'package:flutter_test/flutter_test.dart'; +$expectedComment// some comment + +${expectedImports}import './step/the_app_is_running.dart'; +import './step/i_see_text.dart'; + +void main() { + group(\'\'\'"Testing" {Characters}\'\'\', () { + testWidgets(\'\'\'Test's "special" characters\'\'\', (tester) async { + await theAppIsRunning(tester); + await iSeeText(tester, 'test'); + }); + }); +} +'''; -import './step/the_app_is_running.dart'; + final feature = FeatureFile( + featureDir: 'test.feature', + package: 'test', + input: ''' +// some comment + +Feature: "Testing" {Characters} + Scenario: Test's "special" characters + Given the app is running + Then I see {'test'} text +''', + ); + expect(feature.dartContent, expectedFeatureDart); + }); + + test('Feature with very long step description', () { + const expectedFeatureDart = ''' +${expectedHeader}import './step/the_app_is_running.dart'; +import './step/i_verify_that_this_is_a_very_long_step_description_that_tests_whether_the_framework_can_handle_extremely_long_step_names_without_issues.dart'; void main() { group(\'\'\'Testing feature\'\'\', () { testWidgets(\'\'\'Testing scenario\'\'\', (tester) async { await theAppIsRunning(tester); + await iVerifyThatThisIsAVeryLongStepDescriptionThatTestsWhetherTheFrameworkCanHandleExtremelyLongStepNamesWithoutIssues(tester); }); }); } @@ -128,14 +158,55 @@ void main() { final feature = FeatureFile( featureDir: 'test.feature', package: 'test', - input: minimalFeatureFile, - generatorOptions: const GeneratorOptions( - customHeaders: [ - "import 'package:patrol/patrol.dart';", - '// Import flutter_test for compatibility', - "import 'package:flutter_test/flutter_test.dart';", - ], - ), + input: ''' +Feature: Testing feature + Scenario: Testing scenario + Given the app is running + Then I verify that this is a very long step description that tests whether the framework can handle extremely long step names without issues +''', + ); + expect(feature.dartContent, expectedFeatureDart); + }); + + test('Multiple scenarios in single feature', () { + const expectedFeatureDart = ''' +${expectedHeader}import './step/the_app_is_running.dart'; +import './step/i_see_text.dart'; +import './step/i_tap_icon.dart'; + +void main() { + group(\'\'\'Login feature\'\'\', () { + testWidgets(\'\'\'Successful login\'\'\', (tester) async { + await theAppIsRunning(tester); + await iSeeText(tester, 'Login'); + }); + testWidgets(\'\'\'Failed login\'\'\', (tester) async { + await theAppIsRunning(tester); + await iSeeText(tester, 'Error'); + }); + testWidgets(\'\'\'Logout\'\'\', (tester) async { + await iTapIcon(tester, Icons.logout); + }); + }); +} +'''; + + final feature = FeatureFile( + featureDir: 'test.feature', + package: 'test', + input: ''' +Feature: Login feature + Scenario: Successful login + Given the app is running + Then I see {'Login'} text + + Scenario: Failed login + Given the app is running + Then I see {'Error'} text + + Scenario: Logout + When I tap {Icons.logout} icon +''', ); expect(feature.dartContent, expectedFeatureDart); }); diff --git a/test/scenario_outline_test.dart b/test/scenario_outline_test.dart index 49fa0e4..bd65fa7 100644 --- a/test/scenario_outline_test.dart +++ b/test/scenario_outline_test.dart @@ -82,6 +82,421 @@ void main() { }); }); } +'''; + + final feature = FeatureFile( + featureDir: 'test.feature', + package: 'test', + input: featureFile, + ); + expect(feature.dartContent, expectedFeatureDart); + }); + + test('Scenario Outline Variables in Array', () { + const featureFile = ''' +Feature: Testing feature + Scenario Outline: Variables in array + When I tap {[, ]} times + + Examples: + | plus | minus | + | 1 | 2 | + | 42 | -84 | +'''; + + const expectedFeatureDart = ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './step/i_tap_times.dart'; + +void main() { + group(\'\'\'Testing feature\'\'\', () { + testWidgets(\'\'\'Outline: Variables in array (1, 2)\'\'\', (tester) async { + await iTapTimes(tester, [1, 2]); + }); + testWidgets(\'\'\'Outline: Variables in array (42, -84)\'\'\', (tester) async { + await iTapTimes(tester, [42, -84]); + }); + }); +} +'''; + + final feature = FeatureFile( + featureDir: 'test.feature', + package: 'test', + input: featureFile, + ); + expect(feature.dartContent, expectedFeatureDart); + }); + + test('Scenario Outline Variables in Named Parameters', () { + const featureFile = ''' +Feature: Testing feature + Scenario Outline: Variables in named parameter variable + When I tap {plus: , minus: } times + + Examples: + | plus | minus | + | 1 | 2 | + | 42 | -84 | +'''; + + const expectedFeatureDart = ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './step/i_tap_times.dart'; + +void main() { + group(\'\'\'Testing feature\'\'\', () { + testWidgets(\'\'\'Outline: Variables in named parameter variable (1, 2)\'\'\', (tester) async { + await iTapTimes(tester, plus: 1, minus: 2); + }); + testWidgets(\'\'\'Outline: Variables in named parameter variable (42, -84)\'\'\', (tester) async { + await iTapTimes(tester, plus: 42, minus: -84); + }); + }); +} +'''; + + final feature = FeatureFile( + featureDir: 'test.feature', + package: 'test', + input: featureFile, + ); + expect(feature.dartContent, expectedFeatureDart); + }); + + test('Scenario Outline Variables Mixed Context', () { + const featureFile = ''' +Feature: Testing feature + Scenario Outline: Mixed variables + When I perform with {data: [, ]} and parameter + + Examples: + | action | value1 | value2 | standalone | + | 'tap' | 1 | 2 | Icons.add | +'''; + + const expectedFeatureDart = ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './step/i_perform_with_and_parameter.dart'; + +void main() { + group(\'\'\'Testing feature\'\'\', () { + testWidgets(\'\'\'Outline: Mixed variables ('tap', 1, 2, Icons.add)\'\'\', (tester) async { + await iPerformWithAndParameter(tester, 'tap', data: [1, 2], Icons.add); + }); + }); +} +'''; + + final feature = FeatureFile( + featureDir: 'test.feature', + package: 'test', + input: featureFile, + ); + expect(feature.dartContent, expectedFeatureDart); + }); + + test('Scenario Outline Variables with Non-Placeholder Angle Brackets', () { + const featureFile = ''' +Feature: Testing feature + Scenario Outline: Non-placeholder angle brackets + When I check if is less than + + Examples: + | value | other | + | 5 | 10 | +'''; + + const expectedFeatureDart = ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './step/i_check_if_is_less_than.dart'; + +void main() { + group(\'\'\'Testing feature\'\'\', () { + testWidgets(\'\'\'Outline: Non-placeholder angle brackets (5, 10)\'\'\', (tester) async { + await iCheckIfIsLessThan(tester, 5, 10); + }); + }); +} +'''; + + final feature = FeatureFile( + featureDir: 'test.feature', + package: 'test', + input: featureFile, + ); + expect(feature.dartContent, expectedFeatureDart); + }); + + test('Scenario Outline Nested Braces with Variables', () { + const featureFile = ''' +Feature: Testing feature + Scenario Outline: Nested braces + When I use {outer: {inner: }} structure + + Examples: + | value | + | 42 | +'''; + + const expectedFeatureDart = ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './step/i_use_structure.dart'; + +void main() { + group(\'\'\'Testing feature\'\'\', () { + testWidgets(\'\'\'Outline: Nested braces (42)\'\'\', (tester) async { + await iUseStructure(tester, outer: {inner: 42}); + }); + }); +} +'''; + + final feature = FeatureFile( + featureDir: 'test.feature', + package: 'test', + input: featureFile, + ); + expect(feature.dartContent, expectedFeatureDart); + }); + + test('Scenario Outline Multiple Variables in Same Braces', () { + const featureFile = ''' +Feature: Testing feature + Scenario Outline: Multiple vars same block + When I process {a: , b: , c: } data + + Examples: + | first | second | third | + | 1 | 2 | 3 | +'''; + + const expectedFeatureDart = ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './step/i_process_data.dart'; + +void main() { + group(\'\'\'Testing feature\'\'\', () { + testWidgets(\'\'\'Outline: Multiple vars same block (1, 2, 3)\'\'\', (tester) async { + await iProcessData(tester, a: 1, b: 2, c: 3); + }); + }); +} +'''; + + final feature = FeatureFile( + featureDir: 'test.feature', + package: 'test', + input: featureFile, + ); + expect(feature.dartContent, expectedFeatureDart); + }); + + test('Scenario Outline Variables Outside and Inside Braces', () { + const featureFile = ''' +Feature: Testing feature + Scenario Outline: Mix outside and inside + When I call with {args: [, ]} + + Examples: + | method | arg1 | arg2 | + | 'run' | 10 | 20 | +'''; + + const expectedFeatureDart = ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './step/i_call_with.dart'; + +void main() { + group(\'\'\'Testing feature\'\'\', () { + testWidgets(\'\'\'Outline: Mix outside and inside ('run', 10, 20)\'\'\', (tester) async { + await iCallWith(tester, 'run', args: [10, 20]); + }); + }); +} +'''; + + final feature = FeatureFile( + featureDir: 'test.feature', + package: 'test', + input: featureFile, + ); + expect(feature.dartContent, expectedFeatureDart); + }); + + test('Scenario Outline Sequential Variables', () { + const featureFile = ''' +Feature: Testing feature + Scenario Outline: Sequential variables + When I add and and + + Examples: + | first | second | third | + | 1 | 2 | 3 | +'''; + + const expectedFeatureDart = ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './step/i_add_and_and.dart'; + +void main() { + group(\'\'\'Testing feature\'\'\', () { + testWidgets(\'\'\'Outline: Sequential variables (1, 2, 3)\'\'\', (tester) async { + await iAddAndAnd(tester, 1, 2, 3); + }); + }); +} +'''; + + final feature = FeatureFile( + featureDir: 'test.feature', + package: 'test', + input: featureFile, + ); + expect(feature.dartContent, expectedFeatureDart); + }); + + test('Scenario Outline With Non-Placeholder Angle Bracket', () { + const featureFile = ''' +Feature: Testing feature + Scenario Outline: Angle bracket not a placeholder + When I process {operator: <, value: } data + + Examples: + | val | + | 50 | +'''; + + const expectedFeatureDart = ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './step/i_process_data.dart'; + +void main() { + group(\'\'\'Testing feature\'\'\', () { + testWidgets(\'\'\'Outline: Angle bracket not a placeholder (50)\'\'\', (tester) async { + await iProcessData(tester, operator: <, value: 50); + }); + }); +} +'''; + + final feature = FeatureFile( + featureDir: 'test.feature', + package: 'test', + input: featureFile, + ); + expect(feature.dartContent, expectedFeatureDart); + }); + + test('Scenario Outline with Empty Examples Table', () { + const featureFile = ''' +Feature: Testing feature + Scenario Outline: Empty outline + When I test parameter + + Examples: + | value | +'''; + + const expectedFeatureDart = ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './step/i_test_parameter.dart'; + +void main() { + group(\'\'\'Testing feature\'\'\', () { + }); +} +'''; + + final feature = FeatureFile( + featureDir: 'test.feature', + package: 'test', + input: featureFile, + ); + expect(feature.dartContent, expectedFeatureDart); + }); + + test('Scenario Outline with Unicode Characters in Variables', () { + const featureFile = ''' +Feature: Testing feature + Scenario Outline: Unicode variables + When I see icon + + Examples: + | emoji | + | 'šŸš€' | + | 'šŸ’Æ' | +'''; + + const expectedFeatureDart = ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './step/i_see_icon.dart'; + +void main() { + group(\'\'\'Testing feature\'\'\', () { + testWidgets(\'\'\'Outline: Unicode variables ('šŸš€')\'\'\', (tester) async { + await iSeeIcon(tester, 'šŸš€'); + }); + testWidgets(\'\'\'Outline: Unicode variables ('šŸ’Æ')\'\'\', (tester) async { + await iSeeIcon(tester, 'šŸ’Æ'); + }); + }); +} '''; final feature = FeatureFile(