diff --git a/README.md b/README.md index 193a333..468ae67 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ checkout [`docs/FRs.md`](docs/FRs.md) ```bash flutter pub get -# flutter run build_runner build --delete-conflicting-outputs +# flutter pub run build_runner build --delete-conflicting-outputs +# dart run tool/prune_unused_steps.dart --delete # run the app flutter run diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..dbc49bd 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,6 +9,10 @@ # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml +analyzer: + plugins: + - custom_lint + linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..4c29746 --- /dev/null +++ b/build.yaml @@ -0,0 +1,11 @@ +targets: + $default: + sources: + - integration_test/** + - test/** + - lib/** + - $package$ + builders: + pdf_signature|prune_unused_steps: + generate_for: + - test/features/** diff --git a/lib/data/services/export_service.dart b/lib/data/services/export_service.dart index 4aee5e0..41cad57 100644 --- a/lib/data/services/export_service.dart +++ b/lib/data/services/export_service.dart @@ -34,9 +34,9 @@ class ExportService { required Uint8List? signatureImageBytes, double targetDpi = 144.0, }) async { - print( - 'exportSignedPdfFromFile: enter signedPage=$signedPage outputPath=$outputPath', - ); + // print( + // 'exportSignedPdfFromFile: enter signedPage=$signedPage outputPath=$outputPath', + // ); // Read source bytes and delegate to bytes-based exporter Uint8List? srcBytes; try { diff --git a/lib/ui/features/pdf/widgets/pdf_screen.dart b/lib/ui/features/pdf/widgets/pdf_screen.dart index 339e2d7..f3654c6 100644 --- a/lib/ui/features/pdf/widgets/pdf_screen.dart +++ b/lib/ui/features/pdf/widgets/pdf_screen.dart @@ -93,8 +93,10 @@ class _PdfSignatureHomePageState extends ConsumerState { Future _saveSignedPdf() async { final pdf = ref.read(pdfProvider); final sig = ref.read(signatureProvider); + // Cache messenger before any awaits to avoid using BuildContext across async gaps. + final messenger = ScaffoldMessenger.of(context); if (!pdf.loaded || sig.rect == null) { - ScaffoldMessenger.of(context).showSnackBar( + messenger.showSnackBar( const SnackBar( content: Text('Nothing to save yet'), ), // guard per use-case @@ -180,24 +182,24 @@ class _PdfSignatureHomePageState extends ConsumerState { if (!kIsWeb) { // Desktop/mobile: we had a concrete path if (ok) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Saved: ${savedPath ?? ''}'))); + messenger.showSnackBar( + SnackBar(content: Text('Saved: ${savedPath ?? ''}')), + ); } else { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Failed to save PDF'))); + messenger.showSnackBar( + const SnackBar(content: Text('Failed to save PDF')), + ); } } else { // Web: indicate whether we triggered a download dialog if (ok) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Download started'))); + messenger.showSnackBar( + const SnackBar(content: Text('Download started')), + ); } else { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Failed to generate PDF'))); + messenger.showSnackBar( + const SnackBar(content: Text('Failed to generate PDF')), + ); } } } @@ -461,7 +463,10 @@ class _PdfSignatureHomePageState extends ConsumerState { ), child: DecoratedBox( decoration: BoxDecoration( - color: Colors.black.withOpacity( + color: Color.fromRGBO( + 0, + 0, + 0, 0.05 + math.min(0.25, (sig.contrast - 1.0).abs()), ), border: Border.all(color: Colors.indigo, width: 2), diff --git a/pubspec.yaml b/pubspec.yaml index 8acb1b3..83edb50 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,7 +51,10 @@ dev_dependencies: flutter_test: sdk: flutter build_runner: ^2.4.12 + build: ^3.0.2 bdd_widget_test: ^2.0.1 + custom_lint: ^0.7.6 + riverpod_lint: ^2.6.5 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is diff --git a/test/features/load_signature_picture.feature b/test/features/load_signature_picture.feature index 3bc9a9a..7a15876 100644 --- a/test/features/load_signature_picture.feature +++ b/test/features/load_signature_picture.feature @@ -12,7 +12,7 @@ Feature: load signature picture And the image is not added to the document Examples: - | file | - | corrupted.png | - | signature.bmp | - | empty.jpg | + | file | + | 'corrupted.png' | + | 'signature.bmp' | + | 'empty.jpg' | diff --git a/test/features/load_signature_picture_test.dart b/test/features/load_signature_picture_test.dart index c36c625..5de829a 100644 --- a/test/features/load_signature_picture_test.dart +++ b/test/features/load_signature_picture_test.dart @@ -11,7 +11,6 @@ import './step/the_user_selects.dart'; import './step/the_app_attempts_to_load_the_image.dart'; import './step/the_user_is_notified_of_the_issue.dart'; import './step/the_image_is_not_added_to_the_document.dart'; -import './step/_tokens.dart'; void main() { group('''load signature picture''', () { @@ -21,31 +20,28 @@ void main() { await theImageIsLoadedAndShownAsASignatureAsset(tester); }); testWidgets( - '''Outline: Handle invalid or unsupported files (corrupted.png)''', - (tester) async { - await theUserSelects(tester, corrupted.png); - await theAppAttemptsToLoadTheImage(tester); - await theUserIsNotifiedOfTheIssue(tester); - await theImageIsNotAddedToTheDocument(tester); - }, - ); + '''Outline: Handle invalid or unsupported files ('corrupted.png')''', + (tester) async { + await theUserSelects(tester, 'corrupted.png'); + await theAppAttemptsToLoadTheImage(tester); + await theUserIsNotifiedOfTheIssue(tester); + await theImageIsNotAddedToTheDocument(tester); + }); testWidgets( - '''Outline: Handle invalid or unsupported files (signature.bmp)''', - (tester) async { - await theUserSelects(tester, signature.bmp); - await theAppAttemptsToLoadTheImage(tester); - await theUserIsNotifiedOfTheIssue(tester); - await theImageIsNotAddedToTheDocument(tester); - }, - ); + '''Outline: Handle invalid or unsupported files ('signature.bmp')''', + (tester) async { + await theUserSelects(tester, 'signature.bmp'); + await theAppAttemptsToLoadTheImage(tester); + await theUserIsNotifiedOfTheIssue(tester); + await theImageIsNotAddedToTheDocument(tester); + }); testWidgets( - '''Outline: Handle invalid or unsupported files (empty.jpg)''', - (tester) async { - await theUserSelects(tester, empty.jpg); - await theAppAttemptsToLoadTheImage(tester); - await theUserIsNotifiedOfTheIssue(tester); - await theImageIsNotAddedToTheDocument(tester); - }, - ); + '''Outline: Handle invalid or unsupported files ('empty.jpg')''', + (tester) async { + await theUserSelects(tester, 'empty.jpg'); + await theAppAttemptsToLoadTheImage(tester); + await theUserIsNotifiedOfTheIssue(tester); + await theImageIsNotAddedToTheDocument(tester); + }); }); } diff --git a/test/features/save_signed_pdf_test.dart b/test/features/save_signed_pdf_test.dart index f9e91ed..4e5563b 100644 --- a/test/features/save_signed_pdf_test.dart +++ b/test/features/save_signed_pdf_test.dart @@ -23,49 +23,32 @@ import './step/the_user_cannot_edit_the_document.dart'; void main() { group('''save signed PDF''', () { - testWidgets( - '''Export the signed document to a new file''', - (tester) async { - await aPdfIsOpenAndContainsAtLeastOnePlacedSignature(tester); - await theUserSavesexportsTheDocument(tester); - await aNewPdfFileIsSavedAtSpecifiedFullPathLocationAndFileName(tester); - await theSignaturesAppearOnTheCorrespondingPageInTheOutput(tester); - await keepOtherUnchangedContentpagesIntactInTheOutput(tester); - }, - timeout: const Timeout(Duration(seconds: 30)), - ); - testWidgets( - '''Vector-accurate stamping into PDF page coordinates''', - (tester) async { - await aSignatureIsPlacedWithAPositionAndSizeRelativeToThePage(tester); - await theUserSavesexportsTheDocument(tester); - await theSignatureIsStampedAtTheExactPdfPageCoordinatesAndSize(tester); - await theStampRemainsCrispAtAnyZoomLevelNotRasterizedByTheScreen( - tester, - ); - await otherPageContentRemainsVectorAndUnaltered(tester); - }, - timeout: const Timeout(Duration(seconds: 30)), - ); - testWidgets( - '''Prevent saving when nothing is placed''', - (tester) async { - await aPdfIsOpenWithNoSignaturesPlaced(tester); - await theUserAttemptsToSave(tester); - await theUserIsNotifiedThereIsNothingToSave(tester); - }, - timeout: const Timeout(Duration(seconds: 30)), - ); - testWidgets( - '''Loading sign when exporting/saving files''', - (tester) async { - await aSignatureIsPlacedWithAPositionAndSizeRelativeToThePage(tester); - await theUserStartsExportingTheDocument(tester); - await theExportProcessIsNotYetFinished(tester); - await theUserIsNotifiedThatTheExportIsStillInProgress(tester); - await theUserCannotEditTheDocument(tester); - }, - timeout: const Timeout(Duration(seconds: 30)), - ); + testWidgets('''Export the signed document to a new file''', (tester) async { + await aPdfIsOpenAndContainsAtLeastOnePlacedSignature(tester); + await theUserSavesexportsTheDocument(tester); + await aNewPdfFileIsSavedAtSpecifiedFullPathLocationAndFileName(tester); + await theSignaturesAppearOnTheCorrespondingPageInTheOutput(tester); + await keepOtherUnchangedContentpagesIntactInTheOutput(tester); + }); + testWidgets('''Vector-accurate stamping into PDF page coordinates''', + (tester) async { + await aSignatureIsPlacedWithAPositionAndSizeRelativeToThePage(tester); + await theUserSavesexportsTheDocument(tester); + await theSignatureIsStampedAtTheExactPdfPageCoordinatesAndSize(tester); + await theStampRemainsCrispAtAnyZoomLevelNotRasterizedByTheScreen(tester); + await otherPageContentRemainsVectorAndUnaltered(tester); + }); + testWidgets('''Prevent saving when nothing is placed''', (tester) async { + await aPdfIsOpenWithNoSignaturesPlaced(tester); + await theUserAttemptsToSave(tester); + await theUserIsNotifiedThereIsNothingToSave(tester); + }); + testWidgets('''Loading sign when exporting/saving files''', (tester) async { + await aSignatureIsPlacedWithAPositionAndSizeRelativeToThePage(tester); + await theUserStartsExportingTheDocument(tester); + await theExportProcessIsNotYetFinished(tester); + await theUserIsNotifiedThatTheExportIsStillInProgress(tester); + await theUserCannotEditTheDocument(tester); + }); }); } diff --git a/test/features/signature_state_logic.feature b/test/features/signature_state_logic.feature index e11b2cf..4d8827e 100644 --- a/test/features/signature_state_logic.feature +++ b/test/features/signature_state_logic.feature @@ -32,9 +32,4 @@ Feature: Signature state logic And signature rect right <= {400} And signature rect bottom <= {560} - Scenario: setImageBytes ensures a rect exists for display - Given a new provider container - Then signature rect is null - When I set tiny signature image bytes - Then signature image bytes is not null - And signature rect is not null + diff --git a/test/features/signature_state_logic_test.dart b/test/features/signature_state_logic_test.dart index 7a2f335..600ced6 100644 --- a/test/features/signature_state_logic_test.dart +++ b/test/features/signature_state_logic_test.dart @@ -19,9 +19,6 @@ import './step/signature_rect_moved_from_center.dart'; import './step/aspect_lock_is.dart'; import './step/i_resize_signature_by.dart'; import './step/signature_aspect_ratio_is_preserved_within.dart'; -import './step/i_set_tiny_signature_image_bytes.dart'; -import './step/signature_image_bytes_is_not_null.dart'; -import './step/signature_rect_is_not_null.dart'; void main() { group('''Signature state logic''', () { @@ -58,13 +55,5 @@ void main() { await signatureRectRight(tester, 400); await signatureRectBottom(tester, 560); }); - testWidgets('''setImageBytes ensures a rect exists for display''', - (tester) async { - await aNewProviderContainer(tester); - await signatureRectIsNull(tester); - await iSetTinySignatureImageBytes(tester); - await signatureImageBytesIsNotNull(tester); - await signatureRectIsNotNull(tester); - }); }); } diff --git a/test/features/step/_helpers.dart b/test/features/step/_helpers.dart index 743f6fd..94c06da 100644 --- a/test/features/step/_helpers.dart +++ b/test/features/step/_helpers.dart @@ -2,7 +2,6 @@ import 'dart:typed_data'; import 'dart:ui' show Rect, Size; import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; import '_world.dart'; // A lightweight fake exporter to avoid platform rasterization in tests. diff --git a/test/features/step/i_drag_signature_by.dart b/test/features/step/i_drag_signature_by.dart index 4655273..ba49384 100644 --- a/test/features/step/i_drag_signature_by.dart +++ b/test/features/step/i_drag_signature_by.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; import '_world.dart'; diff --git a/test/features/step/i_resize_signature_by.dart b/test/features/step/i_resize_signature_by.dart index 861f928..cfc8b15 100644 --- a/test/features/step/i_resize_signature_by.dart +++ b/test/features/step/i_resize_signature_by.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; import '_world.dart'; diff --git a/test/features/step/i_set_signature_image_bytes.dart b/test/features/step/i_set_signature_image_bytes.dart deleted file mode 100644 index 8529736..0000000 --- a/test/features/step/i_set_signature_image_bytes.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'dart:typed_data'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; -import '_world.dart'; - -/// Usage: I set signature image bytes {Uint8List.fromList([0, 1, 2])} -Future iSetSignatureImageBytes(WidgetTester tester, dynamic value) async { - final c = TestWorld.container!; - final bytes = value as Uint8List; - c.read(signatureProvider.notifier).setImageBytes(bytes); -} diff --git a/test/features/step/i_set_tiny_signature_image_bytes.dart b/test/features/step/i_set_tiny_signature_image_bytes.dart deleted file mode 100644 index fdf3bfd..0000000 --- a/test/features/step/i_set_tiny_signature_image_bytes.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'dart:typed_data'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; -import '_world.dart'; - -/// Usage: I set tiny signature image bytes -Future iSetTinySignatureImageBytes(WidgetTester tester) async { - final c = TestWorld.container!; - final bytes = Uint8List.fromList([0, 1, 2, 3]); - c.read(signatureProvider.notifier).setImageBytes(bytes); -} diff --git a/test/features/step/signature_image_bytes_is_not_null.dart b/test/features/step/signature_image_bytes_is_not_null.dart deleted file mode 100644 index cd69d4c..0000000 --- a/test/features/step/signature_image_bytes_is_not_null.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; -import '_world.dart'; - -/// Usage: signature image bytes is not null -Future signatureImageBytesIsNotNull(WidgetTester tester) async { - final c = TestWorld.container!; - expect(c.read(signatureProvider).imageBytes, isNotNull); -} diff --git a/test/features/step/signature_rect_is_not_null.dart b/test/features/step/signature_rect_is_not_null.dart deleted file mode 100644 index 7bb96c8..0000000 --- a/test/features/step/signature_rect_is_not_null.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; -import '_world.dart'; - -/// Usage: signature rect is not null -Future signatureRectIsNotNull(WidgetTester tester) async { - final c = TestWorld.container!; - expect(c.read(signatureProvider).rect, isNotNull); -} diff --git a/test/features/step/the_user_savesexports_the_document.dart b/test/features/step/the_user_savesexports_the_document.dart index 39a469b..9d1ae09 100644 --- a/test/features/step/the_user_savesexports_the_document.dart +++ b/test/features/step/the_user_savesexports_the_document.dart @@ -6,9 +6,10 @@ import '_world.dart'; /// Usage: the user saves/exports the document Future theUserSavesexportsTheDocument(WidgetTester tester) async { - // Logic-only: simulate a successful export without invoking IO or printing.raster + // Logic-only: simulate a successful export without invoking IO or printing raster final container = TestWorld.container ?? ProviderContainer(); TestWorld.container = container; + // Ensure state looks exportable final pdf = container.read(pdfProvider); final sig = container.read(signatureProvider); @@ -16,6 +17,7 @@ Future theUserSavesexportsTheDocument(WidgetTester tester) async { expect(pdf.signedPage, isNotNull, reason: 'A signed page must be selected'); expect(sig.rect, isNotNull, reason: 'Signature rect must exist'); expect(sig.imageBytes, isNotNull, reason: 'Signature image must exist'); + // Simulate output TestWorld.lastExportBytes = TestWorld.lastExportBytes ?? Uint8List.fromList([1, 2, 3]); diff --git a/test/pdf_state_test.dart b/test/pdf_state_test.dart deleted file mode 100644 index 993f778..0000000 --- a/test/pdf_state_test.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -void main() { - test('openPicked loads document and initializes state', () { - final container = ProviderContainer(); - addTearDown(container.dispose); - final notifier = container.read(pdfProvider.notifier); - notifier.openPicked(path: 'test.pdf', pageCount: 7); - final state = container.read(pdfProvider); - expect(state.loaded, isTrue); - expect(state.pickedPdfPath, 'test.pdf'); - expect(state.pageCount, 7); - expect(state.currentPage, 1); - expect(state.markedForSigning, isFalse); - }); - - test('jumpTo clamps within page boundaries', () { - final container = ProviderContainer(); - addTearDown(container.dispose); - final notifier = container.read(pdfProvider.notifier); - notifier.openPicked(path: 'test.pdf', pageCount: 5); - notifier.jumpTo(10); - expect(container.read(pdfProvider).currentPage, 5); - notifier.jumpTo(0); - expect(container.read(pdfProvider).currentPage, 1); - notifier.jumpTo(3); - expect(container.read(pdfProvider).currentPage, 3); - }); - - test('setPageCount updates count without toggling other flags', () { - final container = ProviderContainer(); - addTearDown(container.dispose); - final notifier = container.read(pdfProvider.notifier); - notifier.openPicked(path: 'test.pdf', pageCount: 2); - notifier.toggleMark(); - notifier.setPageCount(9); - final s = container.read(pdfProvider); - expect(s.pageCount, 9); - expect(s.loaded, isTrue); - expect(s.markedForSigning, isTrue); - }); -} diff --git a/test/signature_state_test.dart b/test/signature_state_test.dart deleted file mode 100644 index dabf636..0000000 --- a/test/signature_state_test.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'dart:typed_data'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; - -void main() { - test('placeDefaultRect centers a reasonable default rect', () { - final container = ProviderContainer(); - addTearDown(container.dispose); - final sig = container.read(signatureProvider); - // Should be null initially - expect(sig.rect, isNull); - - // Place using default pageSize (400x560) - container.read(signatureProvider.notifier).placeDefaultRect(); - final placed = container.read(signatureProvider).rect!; - - // Default should be within bounds and not tiny - expect(placed.left, greaterThanOrEqualTo(0)); - expect(placed.top, greaterThanOrEqualTo(0)); - expect(placed.right, lessThanOrEqualTo(400)); - expect(placed.bottom, lessThanOrEqualTo(560)); - expect(placed.width, greaterThan(50)); - expect(placed.height, greaterThan(20)); - }); - - test('drag clamps to canvas bounds', () { - final container = ProviderContainer(); - addTearDown(container.dispose); - container.read(signatureProvider.notifier).placeDefaultRect(); - final before = container.read(signatureProvider).rect!; - // Drag far outside bounds - container - .read(signatureProvider.notifier) - .drag(const Offset(10000, -10000)); - final after = container.read(signatureProvider).rect!; - expect(after.left, greaterThanOrEqualTo(0)); - expect(after.top, greaterThanOrEqualTo(0)); - expect(after.right, lessThanOrEqualTo(400)); - expect(after.bottom, lessThanOrEqualTo(560)); - // Ensure it actually moved - expect(after.center, isNot(equals(before.center))); - }); - - test('resize respects aspect lock and clamps', () { - final container = ProviderContainer(); - addTearDown(container.dispose); - final notifier = container.read(signatureProvider.notifier); - notifier.placeDefaultRect(); - final before = container.read(signatureProvider).rect!; - notifier.toggleAspect(true); - notifier.resize(const Offset(1000, 1000)); - final after = container.read(signatureProvider).rect!; - // With aspect lock the ratio should remain approximately the same - final ratioBefore = before.width / before.height; - final ratioAfter = after.width / after.height; - expect((ratioBefore - ratioAfter).abs(), lessThan(0.05)); - // Still within bounds - expect(after.left, greaterThanOrEqualTo(0)); - expect(after.top, greaterThanOrEqualTo(0)); - expect(after.right, lessThanOrEqualTo(400)); - expect(after.bottom, lessThanOrEqualTo(560)); - }); - - test('setImageBytes ensures a rect exists for display', () { - final container = ProviderContainer(); - addTearDown(container.dispose); - final notifier = container.read(signatureProvider.notifier); - expect(container.read(signatureProvider).rect, isNull); - notifier.setImageBytes(Uint8List.fromList([0, 1, 2])); - expect(container.read(signatureProvider).imageBytes, isNotNull); - // placeDefaultRect is called when bytes are set if rect was null - expect(container.read(signatureProvider).rect, isNotNull); - }); -} diff --git a/tool/prune_unused_steps.dart b/tool/prune_unused_steps.dart new file mode 100644 index 0000000..b7a741f --- /dev/null +++ b/tool/prune_unused_steps.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +/// Prunes unused step files under test/features/step. +/// +/// Heuristic: A step file is considered used if any test under test/features +/// imports it like: `import './step/.dart';` (as generated by bdd_widget_test). +/// Otherwise it's unused and can be deleted. +/// +/// Usage: +/// dart run tool/prune_unused_steps.dart # dry-run (prints list) +/// dart run tool/prune_unused_steps.dart --delete # delete unused files +/// dart run tool/prune_unused_steps.dart --verbose # show details +void main(List args) { + final delete = args.contains('--delete'); + final verbose = args.contains('--verbose'); + + final stepDir = Directory('test/features/step'); + final testsDir = Directory('test/features'); + + if (!stepDir.existsSync()) { + stderr.writeln('Step folder not found at ${stepDir.path}'); + exitCode = 2; + return; + } + if (!testsDir.existsSync()) { + stderr.writeln('Tests folder not found at ${testsDir.path}'); + exitCode = 2; + return; + } + + // Collect all step files (exclude private helpers like _world.dart) + final stepFiles = stepDir + .listSync(recursive: false) + .whereType() + .where((f) => f.path.endsWith('.dart')) + .where((f) => !basename(f.path).startsWith('_')) + .toList(); + + // Collect all step imports from generated tests + // Matches imports like: import './step/.dart'; + final importRegex = RegExp(r'''import ['"]\./step/([^'\"]+)['"];\s*'''); + final imported = {}; + + for (final entity in testsDir.listSync(recursive: true)) { + if (entity is! File) continue; + if (!entity.path.endsWith('_test.dart')) continue; + final content = entity.readAsStringSync(); + for (final m in importRegex.allMatches(content)) { + imported.add(m.group(1)!); + } + } + + final unused = []; + for (final f in stepFiles) { + final name = basename(f.path); + final isUsed = imported.contains(name); + if (verbose) { + stdout.writeln('- ${isUsed ? 'USED ' : 'UNUSED '} $name'); + } + if (!isUsed) unused.add(f); + } + + if (unused.isEmpty) { + stdout.writeln('No unused step files found.'); + return; + } + + stdout.writeln('Unused step files (${unused.length}):'); + for (final f in unused) { + stdout.writeln(' ${relative(f.path)}'); + } + + if (!delete) { + stdout.writeln('\nDry-run. Re-run with --delete to remove these files.'); + return; + } + + // Delete unused files + var deleted = 0; + for (final f in unused) { + try { + f.deleteSync(); + deleted++; + } catch (e) { + stderr.writeln('Failed to delete ${f.path}: $e'); + } + } + stdout.writeln('Deleted $deleted unused step files.'); +} + +String basename(String path) => path.split(RegExp(r'[\\/]')).last; +String relative(String path) { + final cwd = Directory.current.path.replaceAll('\\\\', '/'); + final norm = path.replaceAll('\\\\', '/'); + return norm.startsWith('$cwd/') ? norm.substring(cwd.length + 1) : norm; +}