506 lines
18 KiB
Dart
506 lines
18 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/services/background_work_diagnostics_service.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
group('DownloadStallDetector', () {
|
|
late DownloadStallDetector detector;
|
|
final start = DateTime(2026, 7, 25, 12);
|
|
|
|
setUp(() {
|
|
detector = DownloadStallDetector(
|
|
minimumBackgroundGap: const Duration(seconds: 60),
|
|
minimumProgressBytes: 64 * 1024,
|
|
observationsToConfirm: 2,
|
|
);
|
|
});
|
|
|
|
test('needs two consecutive stalls before accusing the OEM', () {
|
|
detector.onPaused(_activity(), start);
|
|
expect(detector.onResumed(_activity(), start.add(const Duration(minutes: 5)), networkAvailable: true), isFalse);
|
|
expect(detector.isStalled, isFalse);
|
|
|
|
detector.onPaused(_activity(), start.add(const Duration(minutes: 6)));
|
|
expect(detector.onResumed(_activity(), start.add(const Duration(minutes: 11)), networkAvailable: true), isTrue);
|
|
expect(detector.isStalled, isTrue);
|
|
});
|
|
|
|
test('confirms only once, so the warning is not re-raised every resume', () {
|
|
for (var i = 0; i < 2; i++) {
|
|
detector.onPaused(_activity(), start);
|
|
detector.onResumed(_activity(), start.add(const Duration(minutes: 5)), networkAvailable: true);
|
|
}
|
|
expect(detector.isStalled, isTrue);
|
|
|
|
detector.onPaused(_activity(), start);
|
|
expect(
|
|
detector.onResumed(_activity(), start.add(const Duration(minutes: 5)), networkAvailable: true),
|
|
isFalse,
|
|
reason: 'already confirmed',
|
|
);
|
|
});
|
|
|
|
test('real background progress clears the counter', () {
|
|
detector.onPaused(_activity(), start);
|
|
detector.onResumed(_activity(), start.add(const Duration(minutes: 5)), networkAvailable: true);
|
|
expect(detector.consecutiveStalls, 1);
|
|
|
|
detector.onPaused(_activity(), start.add(const Duration(minutes: 6)));
|
|
detector.onResumed(
|
|
_activity(downloadedBytes: 1000 + 5 * 1024 * 1024),
|
|
start.add(const Duration(minutes: 11)),
|
|
networkAvailable: true,
|
|
);
|
|
expect(detector.consecutiveStalls, 0);
|
|
});
|
|
|
|
test('a completed download below the byte floor clears the counter', () {
|
|
detector.onPaused(_activity(), start);
|
|
detector.onResumed(_activity(), start.add(const Duration(minutes: 5)), networkAvailable: true);
|
|
expect(detector.consecutiveStalls, 1);
|
|
|
|
detector.onPaused(_activity(), start.add(const Duration(minutes: 6)));
|
|
detector.onResumed(
|
|
_activity(activeTasks: 0, completedTasks: 1, downloadedBytes: 21 * 1024),
|
|
start.add(const Duration(minutes: 11)),
|
|
networkAvailable: true,
|
|
);
|
|
|
|
expect(detector.consecutiveStalls, 0);
|
|
});
|
|
|
|
test('a completion with omitted byte counters still clears the counter', () {
|
|
detector.onPaused(_activity(), start);
|
|
detector.onResumed(_activity(), start.add(const Duration(minutes: 5)), networkAvailable: true);
|
|
expect(detector.consecutiveStalls, 1);
|
|
|
|
detector.onPaused(_activity(downloadedBytes: 500 * 1024 * 1024), start.add(const Duration(minutes: 6)));
|
|
detector.onResumed(
|
|
_activity(activeTasks: 0, completedTasks: 1, downloadedBytes: 0),
|
|
start.add(const Duration(minutes: 11)),
|
|
networkAvailable: true,
|
|
);
|
|
|
|
expect(detector.consecutiveStalls, 0);
|
|
});
|
|
|
|
test('percentage progress clears the counter when byte length is unknown', () {
|
|
detector.onPaused(_activity(), start);
|
|
detector.onResumed(_activity(), start.add(const Duration(minutes: 5)), networkAvailable: true);
|
|
expect(detector.consecutiveStalls, 1);
|
|
|
|
detector.onPaused(_activity(progressUnits: 20), start.add(const Duration(minutes: 6)));
|
|
detector.onResumed(_activity(progressUnits: 21), start.add(const Duration(minutes: 11)), networkAvailable: true);
|
|
|
|
expect(detector.consecutiveStalls, 0);
|
|
});
|
|
|
|
test('an idle app is never scored', () {
|
|
detector.onPaused(_activity(activeTasks: 0), start);
|
|
expect(
|
|
detector.onResumed(_activity(activeTasks: 0), start.add(const Duration(hours: 8)), networkAvailable: true),
|
|
isFalse,
|
|
);
|
|
expect(detector.consecutiveStalls, 0);
|
|
});
|
|
|
|
test('a task removed without failing is inconclusive', () {
|
|
detector.onPaused(_activity(), start);
|
|
detector.onResumed(_activity(), start.add(const Duration(minutes: 5)), networkAvailable: true);
|
|
expect(detector.consecutiveStalls, 1);
|
|
|
|
detector.onPaused(_activity(), start.add(const Duration(minutes: 6)));
|
|
detector.onResumed(_activity(activeTasks: 0), start.add(const Duration(minutes: 11)), networkAvailable: true);
|
|
|
|
expect(detector.consecutiveStalls, 1);
|
|
});
|
|
|
|
test('a newly failed active task is stall evidence', () {
|
|
detector.onPaused(_activity(), start);
|
|
detector.onResumed(
|
|
_activity(activeTasks: 0, failedTasks: 1),
|
|
start.add(const Duration(minutes: 5)),
|
|
networkAvailable: true,
|
|
);
|
|
|
|
expect(detector.consecutiveStalls, 1);
|
|
});
|
|
|
|
test('a short background trip proves nothing', () {
|
|
detector.onPaused(_activity(), start);
|
|
detector.onResumed(_activity(), start.add(const Duration(seconds: 30)), networkAvailable: true);
|
|
|
|
expect(detector.consecutiveStalls, 0);
|
|
});
|
|
|
|
test('being offline is not evidence of a restriction', () {
|
|
detector.onPaused(_activity(), start);
|
|
detector.onResumed(_activity(), start.add(const Duration(minutes: 5)), networkAvailable: false);
|
|
|
|
expect(detector.consecutiveStalls, 0);
|
|
});
|
|
|
|
test('an offline endpoint is inconclusive even when policy allows downloads', () {
|
|
detector.onPaused(_activity(networkAvailable: false), start);
|
|
detector.onResumed(
|
|
_activity(networkAvailable: false),
|
|
start.add(const Duration(minutes: 5)),
|
|
networkAvailable: true,
|
|
);
|
|
|
|
expect(detector.consecutiveStalls, 0);
|
|
});
|
|
|
|
test('a network transition during the background window is inconclusive', () {
|
|
detector.onPaused(_activity(networkStateGeneration: 4), start);
|
|
detector.onResumed(
|
|
_activity(networkStateGeneration: 5),
|
|
start.add(const Duration(minutes: 5)),
|
|
networkAvailable: true,
|
|
);
|
|
|
|
expect(detector.consecutiveStalls, 0);
|
|
});
|
|
|
|
test('downloads deleted while backgrounded are inconclusive', () {
|
|
detector.onPaused(_activity(activeTasks: 2, downloadedBytes: 900 * 1024 * 1024), start);
|
|
detector.onResumed(
|
|
_activity(downloadedBytes: 10 * 1024 * 1024),
|
|
start.add(const Duration(minutes: 5)),
|
|
networkAvailable: true,
|
|
);
|
|
|
|
expect(detector.consecutiveStalls, 0);
|
|
});
|
|
|
|
test('reset clears both the armed snapshot and the count', () {
|
|
detector.onPaused(_activity(), start);
|
|
detector.onResumed(_activity(), start.add(const Duration(minutes: 5)), networkAvailable: true);
|
|
detector.reset();
|
|
expect(detector.consecutiveStalls, 0);
|
|
|
|
expect(detector.onResumed(_activity(), start.add(const Duration(minutes: 10)), networkAvailable: true), isFalse);
|
|
});
|
|
});
|
|
|
|
group('BackgroundWorkStatus', () {
|
|
test('describe reports unknown until a probe lands', () {
|
|
expect(const BackgroundWorkStatus().describe(), 'unknown');
|
|
});
|
|
|
|
test('describe carries verdict, reasons and raw signals for log uploads', () {
|
|
const status = BackgroundWorkStatus(
|
|
verdict: BackgroundWorkVerdict.blocked,
|
|
reasons: [BackgroundWorkReason.backgroundRestricted, BackgroundWorkReason.standbyRestricted],
|
|
standbyBucket: 45,
|
|
ignoringBatteryOptimizations: false,
|
|
probed: true,
|
|
);
|
|
|
|
expect(status.describe(), 'blocked background_restricted+standby_restricted bucket:45 batteryWhitelist:false');
|
|
});
|
|
|
|
test('routes notification faults to their valid settings screens', () {
|
|
const channel = BackgroundWorkStatus(
|
|
verdict: BackgroundWorkVerdict.degraded,
|
|
reasons: [BackgroundWorkReason.downloadChannelBlocked],
|
|
probed: true,
|
|
);
|
|
const appWide = BackgroundWorkStatus(
|
|
verdict: BackgroundWorkVerdict.blocked,
|
|
reasons: [BackgroundWorkReason.notificationsDisabled],
|
|
probed: true,
|
|
);
|
|
const battery = BackgroundWorkStatus(
|
|
verdict: BackgroundWorkVerdict.blocked,
|
|
reasons: [BackgroundWorkReason.backgroundRestricted],
|
|
probed: true,
|
|
);
|
|
|
|
expect(channel.remedyTarget, BackgroundSettingsTarget.notificationChannel);
|
|
expect(appWide.remedyTarget, BackgroundSettingsTarget.appNotifications);
|
|
expect(battery.remedyTarget, BackgroundSettingsTarget.appDetails);
|
|
});
|
|
});
|
|
|
|
group('BackgroundWorkDiagnosticsService', () {
|
|
late List<MethodCall> calls;
|
|
late Map<String, Object?> signals;
|
|
const channel = MethodChannel('com.plezy/device.test');
|
|
|
|
BackgroundWorkDiagnosticsService buildService({
|
|
DownloadStallDetector? stallDetector,
|
|
Future<bool> Function()? networkProbe,
|
|
DateTime Function()? clock,
|
|
bool supported = true,
|
|
}) => BackgroundWorkDiagnosticsService.forTesting(
|
|
channel: channel,
|
|
stallDetector: stallDetector,
|
|
networkProbe: networkProbe,
|
|
clock: clock,
|
|
supported: supported,
|
|
);
|
|
|
|
setUp(() {
|
|
calls = [];
|
|
signals = {'verdict': 'ok', 'reasons': <String>[]};
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
|
|
calls.add(call);
|
|
return switch (call.method) {
|
|
'getBackgroundWorkSignals' => signals,
|
|
'openBackgroundSettings' => true,
|
|
_ => null,
|
|
};
|
|
});
|
|
});
|
|
|
|
tearDown(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null);
|
|
});
|
|
|
|
test('a healthy probe stays healthy but records that it ran', () async {
|
|
final service = buildService();
|
|
|
|
final status = await service.refresh();
|
|
|
|
expect(status.verdict, BackgroundWorkVerdict.ok);
|
|
expect(status.reasons, isEmpty);
|
|
expect(status.probed, isTrue);
|
|
expect(service.describeSync(), 'ok');
|
|
});
|
|
|
|
test('binding download activity performs the cold-start probe', () async {
|
|
signals = {
|
|
'verdict': 'blocked',
|
|
'reasons': ['background_restricted'],
|
|
};
|
|
final service = buildService();
|
|
|
|
service.bindActivitySource(_activity);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(calls.where((call) => call.method == 'getBackgroundWorkSignals'), hasLength(1));
|
|
expect(service.status.isBlocked, isTrue);
|
|
service.unbindActivitySource(_activity);
|
|
service.dispose();
|
|
});
|
|
|
|
test('unsupported devices neither observe nor probe', () async {
|
|
final service = buildService(supported: false);
|
|
|
|
service.bindActivitySource(_activity);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(service.isSupported, isFalse);
|
|
expect(calls, isEmpty);
|
|
expect(service.status.probed, isFalse);
|
|
service.unbindActivitySource(_activity);
|
|
service.dispose();
|
|
});
|
|
|
|
test('parses a blocked verdict with its reasons in order', () async {
|
|
signals = {
|
|
'verdict': 'blocked',
|
|
'reasons': ['background_restricted', 'standby_restricted'],
|
|
'standbyBucket': 45,
|
|
'ignoringBatteryOptimizations': false,
|
|
};
|
|
final service = buildService();
|
|
|
|
final status = await service.refresh();
|
|
|
|
expect(status.verdict, BackgroundWorkVerdict.blocked);
|
|
expect(status.reasons, [BackgroundWorkReason.backgroundRestricted, BackgroundWorkReason.standbyRestricted]);
|
|
expect(status.standbyBucket, 45);
|
|
expect(status.primaryReason, BackgroundWorkReason.backgroundRestricted);
|
|
});
|
|
|
|
test('a blocked verdict with no recognizable reason is not trusted', () async {
|
|
// A newer native build emitting an unknown reason id must not produce a
|
|
// warning the UI has nothing to say about.
|
|
signals = {
|
|
'verdict': 'blocked',
|
|
'reasons': ['some_future_reason'],
|
|
};
|
|
final service = buildService();
|
|
|
|
final status = await service.refresh();
|
|
|
|
expect(status.verdict, BackgroundWorkVerdict.ok);
|
|
expect(status.reasons, isEmpty);
|
|
});
|
|
|
|
test('refuses an oem_unknown reason arriving over the wire', () async {
|
|
signals = {
|
|
'verdict': 'blocked',
|
|
'reasons': ['oem_unknown'],
|
|
};
|
|
final service = buildService();
|
|
|
|
final status = await service.refresh();
|
|
|
|
expect(status.reasons, isEmpty);
|
|
expect(status.verdict, BackgroundWorkVerdict.ok);
|
|
});
|
|
|
|
test('data saver alone is degraded, not blocked', () async {
|
|
signals = {
|
|
'verdict': 'degraded',
|
|
'reasons': ['data_saver'],
|
|
};
|
|
final service = buildService();
|
|
|
|
final status = await service.refresh();
|
|
|
|
expect(status.verdict, BackgroundWorkVerdict.degraded);
|
|
expect(status.isBlocked, isFalse);
|
|
expect(status.isHealthy, isFalse);
|
|
});
|
|
|
|
test('concurrent refreshes coalesce into a single platform call', () async {
|
|
final service = buildService();
|
|
|
|
await Future.wait([service.refresh(), service.refresh(), service.refresh()]);
|
|
|
|
expect(calls.where((c) => c.method == 'getBackgroundWorkSignals'), hasLength(1));
|
|
});
|
|
|
|
test('a missing native handler never produces a warning and stops asking', () async {
|
|
var attempts = 0;
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
|
|
attempts++;
|
|
throw MissingPluginException();
|
|
});
|
|
final service = buildService();
|
|
|
|
final first = await service.refresh();
|
|
final second = await service.refresh();
|
|
|
|
expect(first.verdict, BackgroundWorkVerdict.ok);
|
|
expect(first.probed, isFalse);
|
|
expect(second.probed, isFalse);
|
|
expect(attempts, 1);
|
|
});
|
|
|
|
test('an in-flight probe completing after disposal is ignored', () async {
|
|
final gate = Completer<Map<String, Object?>>();
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) {
|
|
calls.add(call);
|
|
return gate.future;
|
|
});
|
|
final service = buildService();
|
|
|
|
final refresh = service.refresh();
|
|
await Future<void>.delayed(Duration.zero);
|
|
service.dispose();
|
|
gate.complete({
|
|
'verdict': 'blocked',
|
|
'reasons': ['background_restricted'],
|
|
});
|
|
|
|
await expectLater(refresh, completes);
|
|
expect(service.status.probed, isFalse);
|
|
});
|
|
|
|
test('a confirmed stall survives a probe that reports everything is fine', () async {
|
|
final detector = DownloadStallDetector(observationsToConfirm: 1, minimumBackgroundGap: Duration.zero);
|
|
final service = buildService(stallDetector: detector, networkProbe: () async => true);
|
|
detector.onPaused(_activity(downloadedBytes: 0), DateTime(2026));
|
|
detector.onResumed(_activity(downloadedBytes: 0), DateTime(2026, 1, 1, 1), networkAvailable: true);
|
|
expect(detector.isStalled, isTrue);
|
|
|
|
final status = await service.refresh();
|
|
|
|
expect(status.verdict, BackgroundWorkVerdict.blocked);
|
|
expect(status.reasons, [BackgroundWorkReason.oemUnknown]);
|
|
});
|
|
|
|
test('clearing stall evidence drops the reason and re-probes', () async {
|
|
final detector = DownloadStallDetector(observationsToConfirm: 1, minimumBackgroundGap: Duration.zero);
|
|
final service = buildService(stallDetector: detector, networkProbe: () async => true);
|
|
detector.onPaused(_activity(downloadedBytes: 0), DateTime(2026));
|
|
detector.onResumed(_activity(downloadedBytes: 0), DateTime(2026, 1, 1, 1), networkAvailable: true);
|
|
await service.refresh();
|
|
expect(service.status.isBlocked, isTrue);
|
|
|
|
await service.clearStallEvidence();
|
|
|
|
expect(service.status.reasons, isEmpty);
|
|
expect(service.status.verdict, BackgroundWorkVerdict.ok);
|
|
expect(detector.isStalled, isFalse);
|
|
});
|
|
|
|
test('opening settings forwards the target id the native side expects', () async {
|
|
final service = buildService();
|
|
|
|
final opened = await service.openSettings(BackgroundSettingsTarget.notificationChannel);
|
|
|
|
expect(opened, isTrue);
|
|
final call = calls.firstWhere((c) => c.method == 'openBackgroundSettings');
|
|
expect(call.arguments, 'notification_channel');
|
|
});
|
|
|
|
test('lifecycle resume scores the synchronously captured snapshot', () async {
|
|
final detector = DownloadStallDetector(observationsToConfirm: 1, minimumBackgroundGap: Duration.zero);
|
|
final networkGate = Completer<bool>();
|
|
var now = DateTime(2026);
|
|
var activity = _activity(downloadedBytes: 1000);
|
|
final service = buildService(stallDetector: detector, networkProbe: () => networkGate.future, clock: () => now);
|
|
DownloadActivitySnapshot source() => activity;
|
|
service.bindActivitySource(source);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
service.didChangeAppLifecycleState(AppLifecycleState.paused);
|
|
now = now.add(const Duration(minutes: 5));
|
|
service.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
|
activity = _activity(downloadedBytes: 5 * 1024 * 1024);
|
|
networkGate.complete(true);
|
|
await Future<void>.delayed(Duration.zero);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(detector.isStalled, isTrue, reason: 'progress after resume must not be credited to the background window');
|
|
expect(service.status.reasons, contains(BackgroundWorkReason.oemUnknown));
|
|
service.unbindActivitySource(source);
|
|
service.dispose();
|
|
});
|
|
|
|
test('notifies listeners only when the status actually changes', () async {
|
|
signals = {
|
|
'verdict': 'blocked',
|
|
'reasons': ['background_restricted'],
|
|
};
|
|
final service = buildService();
|
|
var notifications = 0;
|
|
service.addListener(() => notifications++);
|
|
|
|
await service.refresh();
|
|
await service.refresh();
|
|
|
|
expect(notifications, 1);
|
|
});
|
|
});
|
|
}
|
|
|
|
DownloadActivitySnapshot _activity({
|
|
int activeTasks = 1,
|
|
int completedTasks = 0,
|
|
int failedTasks = 0,
|
|
int downloadedBytes = 1000,
|
|
int progressUnits = 0,
|
|
bool? networkAvailable = true,
|
|
int networkStateGeneration = 0,
|
|
}) => (
|
|
activeTasks: activeTasks,
|
|
completedTasks: completedTasks,
|
|
failedTasks: failedTasks,
|
|
downloadedBytes: downloadedBytes,
|
|
progressUnits: progressUnits,
|
|
networkAvailable: networkAvailable,
|
|
networkStateGeneration: networkStateGeneration,
|
|
);
|