feat: small tool to remove dead code create by `bdd_widget_test`

This commit is contained in:
insleker 2025-08-29 15:56:35 +08:00
parent b8918717b5
commit 5990f6fb01
22 changed files with 194 additions and 272 deletions

View File

@ -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

View File

@ -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`

11
build.yaml Normal file
View File

@ -0,0 +1,11 @@
targets:
$default:
sources:
- integration_test/**
- test/**
- lib/**
- $package$
builders:
pdf_signature|prune_unused_steps:
generate_for:
- test/features/**

View File

@ -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 {

View File

@ -93,8 +93,10 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
Future<void> _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<PdfSignatureHomePage> {
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<PdfSignatureHomePage> {
),
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),

View File

@ -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

View File

@ -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' |

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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

View File

@ -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);
});
});
}

View File

@ -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.

View File

@ -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';

View File

@ -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';

View File

@ -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<void> iSetSignatureImageBytes(WidgetTester tester, dynamic value) async {
final c = TestWorld.container!;
final bytes = value as Uint8List;
c.read(signatureProvider.notifier).setImageBytes(bytes);
}

View File

@ -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<void> iSetTinySignatureImageBytes(WidgetTester tester) async {
final c = TestWorld.container!;
final bytes = Uint8List.fromList([0, 1, 2, 3]);
c.read(signatureProvider.notifier).setImageBytes(bytes);
}

View File

@ -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<void> signatureImageBytesIsNotNull(WidgetTester tester) async {
final c = TestWorld.container!;
expect(c.read(signatureProvider).imageBytes, isNotNull);
}

View File

@ -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<void> signatureRectIsNotNull(WidgetTester tester) async {
final c = TestWorld.container!;
expect(c.read(signatureProvider).rect, isNotNull);
}

View File

@ -6,9 +6,10 @@ import '_world.dart';
/// Usage: the user saves/exports the document
Future<void> 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<void> 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]);

View File

@ -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);
});
}

View File

@ -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);
});
}

View File

@ -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/<file>.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<String> 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<File>()
.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/<file>.dart';
final importRegex = RegExp(r'''import ['"]\./step/([^'\"]+)['"];\s*''');
final imported = <String>{};
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 = <File>[];
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;
}