Files
plezy/test/watch_together/watch_together_controller_test.dart
T

465 lines
16 KiB
Dart

import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/watch_together/models/playback_state.dart';
import 'package:plezy/watch_together/models/sync_message.dart';
import 'package:plezy/watch_together/models/watch_session.dart';
import 'package:plezy/watch_together/services/watch_together_controller.dart';
import '../test_helpers/watch_together_fakes.dart';
const _epochMs = 1000000;
/// Two live controllers (host + guest) bridged by an in-memory relay.
class _Room {
_Room(this.async, {ControlMode controlMode = ControlMode.hostOnly}) {
hostService = hub.register('host');
guestService = hub.register('guest');
host = WatchTogetherController(
peerService: hostService,
session: WatchSession(
sessionId: 'ROOM1',
role: SessionRole.host,
controlMode: controlMode,
state: SessionState.connected,
hostPeerId: 'host',
),
nowMs: nowMs,
);
guest = WatchTogetherController(
peerService: guestService,
session: WatchSession(
sessionId: 'ROOM1',
role: SessionRole.guest,
controlMode: controlMode,
state: SessionState.connected,
hostPeerId: 'host',
),
nowMs: nowMs,
);
hostPlayer = FakeSyncPlayer(position: const Duration(minutes: 2));
guestPlayer = FakeSyncPlayer(position: Duration.zero);
guest.announceJoin('Guest');
host.announceJoin('Host');
async.flushMicrotasks();
}
final FakeAsync async;
final hub = FakeRelayHub();
late final HubPeerService hostService;
late final HubPeerService guestService;
late final WatchTogetherController host;
late final WatchTogetherController guest;
late final FakeSyncPlayer hostPlayer;
late final FakeSyncPlayer guestPlayer;
int nowMs() => _epochMs + async.elapsed.inMilliseconds;
PlaybackState lastHostState() => hostService.outgoingLog.lastWhere((m) => m.type == SyncMessageType.state).state!;
void hostStartsMedia({String ratingKey = 'rk1', bool hasFirstFrame = false}) {
host.attachPlayer(
hostPlayer,
ratingKey: ratingKey,
serverId: 'srv',
mediaTitle: 'Ep',
hasFirstFrame: hasFirstFrame,
);
host.setCurrentMedia(ratingKey: ratingKey, serverId: 'srv', mediaTitle: 'Ep');
async.flushMicrotasks();
}
void guestJoinsMedia({String ratingKey = 'rk1'}) {
guest.attachPlayer(guestPlayer, ratingKey: ratingKey, serverId: 'srv');
async.flushMicrotasks();
}
void bothBecomeReady() {
hostPlayer.emitPlaybackRestart();
guestPlayer.emitPlaybackRestart();
async.flushMicrotasks();
}
void dispose() {
host.dispose();
guest.dispose();
hub.dispose();
}
}
void main() {
test('full flow: join, media dispatch, load, one simultaneous start — no loops', () {
fakeAsync((async) {
final mediaDispatches = <String>[];
final room = _Room(async);
room.guest.onMediaStateReceived = (rk, sid, title) => mediaDispatches.add(rk);
// Host opens media; guest hears about it from the loading state even
// though the host hasn't finished loading (joiners load in parallel).
room.hostStartsMedia();
expect(mediaDispatches, ['rk1']);
// Guest loads FIRST (the original bug scenario).
room.guestJoinsMedia();
room.guestPlayer.emitPlaybackRestart();
async.flushMicrotasks();
async.elapse(const Duration(seconds: 4));
// While the host loads, the guest must never have been told to play.
expect(room.guestPlayer.state.playing, isFalse);
expect(room.guestPlayer.commandLog.where((c) => c == 'play'), isEmpty);
// Host finishes loading → scheduled start lands on both simultaneously.
room.hostPlayer.emitPlaybackRestart();
async.flushMicrotasks();
final state = room.lastHostState();
expect(state.phase, PlaybackPhase.playing);
final delay = state.anchorHostTimeMs - room.nowMs();
expect(delay, greaterThan(0));
async.elapse(Duration(milliseconds: delay - 50));
expect(room.hostPlayer.state.playing, isFalse);
expect(room.guestPlayer.state.playing, isFalse);
async.elapse(const Duration(milliseconds: 100));
expect(room.hostPlayer.state.playing, isTrue);
expect(room.guestPlayer.state.playing, isTrue);
// And the guest was aligned to the host's anchor position.
expect((room.guestPlayer.state.position.inMilliseconds - state.anchorPositionMs).abs(), lessThanOrEqualTo(500));
room.dispose();
});
});
test('episode switch: state arriving during the guest detach gap is not lost', () {
fakeAsync((async) {
final mediaDispatches = <String>[];
final room = _Room(async);
room.guest.onMediaStateReceived = (rk, sid, title) => mediaDispatches.add(rk);
room.hostStartsMedia();
room.guestJoinsMedia();
room.bothBecomeReady();
final delay = room.lastHostState().anchorHostTimeMs - room.nowMs();
async.elapse(Duration(milliseconds: delay + 100));
expect(room.guestPlayer.state.playing, isTrue);
// Guest detaches (reload gap) — and ONLY THEN the host switches media.
room.guest.detachPlayer();
async.flushMicrotasks();
room.host.setCurrentMedia(ratingKey: 'rk2', serverId: 'srv', mediaTitle: 'Ep 2');
room.host.detachPlayer();
room.host.attachPlayer(room.hostPlayer, ratingKey: 'rk2', serverId: 'srv', mediaTitle: 'Ep 2');
async.flushMicrotasks();
// The guest controller was detached but session-scoped routing caught
// the new epoch.
expect(mediaDispatches, contains('rk2'));
// Guest re-attaches for the new episode; both load; room starts again.
room.guest.attachPlayer(room.guestPlayer, ratingKey: 'rk2', serverId: 'srv');
async.flushMicrotasks();
room.bothBecomeReady();
final resume = room.lastHostState();
expect(resume.phase, PlaybackPhase.playing);
expect(resume.ratingKey, 'rk2');
room.dispose();
});
});
test('hostOnly: forged control requests are dropped at the controller', () {
fakeAsync((async) {
final room = _Room(async);
room.hostStartsMedia();
room.guestJoinsMedia();
room.bothBecomeReady();
final delay = room.lastHostState().anchorHostTimeMs - room.nowMs();
async.elapse(Duration(milliseconds: delay + 100));
expect(room.hostPlayer.state.playing, isTrue);
room.guestService.sendTo(
'host',
SyncMessage.control(const ControlRequest(kind: ControlRequestKind.pause), peerId: 'guest'),
);
async.elapse(const Duration(seconds: 1));
expect(room.hostPlayer.state.playing, isTrue); // Ignored.
room.dispose();
});
});
test('anyone-mode: guest control requests round-trip through the host', () {
fakeAsync((async) {
final room = _Room(async, controlMode: ControlMode.anyone);
room.hostStartsMedia();
room.guestJoinsMedia();
room.bothBecomeReady();
final delay = room.lastHostState().anchorHostTimeMs - room.nowMs();
async.elapse(Duration(milliseconds: delay + 100));
// Guest presses pause → request → host applies → state pauses guest too.
room.guestPlayer.emitPlaying(false);
async.flushMicrotasks();
expect(room.hostPlayer.state.playing, isFalse);
final paused = room.lastHostState();
expect(paused.phase, PlaybackPhase.paused);
expect(paused.actorPeerId, 'guest');
room.dispose();
});
});
test('anyone-mode: invalid controls are rejected and the queue continues', () {
fakeAsync((async) {
final room = _Room(async, controlMode: ControlMode.anyone);
final actions = <(String, PlaybackActionHint)>[];
room.host.onRemoteAction = (peer, hint) => actions.add((peer, hint));
room.hostStartsMedia();
room.guestJoinsMedia();
room.bothBecomeReady();
final delay = room.lastHostState().anchorHostTimeMs - room.nowMs();
async.elapse(Duration(milliseconds: delay + 100));
room.hostPlayer.commandLog.clear();
final statesBefore = room.hostService.outgoingLog
.where((message) => message.type == SyncMessageType.state)
.length;
final stateBefore = room.lastHostState();
SyncMessage wireControl(ControlRequest request) {
return SyncMessage.fromJson(SyncMessage.control(request, peerId: 'forged-peer').toJson());
}
room.guestService.sendTo(
'host',
wireControl(
ControlRequest(kind: ControlRequestKind.seek, positionMs: room.hostPlayer.state.duration.inMilliseconds + 1),
),
);
room.guestService.sendTo(
'host',
wireControl(const ControlRequest(kind: ControlRequestKind.rate, rate: 4.000001)),
);
async.flushMicrotasks();
expect(room.hostPlayer.commandLog, isEmpty);
expect(actions, isEmpty);
expect(
room.hostService.outgoingLog.where((message) => message.type == SyncMessageType.state),
hasLength(statesBefore),
);
expect(room.lastHostState().seq, stateBefore.seq);
expect(room.lastHostState().anchorPositionMs, stateBefore.anchorPositionMs);
expect(room.lastHostState().rate, stateBefore.rate);
room.guestService.sendTo(
'host',
wireControl(const ControlRequest(kind: ControlRequestKind.seek, positionMs: 600000)),
);
async.flushMicrotasks();
room.guestService.sendTo('host', wireControl(const ControlRequest(kind: ControlRequestKind.rate, rate: 0.25)));
async.flushMicrotasks();
expect(room.hostPlayer.commandLog, ['seek:600000', 'rate:0.25']);
expect(actions, [('guest', PlaybackActionHint.seek), ('guest', PlaybackActionHint.rate)]);
final acceptedStates = room.hostService.outgoingLog
.where((message) => message.type == SyncMessageType.state)
.skip(statesBefore)
.map((message) => message.state!)
.toList();
expect(acceptedStates, hasLength(2));
expect(acceptedStates[0].anchorPositionMs, 600000);
expect(acceptedStates[0].actionHint, PlaybackActionHint.seek);
expect(acceptedStates[0].actorPeerId, 'guest');
expect(acceptedStates[1].rate, 0.25);
expect(acceptedStates[1].actionHint, PlaybackActionHint.rate);
expect(acceptedStates[1].actorPeerId, 'guest');
room.dispose();
});
});
test('guest controller starts clock-sync pings automatically', () {
fakeAsync((async) {
final room = _Room(async);
// The guest's clock-sync burst starts immediately and sends pings
// through its relay-backed peer service.
async.elapse(const Duration(seconds: 2));
final pings = room.guestService.outgoingLog.where((m) => m.type == SyncMessageType.ping);
expect(pings, isNotEmpty);
room.dispose();
});
});
test('v2 peers are flagged and never gate the start', () {
fakeAsync((async) {
final needsUpdate = <String>[];
final room = _Room(async);
room.host.onPeerNeedsUpdate = needsUpdate.add;
// A sync-protocol-2 client joins on its own connection. The relay
// stamps the sender ID, so it must really connect as itself.
final legacyService = room.hub.register('legacy');
legacyService.sendTo(
'host',
SyncMessage(
type: SyncMessageType.join,
timestamp: room.nowMs(),
peerId: 'legacy',
displayName: 'Old App',
isHost: false,
version: 2,
),
);
async.flushMicrotasks();
expect(needsUpdate, ['legacy']);
room.hostStartsMedia();
room.guestJoinsMedia();
room.bothBecomeReady();
// The legacy peer never reports status, yet the room starts.
expect(room.lastHostState().phase, PlaybackPhase.playing);
room.dispose();
});
});
test('versionless legacy peers are flagged and never gate the start', () {
fakeAsync((async) {
final needsUpdate = <String>[];
final room = _Room(async);
room.host.onPeerNeedsUpdate = needsUpdate.add;
final versionlessService = room.hub.register('versionless');
versionlessService.sendTo(
'host',
SyncMessage(
type: SyncMessageType.join,
timestamp: room.nowMs(),
peerId: 'versionless',
displayName: 'Old App',
isHost: false,
),
);
async.flushMicrotasks();
expect(needsUpdate, ['versionless']);
room.hostStartsMedia();
room.guestJoinsMedia();
room.bothBecomeReady();
expect(room.lastHostState().phase, PlaybackPhase.playing);
room.dispose();
});
});
test('guest reconnect re-requests state and the host answers directly', () {
fakeAsync((async) {
final room = _Room(async);
room.hostStartsMedia();
room.guestJoinsMedia();
room.bothBecomeReady();
final statesBefore = room.guestService.outgoingLog.length;
room.guest.onReconnected();
async.flushMicrotasks();
// Status + requestState went out; host replied with a targeted state.
final outgoing = room.guestService.outgoingLog.skip(statesBefore);
expect(outgoing.where((m) => m.type == SyncMessageType.status), isNotEmpty);
expect(outgoing.where((m) => m.type == SyncMessageType.requestState), isNotEmpty);
final targeted = room.hostService.outgoingLog.where((m) => m.type == SyncMessageType.state);
expect(targeted, isNotEmpty);
room.dispose();
});
});
test('only relay-stamped state from the declared host reaches guest reconciliation', () {
fakeAsync((async) {
final room = _Room(async);
final mediaDispatches = <String>[];
room.guest.onMediaStateReceived = (ratingKey, serverId, title) => mediaDispatches.add(ratingKey);
const state = PlaybackState(
seq: 10,
ratingKey: 'relay-authority',
serverId: 'srv',
mediaTitle: 'Authorized',
phase: PlaybackPhase.loading,
anchorPositionMs: 0,
anchorHostTimeMs: _epochMs,
rate: 1,
controlMode: ControlMode.hostOnly,
);
final unprivileged = room.hub.register('unprivileged');
// The fake relay overwrites the payload claim with the connection's
// routing ID, just like the production relay envelope parser.
unprivileged.broadcast(SyncMessage.state(state, peerId: 'host'));
async.flushMicrotasks();
expect(mediaDispatches, isEmpty);
room.hostService.broadcast(SyncMessage.state(state, peerId: 'host'));
async.flushMicrotasks();
expect(mediaDispatches, ['relay-authority']);
room.dispose();
});
});
test('fake relay rejects duplicate routing IDs instead of replacing authority', () async {
final hub = FakeRelayHub();
hub.register('reserved');
expect(() => hub.register('reserved'), throwsStateError);
await hub.dispose();
});
group('hostExitedPlayer routing', () {
test('rides the ordered queue: never overtakes states sent before it', () {
fakeAsync((async) {
final room = _Room(async);
final log = <String>[];
room.guest.onMediaStateReceived = (rk, sid, title) => log.add('state:$rk');
room.guest.onHostExitedPlayer = () => log.add('hostExit');
// Host starts media, then exits the player — wire order matters.
room.hostStartsMedia();
room.hostService.broadcast(SyncMessage.hostExitedPlayer(peerId: 'host'));
async.flushMicrotasks();
expect(log, isNotEmpty);
expect(log.first, 'state:rk1');
expect(log.last, 'hostExit');
room.dispose();
});
});
test('is ignored when forged by a non-host peer', () {
fakeAsync((async) {
final room = _Room(async);
var hostExits = 0;
room.guest.onHostExitedPlayer = () => hostExits++;
final evil = room.hub.register('evil');
evil.broadcast(SyncMessage.hostExitedPlayer(peerId: 'evil'));
async.flushMicrotasks();
expect(hostExits, 0);
room.dispose();
});
});
test('the host itself never reacts to a hostExitedPlayer echo', () {
fakeAsync((async) {
final room = _Room(async);
var hostExits = 0;
room.host.onHostExitedPlayer = () => hostExits++;
// A confused/malicious guest sends the message; the host must not
// tear down its own epoch.
room.guestService.broadcast(SyncMessage.hostExitedPlayer(peerId: 'guest'));
async.flushMicrotasks();
expect(hostExits, 0);
room.dispose();
});
});
});
}