Files
plezy/test/watch_together/guest_playback_reconciler_test.dart
T
edde746 86abf3e9da fix(watch-together): retry guest media switches until they commit
Guest switch dispatch pre-marked its dedup key and fired-and-forgot, so
any failure (fetch error, reload busy with an auto-advance, navigation
race with the host exiting) silently stranded the guest on the old media.
A CurrentPlaybackDispatcher now marks a key handled only after the sink
reports success against the committed identity, with a serialized
in-flight slot, timeout, and generation reset; the reconciler re-offers
unattached media on every host heartbeat, making the heartbeat the retry
channel. Fetches that outlive their dispatch are re-validated against the
current snapshot so a stale switch can't override the live one.
hostExitedPlayer now rides the controller's ordered message queue with
host authentication instead of racing state handling in the provider.
2026-07-02 12:44:15 +02:00

593 lines
21 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/attached_player.dart';
import 'package:plezy/watch_together/services/clock_sync.dart';
import 'package:plezy/watch_together/services/guest_playback_reconciler.dart';
import '../test_helpers/watch_together_fakes.dart';
const _epochMs = 1000000;
class _Harness {
_Harness(this.async, {GuestReconcilerCallbacks callbacks = const GuestReconcilerCallbacks()}) {
player = FakeSyncPlayer(position: const Duration(minutes: 2));
clock = ClockSync(sendPing: pings.add, nowMs: nowMs);
reconciler = GuestPlaybackReconciler(
myPeerId: 'guest',
sendToHost: outgoing.add,
clockSync: clock,
callbacks: callbacks,
nowMs: nowMs,
);
attached = AttachedPlayer(player: player, onLost: () {}, nowMs: nowMs);
}
final FakeAsync async;
late final FakeSyncPlayer player;
late final ClockSync clock;
late final GuestPlaybackReconciler reconciler;
late final AttachedPlayer attached;
final List<SyncMessage> outgoing = [];
final List<int> pings = [];
int _seq = 0;
int nowMs() => _epochMs + async.elapsed.inMilliseconds;
void attachReady() {
reconciler.attach(attached, ratingKey: 'rk1', serverId: 'srv', hasFirstFrame: true);
async.flushMicrotasks();
}
PlaybackState state({
PlaybackPhase phase = PlaybackPhase.playing,
int? anchorPositionMs,
int? anchorHostTimeMs,
double rate = 1.0,
ControlMode controlMode = ControlMode.hostOnly,
List<String> waitingOn = const [],
String ratingKey = 'rk1',
String? actorPeerId,
PlaybackActionHint? actionHint,
int? seq,
}) {
return PlaybackState(
seq: seq ?? ++_seq,
ratingKey: ratingKey,
serverId: 'srv',
phase: phase,
anchorPositionMs: anchorPositionMs ?? player.state.position.inMilliseconds,
anchorHostTimeMs: anchorHostTimeMs ?? nowMs(),
rate: rate,
controlMode: controlMode,
waitingOn: waitingOn,
actorPeerId: actorPeerId,
actionHint: actionHint,
);
}
/// Delivers a state and runs one extra tick so the drift median has two
/// samples (a single sample never triggers a correction).
void deliverAndSettleDrift(PlaybackState s) {
reconciler.onState(s);
async.flushMicrotasks();
async.elapse(const Duration(milliseconds: 500));
}
Iterable<String> get seekCommands => player.commandLog.where((c) => c.startsWith('seek:'));
Iterable<PeerStatus> get statuses => outgoing.where((m) => m.type == SyncMessageType.status).map((m) => m.status!);
Iterable<ControlRequest> get controls =>
outgoing.where((m) => m.type == SyncMessageType.control).map((m) => m.control!);
void dispose() {
reconciler.dispose();
attached.dispose();
}
}
void main() {
test('stale sequence numbers are dropped', () {
fakeAsync((async) {
final h = _Harness(async);
h.attachReady();
h.player.emitPlaying(true);
async.flushMicrotasks();
h.reconciler.onState(h.state(phase: PlaybackPhase.paused, seq: 10));
async.flushMicrotasks();
expect(h.player.state.playing, isFalse);
// An older state saying "playing" must not apply.
h.reconciler.onState(h.state(phase: PlaybackPhase.playing, seq: 9));
async.flushMicrotasks();
expect(h.player.state.playing, isFalse);
expect(h.reconciler.latestState!.seq, 10);
h.dispose();
});
});
group('drift pipeline', () {
test('within the deadband nothing happens', () {
fakeAsync((async) {
final h = _Harness(async);
h.attachReady();
h.player.emitPlaying(true);
async.flushMicrotasks();
// Anchor implies we should be 200ms ahead of where we are — inside
// the deadband.
final pos = h.player.state.position.inMilliseconds;
h.deliverAndSettleDrift(h.state(anchorPositionMs: pos + 200));
expect(h.seekCommands, isEmpty);
expect(h.player.commandLog.where((c) => c.startsWith('rate:')), isEmpty);
h.dispose();
});
});
test('moderate drift nudges the rate and restores it on convergence', () {
fakeAsync((async) {
final h = _Harness(async);
h.attachReady();
h.player.emitPlaying(true);
async.flushMicrotasks();
// We are 1s behind the room → speed up by 4%.
final pos = h.player.state.position.inMilliseconds;
final s = h.state(anchorPositionMs: pos + 1000);
h.deliverAndSettleDrift(s);
expect(h.player.state.rate, closeTo(1.04, 0.0001));
expect(h.seekCommands, isEmpty);
// Converged: hold the player ~50ms off target across several ticks
// (median smoothing needs the old samples to wash out) → restored.
for (var i = 0; i < 3; i++) {
h.player.setPosition(Duration(milliseconds: s.targetPositionMs(h.nowMs() + 500) + 50));
async.elapse(const Duration(milliseconds: 500));
}
expect(h.player.state.rate, closeTo(1.0, 0.0001));
h.dispose();
});
});
test('audio passthrough suppresses nudging (tolerated up to the seek band)', () {
fakeAsync((async) {
final h = _Harness(async);
h.attachReady();
h.player.emitPlaying(true);
h.player.audioPassthroughActive = true;
async.flushMicrotasks();
final pos = h.player.state.position.inMilliseconds;
h.deliverAndSettleDrift(h.state(anchorPositionMs: pos + 1000));
expect(h.player.commandLog.where((c) => c.startsWith('rate:')), isEmpty);
expect(h.seekCommands, isEmpty);
h.dispose();
});
});
test('rate nudges that do not take effect disable nudging for the session', () {
fakeAsync((async) {
final h = _Harness(async);
h.attachReady();
h.player.emitPlaying(true);
h.player.ignoreRateChanges = true;
async.flushMicrotasks();
final pos = h.player.state.position.inMilliseconds;
h.deliverAndSettleDrift(h.state(anchorPositionMs: pos + 1000));
expect(h.player.commandLog.where((c) => c.startsWith('rate:')), isNotEmpty);
async.elapse(const Duration(milliseconds: 600)); // Confirm window.
final rateCommandsAfterLatch = h.player.commandLog.where((c) => c.startsWith('rate:')).length;
// Further drift no longer attempts nudges.
h.deliverAndSettleDrift(h.state(anchorPositionMs: h.player.state.position.inMilliseconds + 1500));
async.elapse(const Duration(seconds: 2));
expect(h.player.commandLog.where((c) => c.startsWith('rate:')).length, rateCommandsAfterLatch);
h.dispose();
});
});
test('large drift hard-seeks with lead, settle window, and cooldown', () {
fakeAsync((async) {
final correcting = <bool>[];
final h = _Harness(async, callbacks: GuestReconcilerCallbacks(onCorrectingChanged: correcting.add));
h.attachReady();
h.player.emitPlaying(true);
async.flushMicrotasks();
final pos = h.player.state.position.inMilliseconds;
final target = pos + 10000;
h.deliverAndSettleDrift(h.state(anchorPositionMs: target));
// Seeked to (extrapolated) target + 250ms lead.
expect(h.seekCommands, hasLength(1));
final seekTarget = int.parse(h.seekCommands.single.substring('seek:'.length));
expect(seekTarget, greaterThanOrEqualTo(target + 250));
expect(seekTarget, lessThan(target + 250 + 1500));
expect(correcting, [true]);
// Settle: playback-restart fired on seek; +250ms ends the window.
async.elapse(const Duration(milliseconds: 300));
expect(correcting, [true, false]);
// Within the cooldown a fresh large drift does not seek again.
h.player.setPosition(Duration(milliseconds: seekTarget - 8000));
async.elapse(const Duration(milliseconds: 1000));
expect(h.seekCommands, hasLength(1));
// After the cooldown it does.
async.elapse(const Duration(milliseconds: 1500));
expect(h.seekCommands.length, greaterThan(1));
h.dispose();
});
});
test('settle falls back to the timeout when no playback-restart arrives', () {
fakeAsync((async) {
final correcting = <bool>[];
final h = _Harness(async, callbacks: GuestReconcilerCallbacks(onCorrectingChanged: correcting.add));
h.player.emitRestartOnSeek = false;
h.attachReady();
h.player.emitPlaying(true);
async.flushMicrotasks();
h.deliverAndSettleDrift(h.state(anchorPositionMs: h.player.state.position.inMilliseconds + 10000));
expect(correcting, [true]);
async.elapse(const Duration(milliseconds: 1600));
expect(correcting, [true, false]);
h.dispose();
});
});
});
group('phases', () {
test('paused phase aligns to the anchor and pauses the player', () {
fakeAsync((async) {
final h = _Harness(async);
h.attachReady();
h.player.emitPlaying(true);
async.flushMicrotasks();
h.reconciler.onState(h.state(phase: PlaybackPhase.paused, anchorPositionMs: 600000));
async.flushMicrotasks();
expect(h.player.state.playing, isFalse);
expect(h.seekCommands, hasLength(1));
expect(h.player.state.position, const Duration(minutes: 10));
h.dispose();
});
});
test('host loading phase holds paused without chasing the meaningless anchor', () {
fakeAsync((async) {
final h = _Harness(async);
h.attachReady();
h.player.emitPlaying(true);
async.flushMicrotasks();
h.reconciler.onState(h.state(phase: PlaybackPhase.loading, anchorPositionMs: 0));
async.elapse(const Duration(seconds: 3));
expect(h.player.state.playing, isFalse);
expect(h.seekCommands, isEmpty); // Never seeks to the host's stale 0.
h.dispose();
});
});
test('hostOnly: a local pause snaps back to the room state', () {
fakeAsync((async) {
final h = _Harness(async);
h.attachReady();
h.reconciler.onState(h.state(phase: PlaybackPhase.playing));
async.flushMicrotasks();
expect(h.player.state.playing, isTrue);
h.player.emitPlaying(false); // User pause.
async.flushMicrotasks();
expect(h.player.state.playing, isTrue); // Snapped back.
expect(h.controls, isEmpty); // No request in hostOnly.
h.dispose();
});
});
test('scheduled group start fires at the host moment, clock-adjusted', () {
fakeAsync((async) {
final h = _Harness(async);
h.attachReady();
// Establish a clock offset of +5000ms (host ahead) via one exchange.
h.clock.start();
final ping = h.pings.single;
async.elapse(const Duration(milliseconds: 100));
h.clock.onPong(ping, ping + 50 + 5000);
expect(h.clock.offsetMs, 5000);
h.clock.stop();
// Host schedules the start 1s into ITS future.
final startAtHost = h.clock.hostNowMs() + 1000;
final anchor = h.player.state.position.inMilliseconds;
h.reconciler.onState(h.state(anchorHostTimeMs: startAtHost, anchorPositionMs: anchor));
async.flushMicrotasks();
expect(h.player.state.playing, isFalse); // Holding.
async.elapse(const Duration(milliseconds: 950));
expect(h.player.state.playing, isFalse);
async.elapse(const Duration(milliseconds: 100));
expect(h.player.state.playing, isTrue); // Fired on the dot.
h.dispose();
});
});
test('a newer pause cancels a pending scheduled start', () {
fakeAsync((async) {
final h = _Harness(async);
h.attachReady();
h.reconciler.onState(h.state(anchorHostTimeMs: h.nowMs() + 1000));
async.flushMicrotasks();
h.reconciler.onState(h.state(phase: PlaybackPhase.paused));
async.flushMicrotasks();
async.elapse(const Duration(seconds: 2));
expect(h.player.state.playing, isFalse); // Start never fired.
h.dispose();
});
});
});
group('media and status', () {
test('epoch mismatch hands off to the media-switch flow and stops correcting', () {
fakeAsync((async) {
final switches = <(String, String, String?)>[];
final h = _Harness(
async,
callbacks: GuestReconcilerCallbacks(onMediaSwitchNeeded: (rk, sid, title) => switches.add((rk, sid, title))),
);
h.attachReady();
h.player.emitPlaying(true);
async.flushMicrotasks();
final commandsBefore = h.player.commandLog.length;
h.reconciler.onState(h.state(ratingKey: 'rk2', phase: PlaybackPhase.loading));
async.elapse(const Duration(seconds: 2));
expect(switches, [('rk2', 'srv', null)]);
expect(h.player.commandLog.length, commandsBefore); // No commands for foreign media.
h.dispose();
});
});
test('detached guest is re-notified on every state (heartbeat retry channel)', () {
fakeAsync((async) {
final switches = <String>[];
final h = _Harness(
async,
callbacks: GuestReconcilerCallbacks(onMediaSwitchNeeded: (rk, sid, title) => switches.add(rk)),
);
// Never attached: every heartbeat re-offers the switch so a failed
// navigation can retry (the provider's dispatcher dedups).
h.reconciler.onState(h.state());
h.reconciler.onState(h.state());
h.reconciler.onState(h.state());
async.flushMicrotasks();
expect(switches, ['rk1', 'rk1', 'rk1']);
expect(h.player.commandLog, isEmpty);
h.dispose();
});
});
test('attached to matching media never fires the switch callback', () {
fakeAsync((async) {
final switches = <String>[];
final h = _Harness(
async,
callbacks: GuestReconcilerCallbacks(onMediaSwitchNeeded: (rk, sid, title) => switches.add(rk)),
);
h.attachReady();
h.reconciler.onState(h.state());
async.elapse(const Duration(seconds: 2));
expect(switches, isEmpty);
h.dispose();
});
});
test('attach reconciles to the latest state received while detached', () {
fakeAsync((async) {
final h = _Harness(async);
// State arrives during an episode-switch gap (no player attached).
h.reconciler.onState(h.state(phase: PlaybackPhase.paused, anchorPositionMs: 300000));
async.flushMicrotasks();
h.attachReady();
expect(h.player.state.position, const Duration(minutes: 5));
h.dispose();
});
});
test('readiness is announced on first frame and revoked on detach', () {
fakeAsync((async) {
final h = _Harness(async);
h.reconciler.attach(h.attached, ratingKey: 'rk1', serverId: 'srv');
async.flushMicrotasks();
expect(h.statuses.last.ready, isFalse);
h.player.emitPlaybackRestart();
async.flushMicrotasks();
expect(h.statuses.last.ready, isTrue);
h.reconciler.detachPlayer();
expect(h.statuses.last.ready, isFalse);
h.dispose();
});
});
test('self-heals when the host wrongly lists us in waitingOn', () {
fakeAsync((async) {
final h = _Harness(async);
h.attachReady();
final readyStatuses = h.statuses.where((s) => s.ready).length;
h.reconciler.onState(h.state(phase: PlaybackPhase.waitingForPeers, waitingOn: ['guest']));
async.flushMicrotasks();
expect(h.statuses.where((s) => s.ready).length, readyStatuses + 1);
h.dispose();
});
});
test('buffering changes refresh the status while stalled', () {
fakeAsync((async) {
final h = _Harness(async);
h.attachReady();
h.reconciler.onState(h.state());
async.flushMicrotasks();
h.player.emitBuffering(true);
async.flushMicrotasks();
expect(h.statuses.last.buffering, isTrue);
async.elapse(const Duration(seconds: 6));
expect(h.statuses.where((s) => s.buffering).length, greaterThan(1)); // 5s refresh.
h.player.emitBuffering(false);
async.flushMicrotasks();
expect(h.statuses.last.buffering, isFalse);
h.dispose();
});
});
});
group('anyone-mode control', () {
test('guest seek sends a request and in-flight heartbeats do not undo it', () {
fakeAsync((async) {
final h = _Harness(async);
h.attachReady();
h.reconciler.onState(h.state(controlMode: ControlMode.anyone));
async.flushMicrotasks();
// User seeks locally; screen already moved the player.
h.player.setPosition(const Duration(minutes: 20));
h.reconciler.onLocalSeekIntent(const Duration(minutes: 20));
async.flushMicrotasks();
expect(h.controls.single.kind, ControlRequestKind.seek);
expect(h.controls.single.positionMs, const Duration(minutes: 20).inMilliseconds);
// A heartbeat that left the host before our request arrives with the
// old anchor — inside the optimistic window it must not yank us back.
h.reconciler.onState(h.state(controlMode: ControlMode.anyone, anchorPositionMs: 120000));
async.elapse(const Duration(milliseconds: 600));
expect(h.seekCommands, isEmpty);
// The host's confirming transition (actor = us) closes the window.
h.reconciler.onState(
h.state(
controlMode: ControlMode.anyone,
anchorPositionMs: const Duration(minutes: 20).inMilliseconds,
actorPeerId: 'guest',
actionHint: PlaybackActionHint.seek,
),
);
async.elapse(const Duration(milliseconds: 600));
expect(h.seekCommands, isEmpty); // Already in place — converged.
h.dispose();
});
});
test('guest play/pause intents become control requests', () {
fakeAsync((async) {
final h = _Harness(async);
h.attachReady();
h.reconciler.onState(h.state(controlMode: ControlMode.anyone));
async.flushMicrotasks();
expect(h.player.state.playing, isTrue);
h.player.emitPlaying(false); // User pause.
async.flushMicrotasks();
expect(h.controls.last.kind, ControlRequestKind.pause);
// Optimistic: not snapped back immediately.
expect(h.player.state.playing, isFalse);
h.dispose();
});
});
});
group('edge cases', () {
test('EOF clamp: both at the credits → no fighting', () {
fakeAsync((async) {
final h = _Harness(async);
h.attachReady();
final durationMs = h.player.state.duration.inMilliseconds;
h.player.setPosition(Duration(milliseconds: durationMs));
h.player.setCompleted(true);
h.deliverAndSettleDrift(h.state(anchorPositionMs: durationMs - 400));
expect(h.seekCommands, isEmpty);
h.dispose();
});
});
test('guest at EOF while the room plays on rejoins via seek + play', () {
fakeAsync((async) {
final h = _Harness(async);
h.attachReady();
final durationMs = h.player.state.duration.inMilliseconds;
h.player.setPosition(Duration(milliseconds: durationMs));
h.player.setCompleted(true);
h.reconciler.onState(h.state(anchorPositionMs: durationMs - 600000));
async.flushMicrotasks();
expect(h.seekCommands, hasLength(1));
h.dispose();
});
});
test('live (!seekable) limits corrections to play/pause/rate', () {
fakeAsync((async) {
final h = _Harness(async);
final livePlayer = FakeSyncPlayer(seekable: false, position: const Duration(minutes: 2));
final attached = AttachedPlayer(player: livePlayer, onLost: () {}, nowMs: h.nowMs);
h.reconciler.attach(attached, ratingKey: 'rk1', serverId: 'srv', hasFirstFrame: true);
async.flushMicrotasks();
h.deliverAndSettleDrift(h.state(anchorPositionMs: livePlayer.state.position.inMilliseconds + 60000));
expect(livePlayer.state.playing, isTrue); // Play enforced.
expect(livePlayer.commandLog.where((c) => c.startsWith('seek:')), isEmpty); // Never seeks live.
h.reconciler.dispose();
attached.dispose();
h.attached.dispose();
});
});
test('backgrounded guests freewheel without corrections', () {
fakeAsync((async) {
final h = _Harness(async);
h.attachReady();
h.player.emitPlaying(true);
async.flushMicrotasks();
h.reconciler.setBackgrounded(true);
h.deliverAndSettleDrift(h.state(anchorPositionMs: h.player.state.position.inMilliseconds + 30000));
expect(h.seekCommands, isEmpty);
h.reconciler.setBackgrounded(false);
async.elapse(const Duration(milliseconds: 600));
expect(h.seekCommands, isNotEmpty); // Catches up once foregrounded.
h.dispose();
});
});
});
}