fix(runtime): harden application service boundaries
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/models/companion_remote/remote_command.dart';
|
||||
import 'package:plezy/mpv/mpv.dart';
|
||||
import 'package:plezy/providers/playback_state_provider.dart';
|
||||
import 'package:plezy/screens/video_player_screen.dart';
|
||||
import 'package:plezy/services/companion_remote/companion_remote_receiver.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../test_helpers/media_items.dart';
|
||||
import '../../test_helpers/mock_player_channels.dart';
|
||||
import '../../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
final settings = await SettingsService.getInstance();
|
||||
await settings.write(SettingsService.seekTimeSmall, 7);
|
||||
});
|
||||
|
||||
testWidgets('in-flight Companion seeks stay bound to the receipt-time player', (tester) async {
|
||||
final nativeInitialize = Completer<bool>();
|
||||
final playerA = _ControlledSeekPlayer(position: const Duration(seconds: 30));
|
||||
final playerB = _ControlledSeekPlayer(position: const Duration(seconds: 50));
|
||||
addTearDown(playerA.dispose);
|
||||
|
||||
await withMockPlayerChannels(
|
||||
methodChannelName: 'com.plezy/mpv_player',
|
||||
eventChannelName: 'com.plezy/mpv_player/events',
|
||||
methodHandler: (call) {
|
||||
if (call.method == 'initialize') return nativeInitialize.future;
|
||||
return Future<Object?>.value(null);
|
||||
},
|
||||
eventHandler: (_) async => null,
|
||||
testBody: () async {
|
||||
final key = GlobalKey<VideoPlayerScreenState>();
|
||||
await tester.pumpWidget(_screen(key));
|
||||
expect(key.currentState, isNotNull);
|
||||
key.currentState!.player = playerA;
|
||||
|
||||
CompanionRemoteReceiver.instance.handleCommand(const RemoteCommand(type: RemoteCommandType.seekForward), null);
|
||||
expect(playerA.seekTargets, [const Duration(seconds: 37)]);
|
||||
key.currentState!.player = playerB;
|
||||
playerA.completeSeek();
|
||||
await tester.pump();
|
||||
expect(playerB.seekTargets, isEmpty);
|
||||
|
||||
key.currentState!.player = playerA;
|
||||
CompanionRemoteReceiver.instance.handleCommand(const RemoteCommand(type: RemoteCommandType.seekBackward), null);
|
||||
expect(playerA.seekTargets.last, const Duration(seconds: 23));
|
||||
key.currentState!.player = playerB;
|
||||
playerA.completeSeek();
|
||||
await tester.pump();
|
||||
expect(playerB.seekTargets, isEmpty);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
nativeInitialize.complete(true);
|
||||
await tester.pump();
|
||||
CompanionRemoteReceiver.instance.handleCommand(const RemoteCommand(type: RemoteCommandType.seekForward), null);
|
||||
expect(playerB.seekTargets, isEmpty, reason: 'disposed owner callbacks must be inert');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _screen(GlobalKey<VideoPlayerScreenState> key) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) => PlaybackStateProvider(),
|
||||
child: MaterialApp(
|
||||
home: VideoPlayerScreen(
|
||||
key: key,
|
||||
metadata: testMediaItem(title: 'Companion target test'),
|
||||
isOffline: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ControlledSeekPlayer implements Player {
|
||||
_ControlledSeekPlayer({required Duration position})
|
||||
: _state = PlayerState(position: position, duration: const Duration(minutes: 10), seekable: true);
|
||||
|
||||
final PlayerState _state;
|
||||
final List<Duration> seekTargets = [];
|
||||
Completer<void>? _seekCompleter;
|
||||
|
||||
void completeSeek() {
|
||||
_seekCompleter?.complete();
|
||||
_seekCompleter = null;
|
||||
}
|
||||
|
||||
@override
|
||||
PlayerState get state => _state;
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) {
|
||||
seekTargets.add(position);
|
||||
return (_seekCompleter = Completer<void>()).future;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose({bool preserveDisplayMode = false}) async {}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/screens/video_player/frame_rate_matcher.dart';
|
||||
|
||||
void main() {
|
||||
test('single suppression window lasts through its configured deadline', () {
|
||||
fakeAsync((async) {
|
||||
final matcher = FrameRateMatcher();
|
||||
|
||||
matcher.beginSuppressWindow(2);
|
||||
expect(matcher.suppressesMediaPause, isTrue);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 4999));
|
||||
expect(matcher.suppressesMediaPause, isTrue);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 1));
|
||||
expect(matcher.suppressesMediaPause, isFalse);
|
||||
matcher.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('overlapping suppression windows remain active until the newest deadline', () {
|
||||
fakeAsync((async) {
|
||||
final matcher = FrameRateMatcher();
|
||||
|
||||
matcher.beginSuppressWindow(0);
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
matcher.beginSuppressWindow(0);
|
||||
expect(async.nonPeriodicTimerCount, 1);
|
||||
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(
|
||||
matcher.suppressesMediaPause,
|
||||
isTrue,
|
||||
reason: 'the older window must not clear the overlapping newer window',
|
||||
);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 1999));
|
||||
expect(matcher.suppressesMediaPause, isTrue);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 1));
|
||||
expect(matcher.suppressesMediaPause, isFalse);
|
||||
matcher.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('dispose cancels an active suppression deadline', () {
|
||||
fakeAsync((async) {
|
||||
final matcher = FrameRateMatcher()..beginSuppressWindow(0);
|
||||
expect(async.nonPeriodicTimerCount, 1);
|
||||
|
||||
matcher.dispose();
|
||||
expect(matcher.suppressesMediaPause, isFalse);
|
||||
expect(async.nonPeriodicTimerCount, 0);
|
||||
|
||||
async.elapse(const Duration(seconds: 10));
|
||||
expect(matcher.suppressesMediaPause, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/live_tv_support.dart';
|
||||
import 'package:plezy/models/livetv_capture_buffer.dart';
|
||||
import 'package:plezy/screens/video_player/live_timeline_report.dart';
|
||||
|
||||
void main() {
|
||||
group('runLiveTimelineReport', () {
|
||||
test('late pre-channel heartbeat cannot replace adopted channel buffer', () async {
|
||||
final bufferA = _buffer(1000);
|
||||
final bufferB = _buffer(2000);
|
||||
final freshBufferB = _buffer(2100);
|
||||
final sessionA = _FakeSession(bufferA);
|
||||
final sessionB = _FakeSession(bufferB);
|
||||
LiveTvPlaybackSession? currentSession = sessionA;
|
||||
var generation = 1;
|
||||
var currentBuffer = bufferA;
|
||||
var commits = 0;
|
||||
|
||||
final oldHeartbeat = _run(
|
||||
sessionA,
|
||||
generation,
|
||||
state: 'playing',
|
||||
currentSession: () => currentSession,
|
||||
currentGeneration: () => generation,
|
||||
commit: (buffer) {
|
||||
commits++;
|
||||
currentBuffer = buffer;
|
||||
},
|
||||
);
|
||||
generation++;
|
||||
final stopped = _run(
|
||||
sessionA,
|
||||
generation,
|
||||
state: 'stopped',
|
||||
currentSession: () => currentSession,
|
||||
currentGeneration: () => generation,
|
||||
commit: (buffer) {
|
||||
commits++;
|
||||
currentBuffer = buffer;
|
||||
},
|
||||
);
|
||||
sessionA.complete(1, _buffer(1200));
|
||||
await stopped;
|
||||
|
||||
currentSession = sessionB;
|
||||
currentBuffer = bufferB;
|
||||
generation++;
|
||||
sessionA.complete(0, _buffer(1300));
|
||||
await oldHeartbeat;
|
||||
|
||||
expect(sessionA.states, ['playing', 'stopped']);
|
||||
expect(commits, 0);
|
||||
expect(currentBuffer, same(bufferB));
|
||||
|
||||
final currentHeartbeat = _run(
|
||||
sessionB,
|
||||
generation,
|
||||
state: 'playing',
|
||||
currentSession: () => currentSession,
|
||||
currentGeneration: () => generation,
|
||||
commit: (buffer) {
|
||||
commits++;
|
||||
currentBuffer = buffer;
|
||||
},
|
||||
);
|
||||
sessionB.complete(0, freshBufferB);
|
||||
await currentHeartbeat;
|
||||
expect(commits, 1);
|
||||
expect(currentBuffer, same(freshBufferB));
|
||||
});
|
||||
|
||||
test('session replacement invalidates report without generation change', () async {
|
||||
final sessionA = _FakeSession(_buffer(1000));
|
||||
final sessionB = _FakeSession(_buffer(2000));
|
||||
LiveTvPlaybackSession? currentSession = sessionA;
|
||||
const generation = 4;
|
||||
var commits = 0;
|
||||
|
||||
final report = _run(
|
||||
sessionA,
|
||||
generation,
|
||||
state: 'playing',
|
||||
currentSession: () => currentSession,
|
||||
currentGeneration: () => generation,
|
||||
commit: (_) => commits++,
|
||||
);
|
||||
currentSession = sessionB;
|
||||
sessionA.complete(0, _buffer(1100));
|
||||
await report;
|
||||
|
||||
expect(commits, 0);
|
||||
});
|
||||
|
||||
test('generation change invalidates report for the same session', () async {
|
||||
final session = _FakeSession(_buffer(1000));
|
||||
final currentSession = session;
|
||||
var generation = 8;
|
||||
var commits = 0;
|
||||
|
||||
final report = _run(
|
||||
session,
|
||||
generation,
|
||||
state: 'paused',
|
||||
currentSession: () => currentSession,
|
||||
currentGeneration: () => generation,
|
||||
commit: (_) => commits++,
|
||||
);
|
||||
generation++;
|
||||
session.complete(0, _buffer(1100));
|
||||
await report;
|
||||
|
||||
expect(commits, 0);
|
||||
});
|
||||
|
||||
test('terminal and unmounted responses never commit but are still sent', () async {
|
||||
final session = _FakeSession(_buffer(1000));
|
||||
var mounted = true;
|
||||
var commits = 0;
|
||||
|
||||
final stopped = _run(
|
||||
session,
|
||||
1,
|
||||
state: 'stopped',
|
||||
currentSession: () => session,
|
||||
currentGeneration: () => 1,
|
||||
isMounted: () => mounted,
|
||||
commit: (_) => commits++,
|
||||
);
|
||||
session.complete(0, _buffer(1100));
|
||||
await stopped;
|
||||
|
||||
final playing = _run(
|
||||
session,
|
||||
1,
|
||||
state: 'playing',
|
||||
currentSession: () => session,
|
||||
currentGeneration: () => 1,
|
||||
isMounted: () => mounted,
|
||||
commit: (_) => commits++,
|
||||
);
|
||||
mounted = false;
|
||||
session.complete(1, _buffer(1200));
|
||||
await playing;
|
||||
|
||||
expect(session.states, ['stopped', 'playing']);
|
||||
expect(commits, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _run(
|
||||
LiveTvPlaybackSession session,
|
||||
int generation, {
|
||||
required String state,
|
||||
required LiveTvPlaybackSession? Function() currentSession,
|
||||
required int Function() currentGeneration,
|
||||
bool Function()? isMounted,
|
||||
required void Function(CaptureBuffer) commit,
|
||||
}) {
|
||||
return runLiveTimelineReport(
|
||||
requestSession: session,
|
||||
requestGeneration: generation,
|
||||
state: state,
|
||||
positionMs: 321,
|
||||
currentSession: currentSession,
|
||||
currentGeneration: currentGeneration,
|
||||
isMounted: isMounted ?? () => true,
|
||||
commit: commit,
|
||||
);
|
||||
}
|
||||
|
||||
CaptureBuffer _buffer(double startedAt) => CaptureBuffer(startedAt: startedAt, seekStartSeconds: 0, seekEndSeconds: 60);
|
||||
|
||||
class _FakeSession implements LiveTvPlaybackSession {
|
||||
_FakeSession(this.captureBuffer);
|
||||
|
||||
@override
|
||||
final CaptureBuffer captureBuffer;
|
||||
final List<String> states = [];
|
||||
final List<Completer<CaptureBuffer?>> _reports = [];
|
||||
|
||||
void complete(int index, CaptureBuffer? buffer) => _reports[index].complete(buffer);
|
||||
|
||||
@override
|
||||
LiveTvBackgroundPolicy get backgroundPolicy => LiveTvBackgroundPolicy.retainSession;
|
||||
|
||||
@override
|
||||
bool get canTimeShift => true;
|
||||
|
||||
@override
|
||||
LiveProgramInfo get program => const LiveProgramInfo(durationMs: 999);
|
||||
|
||||
@override
|
||||
Future<LiveTvPlaybackSession?> recover({required bool directStream, required bool directStreamAudio}) async => this;
|
||||
|
||||
@override
|
||||
Future<CaptureBuffer?> reportTimeline({required String state, required int positionMs, required int durationMs}) {
|
||||
states.add(state);
|
||||
final completer = Completer<CaptureBuffer?>();
|
||||
_reports.add(completer);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> streamUrlAt({int? offsetSeconds}) async => 'https://example.invalid/live';
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:os_media_controls/os_media_controls.dart';
|
||||
import 'package:plezy/screens/video_player/media_control_router.dart';
|
||||
|
||||
void main() {
|
||||
test('denied playback and media-item commands are consumed without mutation', () {
|
||||
var canControl = false;
|
||||
var canNavigate = false;
|
||||
final calls = <String>[];
|
||||
final router = _router(canControl: () => canControl, canNavigate: () => canNavigate, calls: calls);
|
||||
|
||||
final denied = <MediaControlEvent>[
|
||||
const PlayEvent(),
|
||||
const PauseEvent(),
|
||||
const TogglePlayPauseEvent(),
|
||||
const SeekEvent(Duration(seconds: 8)),
|
||||
const SkipForwardEvent(Duration(seconds: 10)),
|
||||
const SkipBackwardEvent(null),
|
||||
const SetSpeedEvent(1.5),
|
||||
const NextTrackEvent(),
|
||||
const PreviousTrackEvent(),
|
||||
];
|
||||
for (final event in denied) {
|
||||
expect(router.route(event), isTrue, reason: '$event must be consumed');
|
||||
}
|
||||
expect(calls, isEmpty);
|
||||
|
||||
expect(router.route(const StopEvent()), isTrue);
|
||||
expect(calls, ['stop']);
|
||||
|
||||
canControl = true;
|
||||
for (final event in denied) {
|
||||
router.route(event);
|
||||
}
|
||||
expect(calls, ['stop', 'play', 'pause', 'toggle', 'seek:8', 'forward:10', 'backward:null', 'speed:1.5']);
|
||||
|
||||
canNavigate = true;
|
||||
router.route(const NextTrackEvent());
|
||||
router.route(const PreviousTrackEvent());
|
||||
expect(calls.sublist(calls.length - 2), ['next', 'previous']);
|
||||
});
|
||||
|
||||
test('unknown lifecycle events are left to the screen lifecycle handler', () {
|
||||
final router = _router(canControl: () => false, canNavigate: () => false, calls: []);
|
||||
expect(router.route(const AudioInterruptionBeganEvent()), isFalse);
|
||||
expect(router.route(const AudioRouteOldDeviceUnavailableEvent()), isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
VideoPlayerMediaControlRouter _router({
|
||||
required bool Function() canControl,
|
||||
required bool Function() canNavigate,
|
||||
required List<String> calls,
|
||||
}) {
|
||||
return VideoPlayerMediaControlRouter(
|
||||
canControlPlayback: canControl,
|
||||
canNavigateMediaItems: canNavigate,
|
||||
onPlay: () => calls.add('play'),
|
||||
onPause: () => calls.add('pause'),
|
||||
onTogglePlayPause: () => calls.add('toggle'),
|
||||
onSeek: (position) => calls.add('seek:${position.inSeconds}'),
|
||||
onNext: () => calls.add('next'),
|
||||
onPrevious: () => calls.add('previous'),
|
||||
onStop: () => calls.add('stop'),
|
||||
onSkipForward: (interval) => calls.add('forward:${interval?.inSeconds}'),
|
||||
onSkipBackward: (interval) => calls.add('backward:${interval?.inSeconds}'),
|
||||
onSetSpeed: (speed) => calls.add('speed:$speed'),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/providers/playback_state_provider.dart';
|
||||
import 'package:plezy/screens/video_player_screen.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/focus/focusable_button.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../test_helpers/media_items.dart';
|
||||
import '../../test_helpers/mock_player_channels.dart';
|
||||
import '../../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
await SettingsService.getInstance();
|
||||
});
|
||||
|
||||
testWidgets('initialization ownership serializes rollback, retry, and route removal', (tester) async {
|
||||
final failedDispose = Completer<void>();
|
||||
final replacementInitialize = Completer<bool>();
|
||||
final calls = <MethodCall>[];
|
||||
final eventCalls = <MethodCall>[];
|
||||
var initializeCount = 0;
|
||||
|
||||
await withMockPlayerChannels(
|
||||
methodChannelName: 'com.plezy/mpv_player',
|
||||
eventChannelName: 'com.plezy/mpv_player/events',
|
||||
methodHandler: (call) {
|
||||
calls.add(call);
|
||||
switch (call.method) {
|
||||
case 'initialize':
|
||||
initializeCount++;
|
||||
if (initializeCount == 2) return replacementInitialize.future;
|
||||
return Future<Object?>.value(true);
|
||||
case 'observeProperty':
|
||||
if (initializeCount == 1) {
|
||||
throw PlatformException(code: 'post_creation_failure', message: 'forced observation failure');
|
||||
}
|
||||
return Future<Object?>.value(null);
|
||||
case 'dispose':
|
||||
if (initializeCount == 1) return failedDispose.future;
|
||||
return Future<Object?>.value(null);
|
||||
default:
|
||||
return Future<Object?>.value(null);
|
||||
}
|
||||
},
|
||||
eventHandler: (call) async {
|
||||
eventCalls.add(call);
|
||||
return null;
|
||||
},
|
||||
testBody: () async {
|
||||
final key = GlobalKey<VideoPlayerScreenState>();
|
||||
await tester.pumpWidget(_screen(key));
|
||||
await _pumpUntil(tester, () => calls.any((call) => call.method == 'dispose'));
|
||||
|
||||
expect(key.currentState?.player, isNull);
|
||||
expect(find.widgetWithText(FilledButton, 'Retry'), findsNothing);
|
||||
expect(initializeCount, 1);
|
||||
expect(eventCalls.where((call) => call.method == 'cancel'), hasLength(1));
|
||||
|
||||
failedDispose.complete();
|
||||
await _pumpUntil(tester, () => find.widgetWithText(FilledButton, 'Retry').evaluate().isNotEmpty);
|
||||
|
||||
final retryButton = tester.widget<FilledButton>(find.widgetWithText(FilledButton, 'Retry'));
|
||||
final retryFocusable = tester.widget<FocusableButton>(
|
||||
find.ancestor(of: find.widgetWithText(FilledButton, 'Retry'), matching: find.byType(FocusableButton)),
|
||||
);
|
||||
retryButton.onPressed!();
|
||||
retryFocusable.onPressed!();
|
||||
await _pumpUntil(tester, () => initializeCount == 2);
|
||||
|
||||
expect(initializeCount, 2);
|
||||
expect(key.currentState?.player, isNull);
|
||||
expect(calls.where((call) => call.method == 'dispose'), hasLength(1));
|
||||
expect(eventCalls.where((call) => call.method == 'cancel'), hasLength(1));
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
replacementInitialize.completeError(PlatformException(code: 'late_failure', message: 'forced late failure'));
|
||||
await _pumpUntil(tester, () => calls.where((call) => call.method == 'dispose').length == 2);
|
||||
|
||||
expect(find.widgetWithText(FilledButton, 'Retry'), findsNothing);
|
||||
expect(initializeCount, 2);
|
||||
expect(calls.where((call) => call.method == 'dispose'), hasLength(2));
|
||||
expect(eventCalls.where((call) => call.method == 'cancel'), hasLength(2));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _screen(GlobalKey<VideoPlayerScreenState> key) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) => PlaybackStateProvider(),
|
||||
child: MaterialApp(
|
||||
home: VideoPlayerScreen(
|
||||
key: key,
|
||||
metadata: testMediaItem(title: 'Lifecycle test video'),
|
||||
isOffline: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pumpUntil(WidgetTester tester, bool Function() condition) async {
|
||||
for (var i = 0; i < 200 && !condition(); i++) {
|
||||
await tester.pump(const Duration(milliseconds: 10));
|
||||
if (!condition()) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 5)));
|
||||
}
|
||||
}
|
||||
expect(condition(), isTrue);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/screens/video_player/wakelock_controller.dart';
|
||||
|
||||
void main() {
|
||||
group('WakelockController', () {
|
||||
test('drains enable then disable without overlapping platform work', () async {
|
||||
final platform = _ControlledPlatformToggle();
|
||||
final controller = WakelockController(platformToggle: platform.call);
|
||||
|
||||
final enable = controller.setEnabled(true);
|
||||
await _flushTasks();
|
||||
expect(platform.calls, [true]);
|
||||
|
||||
final disable = controller.setEnabled(false);
|
||||
await _flushTasks();
|
||||
expect(platform.calls, [true]);
|
||||
expect(platform.maxConcurrent, 1);
|
||||
|
||||
platform.succeedNext();
|
||||
await _flushTasks();
|
||||
expect(platform.calls, [true, false]);
|
||||
|
||||
platform.succeedNext();
|
||||
await Future.wait([enable, disable]);
|
||||
|
||||
expect(platform.completed, [true, false]);
|
||||
expect(platform.active, 0);
|
||||
expect(platform.maxConcurrent, 1);
|
||||
});
|
||||
|
||||
test('drains disable then enable to the latest desired state', () async {
|
||||
final platform = _ControlledPlatformToggle();
|
||||
final controller = WakelockController(platformToggle: platform.call);
|
||||
|
||||
final initialEnable = controller.setEnabled(true);
|
||||
await _flushTasks();
|
||||
platform.succeedNext();
|
||||
await initialEnable;
|
||||
|
||||
final disable = controller.setEnabled(false);
|
||||
await _flushTasks();
|
||||
expect(platform.calls, [true, false]);
|
||||
|
||||
final enable = controller.setEnabled(true);
|
||||
await _flushTasks();
|
||||
expect(platform.calls, [true, false]);
|
||||
|
||||
platform.succeedNext();
|
||||
await _flushTasks();
|
||||
expect(platform.calls, [true, false, true]);
|
||||
|
||||
platform.succeedNext();
|
||||
await Future.wait([disable, enable]);
|
||||
|
||||
expect(platform.completed, [true, false, true]);
|
||||
expect(platform.maxConcurrent, 1);
|
||||
});
|
||||
|
||||
test('absorbs acquisition failure and retries only when requested', () async {
|
||||
var calls = 0;
|
||||
final controller = WakelockController(
|
||||
platformToggle: (enabled) async {
|
||||
calls++;
|
||||
expect(enabled, isTrue);
|
||||
if (calls == 1) throw StateError('enable failed');
|
||||
},
|
||||
);
|
||||
|
||||
await expectLater(controller.setEnabled(true), completes);
|
||||
await _flushTasks();
|
||||
expect(calls, 1, reason: 'a failure must not start an automatic retry loop');
|
||||
|
||||
await expectLater(controller.setEnabled(true), completes);
|
||||
expect(calls, 2, reason: 'the failed state must remain explicitly retryable');
|
||||
|
||||
await controller.setEnabled(true);
|
||||
expect(calls, 2, reason: 'only successful platform work becomes effective');
|
||||
});
|
||||
|
||||
test('applies a newer opposing request after acquisition fails', () async {
|
||||
final platform = _ControlledPlatformToggle();
|
||||
final controller = WakelockController(platformToggle: platform.call);
|
||||
|
||||
final enable = controller.setEnabled(true);
|
||||
await _flushTasks();
|
||||
final disable = controller.setEnabled(false);
|
||||
|
||||
platform.failNext(StateError('enable failed'));
|
||||
await _flushTasks();
|
||||
expect(platform.calls, [true, false]);
|
||||
expect(platform.maxConcurrent, 1);
|
||||
|
||||
platform.succeedNext();
|
||||
await expectLater(Future.wait([enable, disable]), completes);
|
||||
|
||||
expect(platform.completed, [false]);
|
||||
expect(platform.active, 0);
|
||||
});
|
||||
|
||||
test('keeps effective state after release failure and retries disable', () async {
|
||||
var disableAttempts = 0;
|
||||
final calls = <bool>[];
|
||||
final controller = WakelockController(
|
||||
platformToggle: (enabled) async {
|
||||
calls.add(enabled);
|
||||
if (!enabled && disableAttempts++ == 0) {
|
||||
throw StateError('disable failed');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await controller.setEnabled(true);
|
||||
await expectLater(controller.setEnabled(false), completes);
|
||||
expect(calls, [true, false]);
|
||||
|
||||
await expectLater(controller.setEnabled(false), completes);
|
||||
expect(calls, [true, false, false]);
|
||||
|
||||
await controller.setEnabled(false);
|
||||
expect(calls, [true, false, false]);
|
||||
});
|
||||
|
||||
test('detached teardown disable drains after an in-flight enable', () async {
|
||||
final platform = _ControlledPlatformToggle();
|
||||
final controller = WakelockController(platformToggle: platform.call);
|
||||
|
||||
final enable = controller.setEnabled(true);
|
||||
await _flushTasks();
|
||||
unawaited(controller.setEnabled(false));
|
||||
|
||||
platform.succeedNext();
|
||||
await _flushTasks();
|
||||
expect(platform.calls, [true, false]);
|
||||
|
||||
platform.succeedNext();
|
||||
await enable;
|
||||
await _flushTasks();
|
||||
|
||||
expect(platform.completed, [true, false]);
|
||||
expect(platform.active, 0);
|
||||
expect(platform.maxConcurrent, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _flushTasks() => Future<void>.delayed(Duration.zero);
|
||||
|
||||
class _ControlledPlatformToggle {
|
||||
final calls = <bool>[];
|
||||
final completed = <bool>[];
|
||||
final _pending = <({bool enabled, Completer<void> completer})>[];
|
||||
|
||||
int active = 0;
|
||||
int maxConcurrent = 0;
|
||||
|
||||
Future<void> call(bool enabled) async {
|
||||
calls.add(enabled);
|
||||
active++;
|
||||
if (active > maxConcurrent) maxConcurrent = active;
|
||||
|
||||
final completer = Completer<void>();
|
||||
_pending.add((enabled: enabled, completer: completer));
|
||||
try {
|
||||
await completer.future;
|
||||
completed.add(enabled);
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
}
|
||||
|
||||
void succeedNext() => _pending.removeAt(0).completer.complete();
|
||||
|
||||
void failNext(Object error) => _pending.removeAt(0).completer.completeError(error);
|
||||
}
|
||||
Reference in New Issue
Block a user