278 lines
8.8 KiB
Dart
278 lines
8.8 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:fake_async/fake_async.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/watch_together/services/attached_player.dart';
|
|
|
|
import '../test_helpers/watch_together_fakes.dart';
|
|
|
|
void main() {
|
|
(AttachedPlayer, FakeSyncPlayer, List<String>) build(
|
|
FakeAsync async, {
|
|
bool playing = false,
|
|
Future<void> Function(Duration)? remoteSeek,
|
|
}) {
|
|
final player = FakeSyncPlayer(playing: playing);
|
|
final lostEvents = <String>[];
|
|
final attached = AttachedPlayer(
|
|
player: player,
|
|
onLost: () => lostEvents.add('lost'),
|
|
remoteSeek: remoteSeek,
|
|
nowMs: () => async.elapsed.inMilliseconds,
|
|
);
|
|
return (attached, player, lostEvents);
|
|
}
|
|
|
|
group('expected-state ledger', () {
|
|
test('command-induced transitions are consumed as acks, not intents', () {
|
|
fakeAsync((async) {
|
|
final (attached, player, _) = build(async);
|
|
final intents = <bool>[];
|
|
attached.playingIntents.listen(intents.add);
|
|
|
|
attached.play();
|
|
async.flushMicrotasks();
|
|
|
|
expect(player.state.playing, isTrue);
|
|
expect(intents, isEmpty);
|
|
attached.dispose();
|
|
});
|
|
});
|
|
|
|
test('late property events (after the command future) are still acks', () {
|
|
fakeAsync((async) {
|
|
final (attached, player, _) = build(async);
|
|
final intents = <bool>[];
|
|
attached.playingIntents.listen(intents.add);
|
|
|
|
// Simulate the real backend: command ack now, property event later.
|
|
player.emitRestartOnSeek = false;
|
|
attached.pause(); // No-op: already paused — expectation lingers.
|
|
async.flushMicrotasks();
|
|
attached.play();
|
|
async.flushMicrotasks();
|
|
expect(intents, isEmpty);
|
|
attached.dispose();
|
|
});
|
|
});
|
|
|
|
test('user transitions with no matching expectation are intents', () {
|
|
fakeAsync((async) {
|
|
final (attached, player, _) = build(async);
|
|
final intents = <bool>[];
|
|
attached.playingIntents.listen(intents.add);
|
|
|
|
player.emitPlaying(true);
|
|
async.flushMicrotasks();
|
|
player.emitPlaying(false);
|
|
async.flushMicrotasks();
|
|
|
|
expect(intents, [true, false]);
|
|
attached.dispose();
|
|
});
|
|
});
|
|
|
|
test('expired expectations no longer absorb user transitions', () {
|
|
fakeAsync((async) {
|
|
final (attached, player, _) = build(async);
|
|
final intents = <bool>[];
|
|
attached.playingIntents.listen(intents.add);
|
|
|
|
// Command is silently swallowed (no event) — e.g. seek-before-load.
|
|
player.nextCommandError = null;
|
|
attached.pause(); // Already paused: no event, expectation parked.
|
|
async.flushMicrotasks();
|
|
|
|
async.elapse(const Duration(seconds: 4)); // Past the 3s TTL.
|
|
player.emitPlaying(true);
|
|
player.emitPlaying(false); // User pause must NOT be eaten.
|
|
async.flushMicrotasks();
|
|
|
|
expect(intents, [true, false]);
|
|
attached.dispose();
|
|
});
|
|
});
|
|
|
|
test('rate acks are consumed, user rate changes are intents', () {
|
|
fakeAsync((async) {
|
|
final (attached, player, _) = build(async);
|
|
final intents = <double>[];
|
|
attached.rateIntents.listen(intents.add);
|
|
|
|
attached.setRate(1.04);
|
|
async.flushMicrotasks();
|
|
expect(intents, isEmpty);
|
|
|
|
player.emitRate(2.0);
|
|
async.flushMicrotasks();
|
|
expect(intents, [2.0]);
|
|
attached.dispose();
|
|
});
|
|
});
|
|
});
|
|
|
|
group('guarded commands', () {
|
|
test('recoverable PlatformException reports failure and fires onLost once', () {
|
|
fakeAsync((async) {
|
|
final (attached, player, lostEvents) = build(async);
|
|
|
|
player.nextCommandError = PlatformException(code: 'COMMAND_FAILED');
|
|
bool? result;
|
|
attached.play().then((v) => result = v);
|
|
async.flushMicrotasks();
|
|
expect(result, isFalse);
|
|
expect(lostEvents, hasLength(1));
|
|
|
|
player.nextCommandError = PlatformException(code: 'NOT_INITIALIZED');
|
|
attached.pause().then((v) => result = v);
|
|
async.flushMicrotasks();
|
|
expect(result, isFalse);
|
|
expect(lostEvents, hasLength(1)); // Still once.
|
|
attached.dispose();
|
|
});
|
|
});
|
|
|
|
test('pending teardown NOT_INITIALIZED reports failure and fires onLost once', () {
|
|
fakeAsync((async) {
|
|
final (attached, player, lostEvents) = build(async);
|
|
final playingIntents = <bool>[];
|
|
attached.playingIntents.listen(playingIntents.add);
|
|
final pending = Completer<void>();
|
|
player.nextCommandFuture = pending.future;
|
|
bool? result;
|
|
|
|
attached.play().then((value) => result = value);
|
|
async.flushMicrotasks();
|
|
expect(result, isNull);
|
|
|
|
pending.completeError(PlatformException(code: 'NOT_INITIALIZED'));
|
|
async.flushMicrotasks();
|
|
|
|
expect(result, isFalse);
|
|
expect(lostEvents, ['lost']);
|
|
|
|
player.emitPlaying(true);
|
|
async.flushMicrotasks();
|
|
expect(playingIntents, [true], reason: 'the failed command must remove its outstanding expectation');
|
|
attached.dispose();
|
|
});
|
|
});
|
|
|
|
test('SET_PROPERTY_FAILED remains non-recoverable', () {
|
|
fakeAsync((async) {
|
|
final (attached, player, lostEvents) = build(async);
|
|
|
|
player.nextCommandError = PlatformException(code: 'SET_PROPERTY_FAILED');
|
|
Object? error;
|
|
attached.play().catchError((Object caught) {
|
|
error = caught;
|
|
return false;
|
|
});
|
|
async.flushMicrotasks();
|
|
expect(error, isA<PlatformException>());
|
|
expect((error as PlatformException).code, 'SET_PROPERTY_FAILED');
|
|
expect(lostEvents, isEmpty);
|
|
attached.dispose();
|
|
});
|
|
});
|
|
|
|
test('commands against a disposed player fail and fire onLost', () {
|
|
fakeAsync((async) {
|
|
final (attached, player, lostEvents) = build(async);
|
|
player.dispose();
|
|
async.flushMicrotasks();
|
|
|
|
bool? result;
|
|
attached.play().then((v) => result = v);
|
|
async.flushMicrotasks();
|
|
expect(result, isFalse);
|
|
expect(lostEvents, hasLength(1));
|
|
attached.dispose();
|
|
});
|
|
});
|
|
|
|
test('disposing the attachment does not fire onLost', () {
|
|
fakeAsync((async) {
|
|
final (attached, _, lostEvents) = build(async);
|
|
attached.dispose();
|
|
async.flushMicrotasks();
|
|
|
|
bool? result;
|
|
attached.play().then((v) => result = v);
|
|
async.flushMicrotasks();
|
|
expect(result, isFalse);
|
|
expect(lostEvents, isEmpty);
|
|
});
|
|
});
|
|
});
|
|
|
|
group('seek routing', () {
|
|
test('uses the remote-seek delegate when provided', () {
|
|
fakeAsync((async) {
|
|
final delegated = <Duration>[];
|
|
final (attached, player, _) = build(async, remoteSeek: (target) async => delegated.add(target));
|
|
|
|
attached.seek(const Duration(seconds: 30));
|
|
async.flushMicrotasks();
|
|
|
|
expect(delegated, [const Duration(seconds: 30)]);
|
|
expect(player.commandLog.where((c) => c.startsWith('seek:')), isEmpty);
|
|
attached.dispose();
|
|
});
|
|
});
|
|
|
|
test('falls back to player.seek when the delegate throws', () {
|
|
fakeAsync((async) {
|
|
final (attached, player, lostEvents) = build(async, remoteSeek: (_) async => throw StateError('screen gone'));
|
|
|
|
bool? result;
|
|
attached.seek(const Duration(seconds: 30)).then((v) => result = v);
|
|
async.flushMicrotasks();
|
|
|
|
expect(result, isTrue);
|
|
expect(player.state.position, const Duration(seconds: 30));
|
|
expect(lostEvents, isEmpty);
|
|
attached.dispose();
|
|
});
|
|
});
|
|
});
|
|
|
|
group('signals and snapshots', () {
|
|
test('forwards buffering transitions and playback-restart signals', () {
|
|
fakeAsync((async) {
|
|
final (attached, player, _) = build(async);
|
|
final buffering = <bool>[];
|
|
var loaded = 0;
|
|
attached.bufferingChanges.listen(buffering.add);
|
|
attached.loadedSignals.listen((_) => loaded++);
|
|
|
|
player.emitBuffering(true);
|
|
player.emitBuffering(true); // Duplicate suppressed.
|
|
player.emitBuffering(false);
|
|
player.emitPlaybackRestart();
|
|
async.flushMicrotasks();
|
|
|
|
expect(buffering, [true, false]);
|
|
expect(loaded, 1);
|
|
attached.dispose();
|
|
});
|
|
});
|
|
|
|
test('bufferAhead is null when unknown and clamps at zero', () {
|
|
fakeAsync((async) {
|
|
final (attached, player, _) = build(async);
|
|
expect(attached.bufferAhead, isNull);
|
|
|
|
player.setPosition(const Duration(seconds: 10));
|
|
player.setBuffer(const Duration(seconds: 18));
|
|
expect(attached.bufferAhead, const Duration(seconds: 8));
|
|
|
|
player.setBuffer(const Duration(seconds: 5));
|
|
expect(attached.bufferAhead, Duration.zero);
|
|
attached.dispose();
|
|
});
|
|
});
|
|
});
|
|
}
|