import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/watch_together/services/watch_together_relay_endpoint.dart'; import 'package:plezy/watch_together/models/sync_message.dart'; import 'package:plezy/watch_together/models/watch_session.dart'; import 'package:plezy/watch_together/providers/watch_together_provider.dart'; import 'package:plezy/watch_together/services/watch_together_peer_service.dart'; import 'package:plezy/watch_together/services/relay_protocol.g.dart'; import '../test_helpers/prefs.dart'; class _FakeWatchTogetherPeerService extends WatchTogetherPeerService { _FakeWatchTogetherPeerService( this.sequence, { required bool hostInitiallyConnected, required this.rejectDisconnectedTargets, this.releaseError, this.releaseBarrier, }) : _hostConnected = hostInitiallyConnected; final int sequence; final bool rejectDisconnectedTargets; final Object? releaseError; final Future? releaseBarrier; final _peerConnectedController = StreamController.broadcast(); final _peerDisconnectedController = StreamController.broadcast(); final _messageController = StreamController.broadcast(); final _errorController = StreamController.broadcast(); final _sessionEndedController = StreamController.broadcast(); final List broadcasts = []; final List<(String, SyncMessage)> directMessages = []; String? _sessionId; String? _myPeerId; String? _hostPeerId; bool _isHost = false; bool _disposed = false; bool _didDisconnect = false; int releaseCalls = 0; bool _hostConnected; @override Stream get onPeerConnected => _peerConnectedController.stream; @override Stream get onPeerDisconnected => _peerDisconnectedController.stream; @override Stream get onMessageReceived => _messageController.stream; @override Stream get onError => _errorController.stream; @override Stream get onSessionEnded => _sessionEndedController.stream; @override String? get sessionId => _sessionId; @override String? get myPeerId => _myPeerId; @override String? get hostPeerId => _hostPeerId; @override bool get isHost => _isHost; @override List get connectedPeers => !_isHost && _sessionId != null && _hostConnected ? ['wt-$_sessionId'] : const []; @override Future createSession({String? sessionId}) { _sessionId = (sessionId ?? 'ROOM$sequence').toUpperCase(); _myPeerId = 'wt-$_sessionId'; _hostPeerId = _myPeerId; _isHost = true; return Future.value(_sessionId); } @override Future joinSession(String sessionId) { _sessionId = sessionId.toUpperCase(); _myPeerId = 'guest-$sequence'; _hostPeerId = 'wt-$_sessionId'; _isHost = false; return Future.value(); } @override void broadcast(SyncMessage message) { broadcasts.add(message); } @override void sendTo(String peerId, SyncMessage message) { if (rejectDisconnectedTargets && !connectedPeers.contains(peerId)) { _errorController.add( const PeerError(type: PeerErrorType.serverError, message: 'Peer is not in the room', serverCode: 'not_in_room'), ); return; } directMessages.add((peerId, message)); } void emitError(PeerError error) => _errorController.add(error); void emitPeerConnected(String peerId) { if (peerId == _hostPeerId) _hostConnected = true; _peerConnectedController.add(peerId); } void emitPeerDisconnected(String peerId) { if (peerId == _hostPeerId) _hostConnected = false; _peerDisconnectedController.add(peerId); } void emitSessionEnded() => _sessionEndedController.add(null); void emitMessage(SyncMessage message) => _messageController.add(message); bool get hasRelayListeners => _peerConnectedController.hasListener || _peerDisconnectedController.hasListener || _messageController.hasListener || _errorController.hasListener || _sessionEndedController.hasListener; bool get isDisposed => _disposed; bool get didDisconnect => _didDisconnect; @override Future releaseSession() async { releaseCalls++; final barrier = releaseBarrier; if (barrier != null) await barrier; final error = releaseError; if (error != null) throw error; } void reconnect() => onReconnected?.call(); @override Future disconnect() { _didDisconnect = true; _sessionId = null; _myPeerId = null; _hostPeerId = null; _isHost = false; _hostConnected = false; return Future.value(); } @override void dispose() { if (_disposed) return; _disposed = true; _peerConnectedController.close(); _peerDisconnectedController.close(); _messageController.close(); _errorController.close(); _sessionEndedController.close(); super.dispose(); } } class _FakePeerServiceFactory { _FakePeerServiceFactory({ this.hostInitiallyConnected = true, this.rejectDisconnectedTargets = false, this.releaseError, this.releaseBarrier, }); final bool hostInitiallyConnected; final bool rejectDisconnectedTargets; final Object? releaseError; final Future? releaseBarrier; final List<_FakeWatchTogetherPeerService> services = []; WatchTogetherPeerService call({WatchTogetherRelayEndpoint? endpoint}) { final service = _FakeWatchTogetherPeerService( services.length + 1, hostInitiallyConnected: hostInitiallyConnected, rejectDisconnectedTargets: rejectDisconnectedTargets, releaseError: releaseError, releaseBarrier: releaseBarrier, ); services.add(service); return service; } } PeerError _transportError([String message = 'WebSocket error: connection reset']) { return PeerError(type: PeerErrorType.serverError, message: message, originalError: StateError('connection reset')); } Future _flushProviderEvents() async { await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); } const _providerHostId = 'relay-authoritative-host'; typedef _ProviderRelayHandler = void Function(WebSocket socket, Map message); class _ProviderRelay { _ProviderRelay._(this._server); final HttpServer _server; final List _sockets = []; final List> messages = []; String get baseUrl => 'http://${_server.address.address}:${_server.port}'; static Future<_ProviderRelay> start(_ProviderRelayHandler handler) async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final relay = _ProviderRelay._(server); server.listen((request) async { if (request.uri.path != '/relay') { request.response.statusCode = HttpStatus.notFound; await request.response.close(); return; } final socket = await WebSocketTransformer.upgrade(request); relay._sockets.add(socket); socket.listen((data) { final message = jsonDecode(data as String) as Map; relay.messages.add(message); handler(socket, message); }); }); return relay; } void send(WebSocket socket, Map message) => socket.add(jsonEncode(message)); Future close() async { for (final socket in _sockets) { await socket.close(); } await _server.close(force: true); } } void main() { test('generated relay versions match the protocol specification', () { final spec = (jsonDecode(File('relay_protocol.json').readAsStringSync()) as Map).cast(); expect(RelayProtocol.protocolVersion, spec['protocolVersion']); expect(RelayProtocol.legacyProtocolVersion, spec['legacyProtocolVersion']); }); TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { // The provider reads SettingsService.instanceOrNull?.read(...) when // creating/joining sessions; ensure prefs are reset between tests. resetSharedPreferencesForTest(); }); group('WatchTogetherProvider — initial state', () { test('starts disconnected with no session, peers, or sync state', () { final p = WatchTogetherProvider(); expect(p.session, isNull); expect(p.sessionId, isNull); expect(p.isInSession, isFalse); expect(p.isHost, isFalse); expect(p.isConnected, isFalse); expect(p.isSyncing, isFalse); expect(p.isWaitingForPeers, isFalse); expect(p.waitingOnNames, isEmpty); expect(p.isWaitingForHostReconnect, isFalse); expect(p.participants, isEmpty); expect(p.participantCount, 0); // Default control mode falls back to hostOnly when there's no session. expect(p.controlMode, ControlMode.hostOnly); expect(p.hasAttachedPlayer, isFalse); p.dispose(); }); test('current media getters all return null on a fresh provider', () { final p = WatchTogetherProvider(); expect(p.currentMediaRatingKey, isNull); expect(p.currentMediaServerId, isNull); expect(p.currentMediaTitle, isNull); expect(p.hasCurrentPlayback, isFalse); p.dispose(); }); test('participants list is unmodifiable', () { final p = WatchTogetherProvider(); // Even when empty, the unmodifiable view must reject mutation so // callers can't smuggle peers in by mutating the returned list. expect( () => p.participants.add(const Participant(peerId: 'x', displayName: 'y', isHost: false)), throwsUnsupportedError, ); p.dispose(); }); test('canControl returns true outside of a session (no gating)', () { final p = WatchTogetherProvider(); expect(p.canControl(), isTrue); p.dispose(); }); }); group('WatchTogetherProvider — session guards', () { test('setCurrentMedia is rejected outside a session', () { final p = WatchTogetherProvider(); var notified = 0; p.addListener(() => notified++); // Without a session, setCurrentMedia logs a warning and bails — no notify. p.setCurrentMedia(ratingKey: 'rk1', serverId: ServerId('s1'), mediaTitle: 't1'); expect(notified, 0); expect(p.currentMediaRatingKey, isNull); p.dispose(); }); test('setBackgrounded is null-safe without a sync controller', () { final p = WatchTogetherProvider(); expect(() => p.setBackgrounded(true), returnsNormally); expect(() => p.setBackgrounded(false), returnsNormally); p.dispose(); }); }); group('WatchTogetherProvider — media switch dispatch', () { test('dispatches once with typed args and suppresses the key after success', () async { final p = WatchTogetherProvider(); final calls = <(String, String, String)>[]; p.onMediaSwitched = (ratingKey, serverId, mediaTitle) async { calls.add((ratingKey, serverId, mediaTitle)); return true; }; p.debugHandleMediaState('rk1', 's1', 'Ep 1'); await Future.delayed(Duration.zero); expect(calls, [('rk1', 's1', 'Ep 1')]); // Heartbeat repeat of the handled key: no re-dispatch. p.debugHandleMediaState('rk1', 's1', 'Ep 1'); await Future.delayed(Duration.zero); expect(calls.length, 1); p.dispose(); }); test('a false result is retried on the next heartbeat state', () async { final p = WatchTogetherProvider(); var calls = 0; p.onMediaSwitched = (ratingKey, serverId, mediaTitle) async { calls++; return calls > 1; // Fail once, then succeed. }; p.debugHandleMediaState('rk1', 's1', null); await Future.delayed(Duration.zero); p.debugHandleMediaState('rk1', 's1', null); await Future.delayed(Duration.zero); expect(calls, 2); p.debugHandleMediaState('rk1', 's1', null); await Future.delayed(Duration.zero); expect(calls, 2); // Second attempt succeeded; key now handled. p.dispose(); }); test('a throwing callback is contained and retried', () async { final p = WatchTogetherProvider(); var calls = 0; p.onMediaSwitched = (ratingKey, serverId, mediaTitle) async { calls++; throw StateError('network down'); }; expect(() => p.debugHandleMediaState('rk1', 's1', null), returnsNormally); await Future.delayed(Duration.zero); p.debugHandleMediaState('rk1', 's1', null); await Future.delayed(Duration.zero); expect(calls, 2); p.dispose(); }); test('no double dispatch while a switch is pending, even for another key', () async { final p = WatchTogetherProvider(); final pending = Completer(); final calls = []; p.onMediaSwitched = (ratingKey, serverId, mediaTitle) { calls.add(ratingKey); return pending.future; }; p.debugHandleMediaState('rk1', 's1', null); p.debugHandleMediaState('rk1', 's1', null); p.debugHandleMediaState('rk2', 's1', null); // Serialized behind rk1. await Future.delayed(Duration.zero); expect(calls, ['rk1']); pending.complete(false); await Future.delayed(Duration.zero); // The slot is free again; the next heartbeat re-dispatches. p.debugHandleMediaState('rk2', 's1', null); await Future.delayed(Duration.zero); expect(calls, ['rk1', 'rk2']); p.dispose(); }); test('onPlayerMediaSwitched takes priority over onMediaSwitched', () async { final p = WatchTogetherProvider(); final calls = []; p.onMediaSwitched = (ratingKey, serverId, mediaTitle) async { calls.add('main'); return true; }; p.onPlayerMediaSwitched = (ratingKey, serverId, mediaTitle) async { calls.add('player'); return true; }; p.debugHandleMediaState('rk1', 's1', null); await Future.delayed(Duration.zero); expect(calls, ['player']); p.dispose(); }); test('markCurrentPlaybackHandled suppresses the marked key', () async { final p = WatchTogetherProvider(); var calls = 0; p.onMediaSwitched = (ratingKey, serverId, mediaTitle) async { calls++; return true; }; p.markCurrentPlaybackHandled(ratingKey: 'rk1', serverId: ServerId('s1')); p.debugHandleMediaState('rk1', 's1', null); await Future.delayed(Duration.zero); expect(calls, 0); p.dispose(); }); test('a blank serverId is ignored without throwing', () { final p = WatchTogetherProvider(); var calls = 0; p.onMediaSwitched = (ratingKey, serverId, mediaTitle) async { calls++; return true; }; expect(() => p.debugHandleMediaState('rk1', '', null), returnsNormally); expect(calls, 0); p.dispose(); }); }); group('WatchTogetherProvider — reconnect recovery', () { test('restores only the matching host transport error and preserves session fields', () async { final factory = _FakePeerServiceFactory(); final provider = WatchTogetherProvider(peerServiceFactory: factory.call); final observedStates = []; provider.addListener(() => observedStates.add(provider.session?.state)); await provider.createSession( controlMode: ControlMode.anyone, relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint, displayName: 'Host', sessionId: 'room1', mediaRatingKey: 'rating-1', mediaServerId: 'server-1', mediaTitle: 'Episode 1', ); await _flushProviderEvents(); final established = provider.session!; final service = factory.services.single; observedStates.clear(); service.broadcasts.clear(); service.emitError(_transportError()); await _flushProviderEvents(); expect(provider.session?.state, SessionState.error); expect(provider.isConnected, isFalse); service.reconnect(); await _flushProviderEvents(); expect(observedStates, [SessionState.error, SessionState.connected]); expect(provider.isConnected, isTrue); expect(provider.session?.errorMessage, isNull); expect(provider.session?.sessionId, established.sessionId); expect(provider.session?.role, established.role); expect(provider.session?.controlMode, established.controlMode); expect(provider.session?.mediaRatingKey, established.mediaRatingKey); expect(provider.session?.mediaServerId, established.mediaServerId); expect(provider.session?.mediaTitle, established.mediaTitle); expect(service.broadcasts.where((message) => message.type == SyncMessageType.join), hasLength(1)); await provider.leaveSession(); provider.dispose(); }); test('guest waits for a reconnecting declared host before requesting state', () async { final factory = _FakePeerServiceFactory(hostInitiallyConnected: false, rejectDisconnectedTargets: true); final provider = WatchTogetherProvider(peerServiceFactory: factory.call); await provider.joinSession( 'late1', relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint, displayName: 'Guest', ); await _flushProviderEvents(); final service = factory.services.single; final hostPeerId = provider.session!.hostPeerId!; expect(provider.session?.state, SessionState.connected); expect(service.connectedPeers, isEmpty); expect(service.directMessages.where((entry) => entry.$2.type == SyncMessageType.requestState), isEmpty); service.emitPeerConnected(hostPeerId); await _flushProviderEvents(); expect(service.connectedPeers, [hostPeerId]); expect( service.directMessages.where( (entry) => entry.$1 == hostPeerId && entry.$2.type == SyncMessageType.requestState, ), hasLength(1), ); expect(provider.session?.state, SessionState.connected); await provider.leaveSession(); provider.dispose(); }); test('guest recovery re-announces and re-requests authoritative state once', () async { final factory = _FakePeerServiceFactory(); final provider = WatchTogetherProvider(peerServiceFactory: factory.call); await provider.joinSession( 'room2', relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint, displayName: 'Guest', ); await _flushProviderEvents(); final service = factory.services.single; service.broadcasts.clear(); service.directMessages.clear(); service.emitError(_transportError()); await _flushProviderEvents(); service.reconnect(); await _flushProviderEvents(); expect(provider.session?.state, SessionState.connected); expect(service.broadcasts.where((message) => message.type == SyncMessageType.join), hasLength(1)); expect(service.directMessages.where((entry) => entry.$2.type == SyncMessageType.requestState), hasLength(1)); await provider.leaveSession(); provider.dispose(); }); test('relay errors remain terminal when the current service reconnects', () async { final factory = _FakePeerServiceFactory(); final provider = WatchTogetherProvider(peerServiceFactory: factory.call); final observedStates = []; provider.addListener(() => observedStates.add(provider.session?.state)); await provider.createSession( controlMode: ControlMode.hostOnly, relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint, sessionId: 'room3', ); await _flushProviderEvents(); final service = factory.services.single; observedStates.clear(); service.emitError( const PeerError(type: PeerErrorType.serverError, message: 'Room was rejected', serverCode: 'room_rejected'), ); await _flushProviderEvents(); service.reconnect(); await _flushProviderEvents(); expect(observedStates, [SessionState.error]); expect(provider.session?.state, SessionState.error); expect(provider.session?.errorMessage, 'Room was rejected'); expect(provider.isConnected, isFalse); await provider.leaveSession(); provider.dispose(); }); test('host-loss expiry supersedes a recoverable guest transport error', () { fakeAsync((async) { final factory = _FakePeerServiceFactory(); final provider = WatchTogetherProvider(peerServiceFactory: factory.call); var joined = false; unawaited( provider .joinSession('room4', relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint, displayName: 'Guest') .then((_) => joined = true), ); async.flushMicrotasks(); expect(joined, isTrue); final service = factory.services.single; final hostPeerId = provider.session!.hostPeerId!; service.emitError(_transportError()); async.flushMicrotasks(); expect(provider.session?.state, SessionState.error); service.emitPeerDisconnected(hostPeerId); async.flushMicrotasks(); expect(provider.isWaitingForHostReconnect, isTrue); async.elapse(const Duration(seconds: 15)); async.flushMicrotasks(); expect(provider.session?.errorMessage, 'Host left the session'); service.reconnect(); async.flushMicrotasks(); expect(provider.session?.state, SessionState.error); expect(provider.session?.errorMessage, 'Host left the session'); unawaited(provider.leaveSession()); async.flushMicrotasks(); provider.dispose(); async.flushMicrotasks(); }); }); test('stale service callback cannot mutate or resync a replacement session', () async { final factory = _FakePeerServiceFactory(); final provider = WatchTogetherProvider(peerServiceFactory: factory.call); var notifications = 0; provider.addListener(() => notifications++); await provider.createSession( controlMode: ControlMode.hostOnly, relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint, sessionId: 'old1', ); await _flushProviderEvents(); final oldService = factory.services.single; final staleReconnect = oldService.onReconnected!; oldService.emitError(_transportError('old transport error')); await _flushProviderEvents(); await provider.createSession( controlMode: ControlMode.anyone, relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint, sessionId: 'new1', ); await _flushProviderEvents(); final currentService = factory.services.last; currentService.broadcasts.clear(); notifications = 0; final replacement = provider.session; staleReconnect(); await _flushProviderEvents(); expect(provider.session, replacement); expect(notifications, 0); expect(currentService.broadcasts, isEmpty); currentService.emitError(_transportError('current transport error')); await _flushProviderEvents(); currentService.reconnect(); await _flushProviderEvents(); expect(provider.session?.state, SessionState.connected); expect(provider.session?.errorMessage, isNull); expect(currentService.broadcasts.where((message) => message.type == SyncMessageType.join), hasLength(1)); await provider.leaveSession(); provider.dispose(); }); }); group('WatchTogetherProvider — release cleanup', () { test('release failure is surfaced after local session teardown completes', () async { final releaseFailure = StateError('relay release failed'); final factory = _FakePeerServiceFactory(releaseError: releaseFailure); final provider = WatchTogetherProvider(peerServiceFactory: factory.call); addTearDown(provider.dispose); await provider.createSession( controlMode: ControlMode.hostOnly, relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint, sessionId: 'fail1', ); final service = factory.services.single; await expectLater(provider.leaveSession(), throwsA(same(releaseFailure))); expect(service.releaseCalls, 1); expect(service.didDisconnect, isTrue); expect(service.isDisposed, isTrue); expect(provider.session, isNull); expect(provider.isInSession, isFalse); expect(provider.participants, isEmpty); }); }); group('WatchTogetherProvider — relay authority', () { test('an empty successful probe joins the reserved room without creating', () async { late final _ProviderRelay relay; relay = await _ProviderRelay.start((socket, message) { if (message['type'] == 'join') { relay.send(socket, { 'type': 'joined', 'sessionId': message['sessionId'], 'hostPeerId': _providerHostId, 'reconnectToken': message['reconnectToken'], 'protocolVersion': 2, }); } else if (message['type'] == 'leave') { relay.send(socket, { 'type': 'left', 'sessionId': message['sessionId'], 'peerId': message['peerId'], 'protocolVersion': 2, }); } }); addTearDown(relay.close); final endpoint = WatchTogetherRelayEndpoint.resolve(relay.baseUrl); final provider = WatchTogetherProvider(); addTearDown(() async { await provider.leaveSession(); provider.dispose(); }); final becameHost = await provider.enterRoom('empty1', relayEndpoint: endpoint, displayName: 'Guest'); expect(becameHost, isFalse); expect(provider.isHost, isFalse); expect(provider.session?.hostPeerId, _providerHostId); final joins = relay.messages.where((message) => message['type'] == 'join').toList(); expect(joins, hasLength(2)); for (final join in joins) { expect(join['protocolVersion'], 2); expect(join['reconnectToken'], matches(RegExp(r'^[A-Za-z0-9_-]{43}$'))); } final leave = relay.messages.singleWhere((message) => message['type'] == 'leave'); expect(leave['peerId'], joins.first['peerId']); expect(leave['reconnectToken'], joins.first['reconnectToken']); expect(leave['protocolVersion'], RelayProtocol.protocolVersion); expect(joins.last['peerId'], isNot(joins.first['peerId'])); expect(relay.messages.map((message) => message['type']).take(3), ['join', 'leave', 'join']); expect(relay.messages.where((message) => message['type'] == 'create'), isEmpty); }); test('a room-not-found probe creates with relay-declared host authority', () async { late final _ProviderRelay relay; relay = await _ProviderRelay.start((socket, message) { if (message['type'] == 'join') { relay.send(socket, {'type': 'error', 'code': 'room_not_found', 'message': 'Room not found'}); } else if (message['type'] == 'create') { relay.send(socket, { 'type': 'created', 'sessionId': message['sessionId'], 'hostPeerId': message['peerId'], 'reconnectToken': message['reconnectToken'], 'protocolVersion': 2, }); } else if (message['type'] == 'endSession') { relay.send(socket, {'type': 'ended', 'sessionId': message['sessionId'], 'protocolVersion': 2}); } }); addTearDown(relay.close); final endpoint = WatchTogetherRelayEndpoint.resolve(relay.baseUrl); final provider = WatchTogetherProvider(); addTearDown(() async { await provider.leaveSession(); provider.dispose(); }); final becameHost = await provider.enterRoom('new01', relayEndpoint: endpoint, displayName: 'Host'); expect(becameHost, isTrue); expect(provider.isHost, isTrue); final create = relay.messages.singleWhere((message) => message['type'] == 'create'); expect(provider.session?.hostPeerId, create['peerId']); expect(create['peerId'], isNot('wt-NEW01')); expect(create['protocolVersion'], 2); expect(create['reconnectToken'], matches(RegExp(r'^[A-Za-z0-9_-]{43}$'))); }); }); group('WatchTogetherProvider — terminal room lifecycle', () { test('relay ended notification exits immediately without release or reconnect grace', () async { final factory = _FakePeerServiceFactory(); final provider = WatchTogetherProvider(peerServiceFactory: factory.call); var hostExitCalls = 0; provider.onHostExitedPlayer = () => hostExitCalls++; await provider.joinSession( 'ended1', relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint, displayName: 'Guest', ); final service = factory.services.single; service.emitPeerDisconnected(provider.session!.hostPeerId!); await _flushProviderEvents(); expect(provider.isWaitingForHostReconnect, isTrue); service.emitSessionEnded(); await _flushProviderEvents(); expect(hostExitCalls, 1); expect(provider.session, isNull); expect(provider.isWaitingForHostReconnect, isFalse); expect(service.releaseCalls, 0); expect(service.didDisconnect, isTrue); expect(service.isDisposed, isTrue); provider.dispose(); }); test('best-effort host-leave cleanup observes release failures', () async { final releaseFailure = StateError('relay release failed'); final factory = _FakePeerServiceFactory(releaseError: releaseFailure); final uncaught = []; await runZonedGuarded(() async { final provider = WatchTogetherProvider(peerServiceFactory: factory.call); await provider.joinSession( 'leave2', relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint, displayName: 'Guest', ); final service = factory.services.single; service.emitMessage(SyncMessage.leave(peerId: provider.session!.hostPeerId!)); await _flushProviderEvents(); expect(provider.session, isNull); expect(service.releaseCalls, 1); provider.dispose(); }, (error, _) => uncaught.add(error)); expect(uncaught, isEmpty); }); }); group('WatchTogetherProvider — dispose hygiene', () { test('participantEvents stream is closed after dispose', () async { final p = WatchTogetherProvider(); // Attach a listener; capture done via the stream's done future. final events = []; var streamDone = false; final sub = p.participantEvents.listen(events.add, onDone: () => streamDone = true); p.dispose(); // Yield so the broadcast controller's close microtask runs. await Future.delayed(Duration.zero); await sub.cancel(); expect(streamDone, isTrue); }); test('dispose detaches local listeners before relay release completes', () async { final releaseCompleter = Completer(); final factory = _FakePeerServiceFactory(releaseBarrier: releaseCompleter.future); final provider = WatchTogetherProvider(peerServiceFactory: factory.call); await provider.joinSession( 'dispose1', relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint, displayName: 'Guest', ); final service = factory.services.single; expect(service.hasRelayListeners, isTrue); provider.dispose(); expect(provider.session, isNull); expect(provider.participants, isEmpty); expect(provider.isWaitingForHostReconnect, isFalse); expect(service.hasRelayListeners, isFalse); expect(service.releaseCalls, 1); expect(service.didDisconnect, isFalse); service.emitPeerConnected('late-peer'); service.emitMessage(SyncMessage.join(peerId: 'late-peer', displayName: 'Late', isHost: false)); await _flushProviderEvents(); expect(provider.participants, isEmpty); releaseCompleter.complete(); await _flushProviderEvents(); expect(service.didDisconnect, isTrue); expect(service.isDisposed, isTrue); }); }); }