tests: added test driver

This commit is contained in:
Thibault Deckers 2020-08-18 21:49:12 +09:00
parent 7b1872fd12
commit e6dc938be3
20 changed files with 420 additions and 17 deletions

View file

@ -31,7 +31,6 @@
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<!-- TODO TLAD remove this permission once this issue is fixed:
https://github.com/flutter/flutter/issues/42349
https://github.com/flutter/flutter/issues/42451
-->
<uses-permission android:name="android.permission.WAKE_LOCK" />

View file

@ -1,6 +1,7 @@
package deckers.thibault.aves.channel.calls;
import android.content.Context;
import android.media.MediaScannerConnection;
import android.os.Build;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
@ -51,6 +52,10 @@ public class StorageHandler implements MethodChannel.MethodCallHandler {
}
break;
}
case "scanFile": {
scanFile(call, new MethodResultWrapper(result));
break;
}
default:
result.notImplemented();
break;
@ -82,4 +87,12 @@ public class StorageHandler implements MethodChannel.MethodCallHandler {
}
return volumes;
}
}
private void scanFile(MethodCall call, MethodChannel.Result result) {
String path = call.argument("path");
String mimeType = call.argument("mimeType");
MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, uri) -> {
result.success(uri != null ? uri.toString() : null);
});
}
}

View file

@ -1,9 +1,10 @@
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/fullscreen/info/location_section.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'source/enums.dart';
final Settings settings = Settings._private();
typedef SettingsCallback = void Function(String key, dynamic oldValue, dynamic newValue);

View file

@ -12,6 +12,8 @@ import 'package:aves/utils/change_notifier.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'enums.dart';
class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSelectionMixin {
final CollectionSource source;
final Set<CollectionFilter> filters;
@ -214,12 +216,6 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
}
}
enum SortFactor { date, size, name }
enum GroupFactor { none, album, month, day }
enum Activity { browse, select }
mixin CollectionActivityMixin {
final ValueNotifier<Activity> _activityNotifier = ValueNotifier(Activity.browse);

View file

@ -11,6 +11,8 @@ import 'package:aves/model/source/tag.dart';
import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart';
import 'enums.dart';
mixin SourceBase {
final List<ImageEntry> _rawEntries = [];

View file

@ -0,0 +1,5 @@
enum SortFactor { date, size, name }
enum GroupFactor { none, album, month, day }
enum Activity { browse, select }

View file

@ -52,4 +52,19 @@ class AndroidFileService {
}
return false;
}
// return media URI
static Future<Uri> scanFile(String path, String mimeType) async {
debugPrint('scanFile with path=$path, mimeType=$mimeType');
try {
final uriString = await platform.invokeMethod('scanFile', <String, dynamic>{
'path': path,
'mimeType': mimeType,
});
return Uri.tryParse(uriString ?? '');
} on PlatformException catch (e) {
debugPrint('scanFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
}
return null;
}
}

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:aves/main.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/album/filter_bar.dart';
import 'package:aves/widgets/album/search/search_delegate.dart';
@ -133,7 +134,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
Widget _buildAppBarTitle() {
if (collection.isBrowsing) {
Widget title = Text(AvesApp.mode == AppMode.pick ? 'Select' : 'Aves');
Widget title = Text(AvesApp.mode == AppMode.pick ? 'Select' : 'Aves', key: Key('appbar-title'));
if (AvesApp.mode == AppMode.main) {
title = SourceStateAwareAppBarTitle(
title: title,
@ -184,15 +185,18 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
)),
Builder(
builder: (context) => PopupMenuButton<CollectionAction>(
key: Key('menu-button'),
itemBuilder: (context) {
final hasSelection = collection.selection.isNotEmpty;
return [
PopupMenuItem(
key: Key('menu-sort'),
value: CollectionAction.sort,
child: MenuRow(text: 'Sort...', icon: AIcons.sort),
),
if (collection.sortFactor == SortFactor.date)
PopupMenuItem(
key: Key('menu-group'),
value: CollectionAction.group,
child: MenuRow(text: 'Group...', icon: AIcons.group),
),

View file

@ -2,6 +2,7 @@ import 'dart:math';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/utils/durations.dart';

View file

@ -2,6 +2,7 @@ import 'dart:math';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/fx/sweeper.dart';
import 'package:aves/widgets/common/icons.dart';

View file

@ -1,5 +1,5 @@
import 'package:aves/model/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
@ -35,6 +35,7 @@ class _GroupCollectionDialogState extends State<GroupCollectionDialog> {
child: Text('Cancel'.toUpperCase()),
),
FlatButton(
key: Key('apply-button'),
onPressed: () => Navigator.pop(context, _selectedGroup),
child: Text('Apply'.toUpperCase()),
),
@ -42,8 +43,9 @@ class _GroupCollectionDialogState extends State<GroupCollectionDialog> {
);
}
Widget _buildRadioListTile(GroupFactor group, String title) => RadioListTile<GroupFactor>(
value: group,
Widget _buildRadioListTile(GroupFactor value, String title) => RadioListTile<GroupFactor>(
key: Key(value.toString()),
value: value,
groupValue: _selectedGroup,
onChanged: (group) => setState(() => _selectedGroup = group),
title: Text(

View file

@ -1,5 +1,5 @@
import 'package:aves/model/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
@ -34,6 +34,7 @@ class _SortCollectionDialogState extends State<SortCollectionDialog> {
child: Text('Cancel'.toUpperCase()),
),
FlatButton(
key: Key('apply-button'),
onPressed: () => Navigator.pop(context, _selectedSort),
child: Text('Apply'.toUpperCase()),
),
@ -41,8 +42,9 @@ class _SortCollectionDialogState extends State<SortCollectionDialog> {
);
}
Widget _buildRadioListTile(SortFactor sort, String title) => RadioListTile<SortFactor>(
value: sort,
Widget _buildRadioListTile(SortFactor value, String title) => RadioListTile<SortFactor>(
key: Key(value.toString()),
value: value,
groupValue: _selectedSort,
onChanged: (sort) => setState(() => _selectedSort = sort),
title: Text(

View file

@ -93,11 +93,13 @@ class _WelcomePageState extends State<WelcomePage> {
List<Widget> _buildBottomControls(BuildContext context) {
final checkbox = LabeledCheckbox(
key: Key('agree-checkbox'),
value: _hasAcceptedTerms,
onChanged: (v) => setState(() => _hasAcceptedTerms = v),
text: 'I agree to the terms and conditions',
);
final button = RaisedButton(
key: Key('continue-button'),
child: Text('Continue'),
onPressed: _hasAcceptedTerms
? () {

View file

@ -1,6 +1,20 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
url: "https://pub.dartlang.org"
source: hosted
version: "7.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
url: "https://pub.dartlang.org"
source: hosted
version: "0.39.17"
ansicolor:
dependency: transitive
description:
@ -78,6 +92,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.9.0"
cli_util:
dependency: transitive
description:
name: cli_util
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0"
clock:
dependency: transitive
description:
@ -106,6 +127,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
coverage:
dependency: transitive
description:
name: coverage
url: "https://pub.dartlang.org"
source: hosted
version: "0.14.0"
crypto:
dependency: transitive
description:
@ -113,6 +141,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.5"
csslib:
dependency: transitive
description:
name: csslib
url: "https://pub.dartlang.org"
source: hosted
version: "0.16.2"
draggable_scrollbar:
dependency: "direct main"
description:
@ -178,6 +213,11 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.1"
flutter_driver:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_ijkplayer:
dependency: "direct main"
description:
@ -246,6 +286,11 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
geocoder:
dependency: "direct main"
description:
@ -253,6 +298,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.1"
glob:
dependency: transitive
description:
name: glob
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
google_maps_flutter:
dependency: "direct main"
description:
@ -267,6 +319,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.4"
html:
dependency: transitive
description:
name: html
url: "https://pub.dartlang.org"
source: hosted
version: "0.14.0+3"
http:
dependency: transitive
description:
@ -274,6 +333,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.12.2"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.0"
http_parser:
dependency: transitive
description:
@ -295,6 +361,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.16.1"
io:
dependency: transitive
description:
name: io
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.4"
js:
dependency: transitive
description:
@ -302,6 +375,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.2"
json_rpc_2:
dependency: transitive
description:
name: json_rpc_2
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.1"
latlong:
dependency: "direct main"
description:
@ -351,6 +431,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
mime:
dependency: transitive
description:
name: mime
url: "https://pub.dartlang.org"
source: hosted
version: "0.9.7"
nested:
dependency: transitive
description:
@ -358,6 +445,27 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.4"
node_interop:
dependency: transitive
description:
name: node_interop
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.1"
node_io:
dependency: transitive
description:
name: node_io
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.1"
node_preamble:
dependency: transitive
description:
name: node_preamble
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.12"
outline_material_icons:
dependency: "direct main"
description:
@ -365,6 +473,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.1"
package_config:
dependency: transitive
description:
name: package_config
url: "https://pub.dartlang.org"
source: hosted
version: "1.9.3"
package_info:
dependency: "direct main"
description:
@ -500,6 +615,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
pool:
dependency: transitive
description:
name: pool
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.0"
positioned_tap_detector:
dependency: transitive
description:
@ -605,11 +727,53 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.2+7"
shelf:
dependency: transitive
description:
name: shelf
url: "https://pub.dartlang.org"
source: hosted
version: "0.7.9"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
shelf_static:
dependency: transitive
description:
name: shelf_static
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.8"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.3"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.99"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
source_maps:
dependency: transitive
description:
name: source_maps
url: "https://pub.dartlang.org"
source: hosted
version: "0.10.9"
source_span:
dependency: transitive
description:
@ -666,6 +830,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.5"
sync_http:
dependency: transitive
description:
name: sync_http
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0"
synchronized:
dependency: transitive
description:
@ -680,6 +851,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
test:
dependency: "direct dev"
description:
name: test
url: "https://pub.dartlang.org"
source: hosted
version: "1.15.2"
test_api:
dependency: transitive
description:
@ -687,6 +865,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.17"
test_core:
dependency: transitive
description:
name: test_core
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.10"
transparent_image:
dependency: transitive
description:
@ -778,6 +963,48 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.8"
vm_service:
dependency: transitive
description:
name: vm_service
url: "https://pub.dartlang.org"
source: hosted
version: "4.2.0"
vm_service_client:
dependency: transitive
description:
name: vm_service_client
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.6+2"
watcher:
dependency: transitive
description:
name: watcher
url: "https://pub.dartlang.org"
source: hosted
version: "0.9.7+15"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
webdriver:
dependency: transitive
description:
name: webdriver
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
url: "https://pub.dartlang.org"
source: hosted
version: "0.7.3"
wkt_parser:
dependency: transitive
description:
@ -799,6 +1026,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "4.2.0"
yaml:
dependency: transitive
description:
name: yaml
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.1"
sdks:
dart: ">=2.9.0-14.0.dev <3.0.0"
flutter: ">=1.18.0-6.0.pre <2.0.0"

View file

@ -86,9 +86,12 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
# flutter drive --target=test_driver/app.dart
flutter_driver:
sdk: flutter
test: any
flutter:
uses-material-design: true
assets:

18
test_driver/app.dart Normal file
View file

@ -0,0 +1,18 @@
import 'package:aves/main.dart' as app;
import 'package:aves/services/android_file_service.dart';
import 'package:flutter_driver/driver_extension.dart';
import 'package:path/path.dart';
import 'package:pedantic/pedantic.dart';
import 'constants.dart';
void main() {
enableFlutterDriverExtension();
// scan files copied from test assets
// we do it via the app instead of broadcasting via ADB
// because `MEDIA_SCANNER_SCAN_FILE` intent got deprecated in API 29
unawaited(AndroidFileService.scanFile(join(targetPicturesDir, 'ipse.jpg'), 'image/jpeg'));
app.main();
}

67
test_driver/app_test.dart Normal file
View file

@ -0,0 +1,67 @@
import 'package:aves/model/source/enums.dart';
import 'package:flutter_driver/flutter_driver.dart';
import 'package:pedantic/pedantic.dart';
import 'package:test/test.dart';
import 'constants.dart';
import 'utils.dart';
void main() {
group('Aves app', () {
FlutterDriver driver;
setUpAll(() async {
await copyContent(sourcePicturesDir, targetPicturesDir);
await grantPermissions('deckers.thibault.aves.debug', [
'android.permission.READ_EXTERNAL_STORAGE',
'android.permission.WRITE_EXTERNAL_STORAGE',
'android.permission.ACCESS_MEDIA_LOCATION',
]);
driver = await FlutterDriver.connect();
});
tearDownAll(() async {
await removeDirectory(targetPicturesDir);
if (driver != null) {
unawaited(driver.close());
}
});
final appBarTitleFinder = find.byValueKey('appbar-title');
test('agree to terms and reach home', () async {
await driver.scroll(find.text('Terms of Service'), 0, -300, Duration(milliseconds: 500));
final buttonFinder = find.byValueKey('continue-button');
expect(await isEnabled(driver, buttonFinder), equals(false));
await driver.tap(find.byValueKey('agree-checkbox'));
await driver.waitUntilNoTransientCallbacks();
expect(await isEnabled(driver, buttonFinder), equals(true));
await driver.tap(buttonFinder);
await driver.waitUntilNoTransientCallbacks();
expect(await driver.getText(appBarTitleFinder), 'Aves');
});
test('sort and group', () async {
await driver.tap(find.byValueKey('menu-button'));
await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.byValueKey('menu-sort'));
await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.byValueKey(SortFactor.date.toString()));
await driver.tap(find.byValueKey('apply-button'));
await driver.tap(find.byValueKey('menu-button'));
await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.byValueKey('menu-group'));
await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.byValueKey(GroupFactor.album.toString()));
await driver.tap(find.byValueKey('apply-button'));
});
}, timeout: Timeout(Duration(seconds: 10)));
}

BIN
test_driver/assets/ipse.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 MiB

View file

@ -0,0 +1,3 @@
const sourcePicturesDir = 'test_driver/assets/';
const targetPicturesDir = '/sdcard/Pictures/Aves Test Driver/';

35
test_driver/utils.dart Normal file
View file

@ -0,0 +1,35 @@
import 'dart:io';
import 'package:flutter_driver/flutter_driver.dart';
import 'package:path/path.dart';
String get adb {
final env = Platform.environment;
final sdkDir = env['ANDROID_SDK_ROOT'] ?? env['ANDROID_SDK'];
return join(sdkDir, 'platform-tools', Platform.isWindows ? 'adb.exe' : 'adb');
}
Future<void> createDirectory(String dir) async {
await Process.run(adb, ['shell', 'mkdir -p', dir.replaceAll(' ', '\\ ')]);
}
Future<void> removeDirectory(String dir) async {
await Process.run(adb, ['shell', 'rm -r', dir.replaceAll(' ', '\\ ')]);
}
Future<void> copyContent(String sourceDir, String targetDir) async {
// to copy the content of `source` inside `target`
// `push source/* target/` works only when the target directory exists, and fails when `target` contains spaces
// `push source/ target/` works only when the target directory does not exist
await removeDirectory(targetDir);
await Process.run(adb, ['push', sourceDir, targetDir]);
}
Future<void> grantPermissions(String packageName, Iterable<String> permissions) async {
await Future.forEach(permissions, (permission) => Process.run(adb, ['shell', 'pm', 'grant', packageName, permission]));
}
Future<bool> isEnabled(FlutterDriver driver, SerializableFinder widgetFinder) async {
Map widgetDiagnostics = await driver.getWidgetDiagnostics(widgetFinder);
return widgetDiagnostics['properties'].firstWhere((property) => property['name'] == 'enabled')['value'];
}