RideNotificationController seam over flutter_local_notifications; RideNotificationCoordinator wires TripRepository.watchActiveTrip() to it and routes Pause/Resume actions back into RecordingEngine. Instantiated eagerly at app root rather than screen-owned, since a pocketed ride has no visible widget tree. Documents an unresolved risk: geolocator's own foreground-service notification cannot be suppressed, so two notifications may be visible until verified on a device.
136 lines
4.4 KiB
Dart
136 lines
4.4 KiB
Dart
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
|
import 'package:drift/native.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:rippr/src/data/database.dart';
|
|
import 'package:rippr/src/data/trip_repository.dart';
|
|
import 'package:rippr/src/domain/models.dart';
|
|
import 'package:rippr/src/notification/ride_notification_controller.dart';
|
|
import 'package:rippr/src/notification/ride_notification_coordinator.dart';
|
|
import 'package:rippr/src/notification/ride_notification_text.dart';
|
|
import 'package:rippr/src/recording/location_source.dart';
|
|
import 'package:rippr/src/recording/recording_engine.dart';
|
|
|
|
/// V3-06: the plugin call itself needs a device (see the ticket's Tests section), but the
|
|
/// text it is given, and the routing from a tapped action back to the engine, do not. The
|
|
/// coordinator tests check the database after an action, not the UI -- the project's
|
|
/// standing rule for anything touching recording state.
|
|
void main() {
|
|
Trip trip({
|
|
TripState state = TripState.recording,
|
|
double distanceM = 12345,
|
|
int movingMillis = 45 * 60 * 1000 + 12 * 1000,
|
|
}) => Trip(
|
|
id: 1,
|
|
startedAt: 0,
|
|
state: state,
|
|
distanceM: distanceM,
|
|
movingMillis: movingMillis,
|
|
);
|
|
|
|
group('rideNotificationText', () {
|
|
test('recording: distance and elapsed, no prefix', () {
|
|
expect(rideNotificationText(trip()), '12.3 km · 00:45:12');
|
|
});
|
|
|
|
test('paused: prefixed so the shade reads correctly without opening the app', () {
|
|
expect(
|
|
rideNotificationText(trip(state: TripState.paused)),
|
|
'Paused · 12.3 km · 00:45:12',
|
|
);
|
|
});
|
|
|
|
test('honours the unit system, exactly like every other display surface', () {
|
|
expect(
|
|
rideNotificationText(trip(), unit: UnitSystem.imperial),
|
|
contains('mi'),
|
|
);
|
|
});
|
|
});
|
|
|
|
group('RideNotificationCoordinator', () {
|
|
late AppDatabase db;
|
|
late TripRepository repo;
|
|
late FakeLocationSource source;
|
|
late RecordingEngine engine;
|
|
late FakeRideNotificationController controller;
|
|
|
|
setUp(() {
|
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
|
db = AppDatabase(NativeDatabase.memory());
|
|
repo = TripRepository(db);
|
|
source = FakeLocationSource();
|
|
engine = RecordingEngine(repository: repo, locationSource: source);
|
|
controller = FakeRideNotificationController();
|
|
});
|
|
|
|
tearDown(() async {
|
|
await source.dispose();
|
|
await db.close();
|
|
});
|
|
|
|
test('shows the notification when a trip starts and cancels it when it ends',
|
|
() async {
|
|
final coordinator = RideNotificationCoordinator(
|
|
controller: controller,
|
|
engine: engine,
|
|
tripStream: repo.watchActiveTrip(),
|
|
);
|
|
|
|
await repo.startTrip(1000);
|
|
await pumpEventQueue();
|
|
expect(controller.visible, isTrue);
|
|
expect(controller.lastText, isNotNull);
|
|
|
|
await repo.completeTrip(2000);
|
|
await pumpEventQueue();
|
|
expect(controller.visible, isFalse);
|
|
expect(controller.cancelCalls, greaterThan(0));
|
|
|
|
await coordinator.dispose();
|
|
});
|
|
|
|
test('a tapped Pause action actually pauses the ride -- checked in the '
|
|
'database, not the notification', () async {
|
|
final coordinator = RideNotificationCoordinator(
|
|
controller: controller,
|
|
engine: engine,
|
|
tripStream: repo.watchActiveTrip(),
|
|
);
|
|
await repo.startTrip(1000);
|
|
await pumpEventQueue();
|
|
|
|
controller.simulateAction(RideNotificationAction.pause);
|
|
await pumpEventQueue();
|
|
|
|
final active = await repo.activeTrip();
|
|
expect(active?.state, TripState.paused);
|
|
|
|
await coordinator.dispose();
|
|
});
|
|
|
|
test('a tapped Resume action only resumes an actually-paused trip', () async {
|
|
final coordinator = RideNotificationCoordinator(
|
|
controller: controller,
|
|
engine: engine,
|
|
tripStream: repo.watchActiveTrip(),
|
|
);
|
|
await repo.startTrip(1000);
|
|
await pumpEventQueue();
|
|
|
|
// Recording, not paused: a Resume action here would be bogus.
|
|
controller.simulateAction(RideNotificationAction.resume);
|
|
await pumpEventQueue();
|
|
expect((await repo.activeTrip())?.state, TripState.recording);
|
|
|
|
await repo.pauseTrip(2000);
|
|
await pumpEventQueue();
|
|
controller.simulateAction(RideNotificationAction.resume);
|
|
await pumpEventQueue();
|
|
|
|
expect((await repo.activeTrip())?.state, TripState.recording);
|
|
|
|
await coordinator.dispose();
|
|
});
|
|
});
|
|
}
|