960 lines
28 KiB
Dart
960 lines
28 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
|
|
|
import 'package:app/backup_service.dart';
|
|
import 'package:app/app_strings.dart';
|
|
import 'package:app/ble_controller.dart';
|
|
import 'package:app/main.dart';
|
|
import 'package:app/models.dart';
|
|
import 'package:app/secure_box_store.dart';
|
|
|
|
class _MemoryStore extends SecureBoxStore {
|
|
_MemoryStore({
|
|
this.language = 'en',
|
|
this.boxes = const <SavedBox>[],
|
|
this.lastBackupAt,
|
|
Map<String, Map<String, dynamic>>? cachedStates,
|
|
}) : cachedStates = cachedStates ?? <String, Map<String, dynamic>>{};
|
|
|
|
final String? language;
|
|
final List<SavedBox> boxes;
|
|
DateTime? lastBackupAt;
|
|
final Map<String, Map<String, dynamic>> cachedStates;
|
|
|
|
@override
|
|
Future<List<SavedBox>> loadBoxes() async => boxes;
|
|
|
|
@override
|
|
Future<String?> loadLanguage() async => language;
|
|
|
|
@override
|
|
Future<void> saveBoxes(List<SavedBox> boxes) async {}
|
|
|
|
@override
|
|
Future<Map<String, dynamic>?> loadCachedState(String identityKey) async {
|
|
final state = cachedStates[identityKey];
|
|
return state == null ? null : Map<String, dynamic>.from(state);
|
|
}
|
|
|
|
@override
|
|
Future<void> saveCachedState(
|
|
String identityKey,
|
|
Map<String, dynamic> state,
|
|
) async {
|
|
cachedStates[identityKey] = Map<String, dynamic>.from(state);
|
|
}
|
|
|
|
@override
|
|
Future<void> deleteCachedState(String identityKey) async {
|
|
cachedStates.remove(identityKey);
|
|
}
|
|
|
|
@override
|
|
Future<DateTime?> loadLastBackupAt() async => lastBackupAt;
|
|
|
|
@override
|
|
Future<void> saveLastBackupAt(DateTime date) async {
|
|
lastBackupAt = date;
|
|
}
|
|
}
|
|
|
|
class _FakeBackupService extends BackupService {
|
|
bool fileWasPicked = false;
|
|
|
|
@override
|
|
Future<Uint8List?> pickBackupFile() async {
|
|
fileWasPicked = true;
|
|
return Uint8List(0);
|
|
}
|
|
|
|
@override
|
|
Future<List<SavedBox>> importBoxes(Uint8List bytes, String password) async =>
|
|
<SavedBox>[];
|
|
}
|
|
|
|
class _SequenceRandom implements Random {
|
|
_SequenceRandom(this.values);
|
|
|
|
final List<int> values;
|
|
int _index = 0;
|
|
|
|
@override
|
|
int nextInt(int max) => values[_index++] % max;
|
|
|
|
@override
|
|
bool nextBool() => nextInt(2) == 1;
|
|
|
|
@override
|
|
double nextDouble() => nextInt(1 << 20) / (1 << 20);
|
|
}
|
|
|
|
class _RecordingController extends OpenParcelBoxBleController {
|
|
_RecordingController({required Random random})
|
|
: super(
|
|
store: _MemoryStore(),
|
|
reconnectOnInitialize: false,
|
|
random: random,
|
|
);
|
|
|
|
OpenParcelBoxCommand? lastCommand;
|
|
|
|
@override
|
|
Future<void> send(OpenParcelBoxCommand command, {bool refresh = true}) async {
|
|
lastCommand = command;
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
test('connection timeouts never trigger Android bond recovery', () {
|
|
final timeout = FlutterBluePlusException(
|
|
ErrorPlatform.fbp,
|
|
'connect',
|
|
FbpErrorCode.timeout.index,
|
|
'Timed out',
|
|
);
|
|
final encryptedGattFailure = FlutterBluePlusException(
|
|
ErrorPlatform.android,
|
|
'setNotifyValue',
|
|
5,
|
|
'Insufficient authentication',
|
|
);
|
|
|
|
expect(isRecoverableAndroidBondFailure(timeout), isFalse);
|
|
expect(isRecoverableAndroidBondFailure(encryptedGattFailure), isTrue);
|
|
});
|
|
|
|
const transparentPixel = <int>[
|
|
0x89,
|
|
0x50,
|
|
0x4E,
|
|
0x47,
|
|
0x0D,
|
|
0x0A,
|
|
0x1A,
|
|
0x0A,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x0D,
|
|
0x49,
|
|
0x48,
|
|
0x44,
|
|
0x52,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x01,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x01,
|
|
0x08,
|
|
0x06,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x1F,
|
|
0x15,
|
|
0xC4,
|
|
0x89,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x0A,
|
|
0x49,
|
|
0x44,
|
|
0x41,
|
|
0x54,
|
|
0x78,
|
|
0x9C,
|
|
0x63,
|
|
0x00,
|
|
0x01,
|
|
0x00,
|
|
0x00,
|
|
0x05,
|
|
0x00,
|
|
0x01,
|
|
0x0D,
|
|
0x0A,
|
|
0x2D,
|
|
0xB4,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x49,
|
|
0x45,
|
|
0x4E,
|
|
0x44,
|
|
0xAE,
|
|
0x42,
|
|
0x60,
|
|
0x82,
|
|
];
|
|
|
|
setUpAll(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMessageHandler('flutter/assets', (ByteData? message) async {
|
|
final key = String.fromCharCodes(message!.buffer.asUint8List());
|
|
if (key == 'AssetManifest.bin') {
|
|
return const StandardMessageCodec().encodeMessage(<String, Object>{
|
|
'images/background.jpg': <Object>[
|
|
<String, Object>{'asset': 'images/background.jpg'},
|
|
],
|
|
'images/openparcelbox-logo-256.png': <Object>[
|
|
<String, Object>{'asset': 'images/openparcelbox-logo-256.png'},
|
|
],
|
|
});
|
|
}
|
|
if (key == 'images/background.jpg' ||
|
|
key == 'images/openparcelbox-logo-256.png') {
|
|
final bytes = Uint8List.fromList(transparentPixel);
|
|
return ByteData.sublistView(bytes);
|
|
}
|
|
return null;
|
|
});
|
|
});
|
|
|
|
testWidgets('startup splash shows the background and centered logo', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.pumpWidget(MyApp(store: _MemoryStore()));
|
|
|
|
expect(
|
|
find.image(const AssetImage('images/background.jpg')),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
find.image(const AssetImage('images/openparcelbox-logo-256.png')),
|
|
findsOneWidget,
|
|
);
|
|
|
|
await tester.pump(const Duration(milliseconds: 900));
|
|
});
|
|
|
|
testWidgets('empty home only exposes registration and restore actions', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
MyApp(store: _MemoryStore(), splashDuration: Duration.zero),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Add a new OpenParcelBox'), findsOneWidget);
|
|
expect(find.text('Restore a backup'), findsOneWidget);
|
|
expect(find.byIcon(Icons.lock_open), findsNothing);
|
|
expect(find.text('Permanent codes'), findsNothing);
|
|
final background = tester.widget<DecoratedBox>(
|
|
find.byKey(const ValueKey<String>('app-background')),
|
|
);
|
|
final decoration = background.decoration as BoxDecoration;
|
|
expect(decoration.image?.image, const AssetImage('images/background.jpg'));
|
|
});
|
|
|
|
test('saved guest identity names survive backup serialization', () {
|
|
const box = SavedBox(
|
|
remoteId: 'AA:BB',
|
|
name: 'Front gate',
|
|
identityKey: '1234',
|
|
role: BoxRole.guest,
|
|
identityName: 'Alice',
|
|
);
|
|
|
|
final restored = SavedBox.fromJson(box.toJson());
|
|
|
|
expect(restored.identityName, 'Alice');
|
|
});
|
|
|
|
test('restored connection records can require BLE rediscovery', () {
|
|
const box = SavedBox(
|
|
remoteId: 'AA:BB',
|
|
name: 'Front gate',
|
|
identityKey: '1234',
|
|
role: BoxRole.administrator,
|
|
);
|
|
|
|
final portable = box.copyWith(needsRediscovery: true);
|
|
final restored = SavedBox.fromJson(portable.toJson());
|
|
|
|
expect(restored.identityKey, box.identityKey);
|
|
expect(restored.needsRediscovery, isTrue);
|
|
});
|
|
|
|
test('guest invitations transfer the guest identity key', () {
|
|
final controller = OpenParcelBoxBleController(store: _MemoryStore())
|
|
..currentBox = const SavedBox(
|
|
remoteId: 'AA:BB',
|
|
name: 'Front gate',
|
|
identityKey: 'ADMIN',
|
|
role: BoxRole.administrator,
|
|
);
|
|
const guest = GuestIdentity(name: 'Alice', key: 'GUEST-KEY');
|
|
|
|
final invitation =
|
|
jsonDecode(controller.invitationPayload(guest)) as Map<String, dynamic>;
|
|
|
|
expect(invitation['format'], 'openparcelbox-invite-v1');
|
|
expect(invitation['box_name'], 'Front gate');
|
|
expect(invitation['guest_name'], 'Alice');
|
|
expect(invitation['guest_key'], 'GUEST-KEY');
|
|
});
|
|
|
|
test('cached box state is available before BLE reconnection', () async {
|
|
final store = _MemoryStore(
|
|
boxes: const <SavedBox>[
|
|
SavedBox(
|
|
remoteId: 'AA:BB',
|
|
name: 'Front gate',
|
|
identityKey: 'ADMIN',
|
|
role: BoxRole.administrator,
|
|
),
|
|
],
|
|
cachedStates: <String, Map<String, dynamic>>{
|
|
'ADMIN': <String, dynamic>{
|
|
'codes': <Map<String, dynamic>>[
|
|
<String, dynamic>{'slot': 0, 'code': '123456', 'kind': 'permanent'},
|
|
<String, dynamic>{'slot': 1, 'code': '654321', 'kind': 'one_time'},
|
|
],
|
|
'nfc_tags': <Map<String, dynamic>>[
|
|
<String, dynamic>{'slot': 0, 'uid': '60:4F:E2:B5', 'name': 'Alice'},
|
|
],
|
|
'history': <Map<String, dynamic>>[
|
|
<String, dynamic>{
|
|
'unix_ms': 1784318264772,
|
|
'kind': 0,
|
|
'actor': 'Administrator',
|
|
},
|
|
],
|
|
'guests': <Map<String, dynamic>>[
|
|
<String, dynamic>{'name': 'Bob', 'key': 'GUEST'},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
final controller = OpenParcelBoxBleController(
|
|
store: store,
|
|
reconnectOnInitialize: false,
|
|
);
|
|
|
|
await controller.initialize();
|
|
|
|
expect(controller.permanentCodes.single.code, '123456');
|
|
expect(controller.oneTimeCodes.single.code, '654321');
|
|
expect(controller.nfcTags.single.name, 'Alice');
|
|
expect(controller.history.single.actor, 'Administrator');
|
|
expect(controller.guests.single.name, 'Bob');
|
|
controller.dispose();
|
|
});
|
|
|
|
test(
|
|
'generated codes skip every active permanent and temporary code',
|
|
() async {
|
|
final controller =
|
|
_RecordingController(
|
|
random: _SequenceRandom(<int>[123456, 654321, 42]),
|
|
)
|
|
..currentBox = const SavedBox(
|
|
remoteId: 'AA:BB',
|
|
name: 'Front gate',
|
|
identityKey: 'ADMIN',
|
|
role: BoxRole.administrator,
|
|
)
|
|
..permanentCodes.add(
|
|
const AccessCode(
|
|
id: 'slot-0',
|
|
code: '123456',
|
|
kind: CredentialKind.permanent,
|
|
),
|
|
)
|
|
..oneTimeCodes.add(
|
|
const AccessCode(
|
|
id: 'slot-1',
|
|
code: '654321',
|
|
kind: CredentialKind.oneTime,
|
|
),
|
|
);
|
|
|
|
await controller.addGeneratedCode(CredentialKind.permanent);
|
|
|
|
expect(controller.lastCommand?.name, 'add_code');
|
|
expect(controller.lastCommand?.payload['code'], '000042');
|
|
expect(controller.lastCommand?.payload['exclusive'], 1);
|
|
controller.dispose();
|
|
},
|
|
);
|
|
|
|
test(
|
|
'manual permanent-code replacement uses the atomic BLE command',
|
|
() async {
|
|
final controller = _RecordingController(random: _SequenceRandom(<int>[0]))
|
|
..currentBox = const SavedBox(
|
|
remoteId: 'AA:BB',
|
|
name: 'Front gate',
|
|
identityKey: 'ADMIN',
|
|
role: BoxRole.administrator,
|
|
);
|
|
const previous = AccessCode(
|
|
id: 'slot-0',
|
|
code: '123456',
|
|
kind: CredentialKind.permanent,
|
|
);
|
|
|
|
await controller.replacePermanentCode(previous, '654321');
|
|
|
|
expect(controller.lastCommand?.name, 'replace_code');
|
|
expect(controller.lastCommand?.payload['old_code'], '123456');
|
|
expect(controller.lastCommand?.payload['new_code'], '654321');
|
|
expect(controller.lastCommand?.payload['kind'], 'permanent');
|
|
controller.dispose();
|
|
},
|
|
);
|
|
|
|
testWidgets('code editor uses a numeric keyboard and repeated backspace', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Scaffold(
|
|
body: CodeEditorDialog(
|
|
title: 'Edit permanent code',
|
|
description: 'Enter six digits.',
|
|
initialCode: '123456',
|
|
cancelLabel: 'Cancel',
|
|
confirmLabel: 'Save',
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
final input = tester.widget<TextField>(
|
|
find.byKey(const ValueKey<String>('code-input')),
|
|
);
|
|
expect(input.keyboardType, TextInputType.number);
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.backspace);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.backspace);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.backspace);
|
|
await tester.pump();
|
|
|
|
final editedController = tester
|
|
.widget<TextField>(find.byKey(const ValueKey<String>('code-input')))
|
|
.controller;
|
|
expect(editedController?.text, '123');
|
|
expect(
|
|
editedController?.selection,
|
|
const TextSelection.collapsed(offset: 3),
|
|
);
|
|
expect(find.text('4'), findsNothing);
|
|
expect(find.text('5'), findsNothing);
|
|
expect(find.text('6'), findsNothing);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('styled modal keeps its final row above Android navigation', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.binding.setSurfaceSize(const Size(400, 800));
|
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
builder: (context, child) => MediaQuery(
|
|
data: MediaQuery.of(
|
|
context,
|
|
).copyWith(viewPadding: const EdgeInsets.only(bottom: 24)),
|
|
child: child!,
|
|
),
|
|
home: Builder(
|
|
builder: (context) => Scaffold(
|
|
body: TextButton(
|
|
onPressed: () => showOpbModal(
|
|
context,
|
|
title: 'Codes',
|
|
child: Column(
|
|
children: List<Widget>.generate(
|
|
8,
|
|
(index) => Container(
|
|
key: ValueKey<String>('modal-code-$index'),
|
|
height: 72,
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
child: const Text('Open'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.tap(find.text('Open'));
|
|
await tester.pumpAndSettle();
|
|
final finalRow = find.byKey(const ValueKey<String>('modal-code-7'));
|
|
await tester.ensureVisible(finalRow);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(tester.getRect(finalRow).bottom, lessThanOrEqualTo(760));
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
test('guest logic rejects permanent-code mutations', () {
|
|
final controller = OpenParcelBoxBleController(store: _MemoryStore())
|
|
..currentBox = const SavedBox(
|
|
remoteId: 'AA:BB',
|
|
name: 'Front gate',
|
|
identityKey: '1234',
|
|
role: BoxRole.guest,
|
|
);
|
|
|
|
expect(
|
|
() => controller.addGeneratedCode(CredentialKind.permanent),
|
|
throwsStateError,
|
|
);
|
|
expect(
|
|
() => controller.removeCode(
|
|
const AccessCode(
|
|
id: 'slot-0',
|
|
code: '123456',
|
|
kind: CredentialKind.permanent,
|
|
),
|
|
),
|
|
throwsStateError,
|
|
);
|
|
controller.dispose();
|
|
});
|
|
|
|
test('NFC enrollment state and commands use the two-step box flow', () async {
|
|
const box = SavedBox(
|
|
remoteId: 'AA:BB',
|
|
name: 'Front gate',
|
|
identityKey: '1234',
|
|
role: BoxRole.administrator,
|
|
);
|
|
final controller = OpenParcelBoxBleController(
|
|
store: _MemoryStore(
|
|
boxes: const <SavedBox>[box],
|
|
cachedStates: <String, Map<String, dynamic>>{
|
|
box.identityKey: <String, dynamic>{
|
|
'nfc_enrollment': <String, dynamic>{
|
|
'active': false,
|
|
'uid': '60:4F:E2:B5',
|
|
'error': '',
|
|
},
|
|
},
|
|
},
|
|
),
|
|
reconnectOnInitialize: false,
|
|
);
|
|
await controller.initialize();
|
|
expect(controller.nfcEnrollmentUid, '60:4F:E2:B5');
|
|
expect(controller.nfcEnrollmentError, isNull);
|
|
controller.dispose();
|
|
|
|
final recording = _RecordingController(random: _SequenceRandom(<int>[]));
|
|
await recording.startNfcEnrollment();
|
|
expect(recording.lastCommand?.name, 'start_nfc_enrollment');
|
|
expect(recording.lastCommand?.payload, isEmpty);
|
|
await recording.cancelNfcEnrollment();
|
|
expect(recording.lastCommand?.name, 'cancel_nfc_enrollment');
|
|
recording.dispose();
|
|
});
|
|
|
|
testWidgets('NFC enrollment keeps errors visible then shows naming field', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final controller = _RecordingController(random: _SequenceRandom(<int>[]))
|
|
..nfcEnrollmentError = 'timeout';
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: NfcEnrollmentDialog(
|
|
controller: controller,
|
|
strings: AppStrings(const Locale('en')),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
expect(
|
|
find.byKey(const ValueKey<String>('nfc-enrollment-modal')),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
find.byKey(const ValueKey<String>('nfc-enrollment-error')),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('No tag was detected within 15 seconds.'), findsOneWidget);
|
|
|
|
controller
|
|
..nfcEnrollmentError = null
|
|
..nfcEnrollmentUid = '60:4F:E2:B5'
|
|
..notifyListeners();
|
|
await tester.pump();
|
|
expect(
|
|
find.byKey(const ValueKey<String>('nfc-enrollment-name')),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('60:4F:E2:B5'), findsOneWidget);
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
controller.dispose();
|
|
});
|
|
|
|
testWidgets('administrator compact actions share one bottom row', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.binding.setSurfaceSize(const Size(400, 800));
|
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
|
await tester.pumpWidget(
|
|
MyApp(
|
|
store: _MemoryStore(
|
|
lastBackupAt: null,
|
|
boxes: const <SavedBox>[
|
|
SavedBox(
|
|
remoteId: 'AA:BB',
|
|
name: 'Front gate',
|
|
identityKey: '1234',
|
|
role: BoxRole.administrator,
|
|
),
|
|
],
|
|
),
|
|
splashDuration: Duration.zero,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
final tags = tester.getRect(
|
|
find.byKey(const ValueKey<String>('compact-NFC tags')),
|
|
);
|
|
final guests = tester.getRect(
|
|
find.byKey(const ValueKey<String>('compact-Guests')),
|
|
);
|
|
final settings = tester.getRect(
|
|
find.byKey(const ValueKey<String>('compact-Settings')),
|
|
);
|
|
final quickActions = tester.getRect(
|
|
find.byKey(const ValueKey<String>('quick-actions')),
|
|
);
|
|
final codeActions = tester.getRect(
|
|
find.byKey(const ValueKey<String>('code-actions')),
|
|
);
|
|
|
|
expect(tags.top, guests.top);
|
|
expect(guests.top, settings.top);
|
|
expect(tags.width, guests.width);
|
|
expect(guests.width, settings.width);
|
|
expect(800 - settings.bottom, 20);
|
|
expect(codeActions.top - quickActions.bottom, 28);
|
|
await tester.tap(find.byKey(const ValueKey<String>('compact-Settings')));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Last backup: never'), findsOneWidget);
|
|
expect(find.text('Factory-reset the box'), findsNothing);
|
|
expect(find.text('Disconnected'), findsOneWidget);
|
|
expect(find.text('Reconnect'), findsOneWidget);
|
|
await tester.ensureVisible(
|
|
find.widgetWithText(OutlinedButton, 'Forget the box'),
|
|
);
|
|
await tester.tap(find.widgetWithText(OutlinedButton, 'Forget the box'));
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
find.textContaining(
|
|
'This deletes the connection information, administrator identity, '
|
|
'and Bluetooth bond',
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.byType(CheckboxListTile), findsOneWidget);
|
|
expect(tester.widget<Checkbox>(find.byType(Checkbox)).value, isFalse);
|
|
expect(tester.widget<Checkbox>(find.byType(Checkbox)).onChanged, isNull);
|
|
expect(
|
|
find.text('This action is unavailable while offline.'),
|
|
findsOneWidget,
|
|
);
|
|
await tester.tap(find.byType(Checkbox));
|
|
await tester.pump();
|
|
expect(tester.widget<Checkbox>(find.byType(Checkbox)).value, isFalse);
|
|
await tester.tap(find.text('Cancel'));
|
|
await tester.pumpAndSettle();
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('guest can read history and manage only temporary codes', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.binding.setSurfaceSize(const Size(400, 800));
|
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
|
await tester.pumpWidget(
|
|
MyApp(
|
|
store: _MemoryStore(
|
|
boxes: const <SavedBox>[
|
|
SavedBox(
|
|
remoteId: 'AA:BB',
|
|
name: 'Front gate',
|
|
identityKey: '1234',
|
|
role: BoxRole.guest,
|
|
identityName: 'Alice',
|
|
),
|
|
],
|
|
),
|
|
splashDuration: Duration.zero,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
final temporary = tester.getRect(
|
|
find.byKey(const ValueKey<String>('temporary-code-action')),
|
|
);
|
|
final permanent = tester.getRect(
|
|
find.byKey(const ValueKey<String>('permanent-code-action')),
|
|
);
|
|
expect(temporary.left, lessThan(permanent.left));
|
|
|
|
await tester.tap(find.text('History'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('No opening has been recorded.'), findsOneWidget);
|
|
await tester.tap(find.byIcon(Icons.close));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(
|
|
find.byKey(const ValueKey<String>('permanent-code-action')),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(find.textContaining('Generate a code'), findsNothing);
|
|
expect(find.text('Stored codes: 0/8'), findsOneWidget);
|
|
await tester.tap(find.byIcon(Icons.close));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(
|
|
find.byKey(const ValueKey<String>('temporary-code-action')),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(find.textContaining('Generate a code'), findsOneWidget);
|
|
final generateButton = tester.widget<FilledButton>(
|
|
find.widgetWithText(FilledButton, 'Generate a code (0/20)'),
|
|
);
|
|
expect(generateButton.onPressed, isNull);
|
|
await tester.tap(find.byIcon(Icons.close));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.byKey(const ValueKey<String>('compact-Settings')));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Firmware update (coming later)'), findsNothing);
|
|
expect(find.text('Factory-reset the box'), findsNothing);
|
|
await tester.ensureVisible(
|
|
find.widgetWithText(OutlinedButton, 'Forget the box'),
|
|
);
|
|
await tester.tap(find.widgetWithText(OutlinedButton, 'Forget the box'));
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
find.textContaining(
|
|
'This deletes the box connection information and Bluetooth bond '
|
|
'from this phone.',
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.byType(CheckboxListTile), findsNothing);
|
|
await tester.tap(find.text('Cancel'));
|
|
await tester.pumpAndSettle();
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('permanent and temporary codes can be copied', (
|
|
WidgetTester tester,
|
|
) async {
|
|
String? clipboardText;
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
SystemChannels.platform,
|
|
(MethodCall call) async {
|
|
if (call.method == 'Clipboard.setData') {
|
|
clipboardText =
|
|
(call.arguments as Map<Object?, Object?>)['text'] as String?;
|
|
}
|
|
return null;
|
|
},
|
|
);
|
|
addTearDown(
|
|
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
SystemChannels.platform,
|
|
null,
|
|
),
|
|
);
|
|
await tester.binding.setSurfaceSize(const Size(400, 800));
|
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
|
|
|
await tester.pumpWidget(
|
|
MyApp(
|
|
store: _MemoryStore(
|
|
boxes: const <SavedBox>[
|
|
SavedBox(
|
|
remoteId: 'AA:BB',
|
|
name: 'Front gate',
|
|
identityKey: 'ADMIN',
|
|
role: BoxRole.administrator,
|
|
),
|
|
],
|
|
cachedStates: <String, Map<String, dynamic>>{
|
|
'ADMIN': <String, dynamic>{
|
|
'codes': <Map<String, dynamic>>[
|
|
<String, dynamic>{
|
|
'slot': 0,
|
|
'code': '123456',
|
|
'kind': 'permanent',
|
|
},
|
|
<String, dynamic>{
|
|
'slot': 1,
|
|
'code': '654321',
|
|
'kind': 'one_time',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
),
|
|
splashDuration: Duration.zero,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(
|
|
find.byKey(const ValueKey<String>('permanent-code-action')),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
final copyButton = find.byTooltip('Copy code');
|
|
expect(copyButton, findsOneWidget);
|
|
expect(
|
|
tester.getRect(copyButton).left,
|
|
lessThan(tester.getRect(find.byTooltip('Edit code')).left),
|
|
);
|
|
await tester.tap(copyButton);
|
|
await tester.pump();
|
|
expect(clipboardText, '123456');
|
|
await tester.tap(find.byIcon(Icons.close));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(
|
|
find.byKey(const ValueKey<String>('temporary-code-action')),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.byTooltip('Copy code'));
|
|
await tester.pump();
|
|
expect(clipboardText, '654321');
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('registration opens the styled setup modal', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
MyApp(store: _MemoryStore(), splashDuration: Duration.zero),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('Add a new OpenParcelBox'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Box identifier'), findsOneWidget);
|
|
expect(find.text('Scan a guest QR code'), findsOneWidget);
|
|
expect(find.text('Detected OpenParcelBox devices'), findsOneWidget);
|
|
expect(find.text('Add this box'), findsOneWidget);
|
|
final addButton = tester.widget<FilledButton>(
|
|
find.widgetWithText(FilledButton, 'Add this box'),
|
|
);
|
|
expect(addButton.onPressed, isNull);
|
|
expect(find.byIcon(Icons.close), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('registration text field works with the French locale', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
MyApp(
|
|
store: _MemoryStore(language: 'fr'),
|
|
splashDuration: Duration.zero,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('Ajouter une nouvelle OpenParcelBox'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(TextFormField), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets(
|
|
'closing registration after editing does not dispose input early',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MyApp(store: _MemoryStore(), splashDuration: Duration.zero),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('Add a new OpenParcelBox'));
|
|
await tester.pumpAndSettle();
|
|
await tester.enterText(find.byType(TextFormField), 'Front gate');
|
|
await tester.tap(find.byIcon(Icons.close));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Add a new OpenParcelBox'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
},
|
|
);
|
|
|
|
testWidgets('restore picks a file before requesting its password', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final backupService = _FakeBackupService();
|
|
await tester.pumpWidget(
|
|
MyApp(
|
|
store: _MemoryStore(),
|
|
backupService: backupService,
|
|
splashDuration: Duration.zero,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('Restore a backup'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(backupService.fileWasPicked, isTrue);
|
|
expect(find.text('Password'), findsNWidgets(2));
|
|
expect(
|
|
find.text('Enter the password used when this backup was created.'),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
tester
|
|
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Confirm'))
|
|
.onPressed,
|
|
isNull,
|
|
);
|
|
expect(
|
|
tester.widget<TextField>(find.byType(TextField)).obscureText,
|
|
isTrue,
|
|
);
|
|
await tester.tap(find.byIcon(Icons.visibility));
|
|
await tester.pump();
|
|
expect(
|
|
tester.widget<TextField>(find.byType(TextField)).obscureText,
|
|
isFalse,
|
|
);
|
|
expect(find.byIcon(Icons.visibility_off), findsOneWidget);
|
|
|
|
await tester.enterText(find.byType(TextField), 'secret');
|
|
await tester.pump();
|
|
await tester.tap(find.text('Confirm'));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
|
|
expect(find.text('Backup restored.'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
}
|