294 lines
7.6 KiB
Dart
294 lines
7.6 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
|
import 'package:xworkmate/features/desktop/desktop_client.dart';
|
|
|
|
class FakeMediaStream extends MediaStream {
|
|
FakeMediaStream(String id) : super(id, 'test');
|
|
|
|
final List<MediaStreamTrack> tracks = [];
|
|
final List<bool> addToNativeValues = [];
|
|
|
|
@override
|
|
bool? get active => true;
|
|
|
|
@override
|
|
Future<void> addTrack(
|
|
MediaStreamTrack track, {
|
|
bool addToNative = true,
|
|
}) async {
|
|
tracks.add(track);
|
|
addToNativeValues.add(addToNative);
|
|
}
|
|
|
|
@override
|
|
Future<void> getMediaTracks() async {}
|
|
|
|
@override
|
|
List<MediaStreamTrack> getAudioTracks() =>
|
|
tracks.where((track) => track.kind == 'audio').toList();
|
|
|
|
@override
|
|
MediaStreamTrack? getTrackById(String trackId) {
|
|
for (final track in tracks) {
|
|
if (track.id == trackId) {
|
|
return track;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
List<MediaStreamTrack> getTracks() => List.unmodifiable(tracks);
|
|
|
|
@override
|
|
List<MediaStreamTrack> getVideoTracks() =>
|
|
tracks.where((track) => track.kind == 'video').toList();
|
|
|
|
@override
|
|
Future<void> removeTrack(
|
|
MediaStreamTrack track, {
|
|
bool removeFromNative = true,
|
|
}) async {
|
|
tracks.remove(track);
|
|
}
|
|
}
|
|
|
|
class FakeMediaStreamTrack extends MediaStreamTrack {
|
|
FakeMediaStreamTrack({required this.trackId, required this.trackKind});
|
|
|
|
final String trackId;
|
|
final String trackKind;
|
|
bool _enabled = true;
|
|
|
|
@override
|
|
Future<ByteBuffer> captureFrame() {
|
|
throw UnimplementedError();
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {}
|
|
|
|
@override
|
|
bool get enabled => _enabled;
|
|
|
|
@override
|
|
set enabled(bool b) {
|
|
_enabled = b;
|
|
}
|
|
|
|
@override
|
|
Future<bool> hasTorch() async => false;
|
|
|
|
@override
|
|
String? get id => trackId;
|
|
|
|
@override
|
|
String? get kind => trackKind;
|
|
|
|
@override
|
|
String? get label => trackKind;
|
|
|
|
@override
|
|
bool? get muted => false;
|
|
|
|
@override
|
|
Future<void> setTorch(bool torch) async {}
|
|
|
|
@override
|
|
Future<void> stop() async {}
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
group('DesktopClient protocol helpers', () {
|
|
test('normalizes WebRTC connection states for view gating', () {
|
|
expect(
|
|
desktopConnectionStateName(
|
|
RTCPeerConnectionState.RTCPeerConnectionStateConnected,
|
|
),
|
|
'connected',
|
|
);
|
|
expect(
|
|
desktopConnectionStateName(
|
|
RTCPeerConnectionState.RTCPeerConnectionStateFailed,
|
|
),
|
|
'failed',
|
|
);
|
|
});
|
|
|
|
test('builds desktop offer params with native numeric values', () {
|
|
final params = desktopOfferParams(
|
|
sessionId: 'desktop-session-1',
|
|
sdpOffer: 'v=0',
|
|
display: ':0.0',
|
|
width: 1280,
|
|
height: 720,
|
|
fps: 30,
|
|
bitrate: 2000,
|
|
useGpu: false,
|
|
);
|
|
|
|
expect(params['sessionId'], 'desktop-session-1');
|
|
expect(params['sdpOffer'], 'v=0');
|
|
expect(params['display'], ':0.0');
|
|
expect(params['width'], isA<int>());
|
|
expect(params['height'], isA<int>());
|
|
expect(params['fps'], isA<int>());
|
|
expect(params['bitrate'], isA<int>());
|
|
expect(params['useGpu'], isA<bool>());
|
|
expect(params['width'], 1280);
|
|
expect(params['height'], 720);
|
|
});
|
|
|
|
test(
|
|
'generates distinct desktop session ids for parallel app instances',
|
|
() {
|
|
final first = desktopSessionId();
|
|
final second = desktopSessionId();
|
|
|
|
expect(first, startsWith('remote-desktop-'));
|
|
expect(second, startsWith('remote-desktop-'));
|
|
expect(first, isNot(second));
|
|
expect(first, isNot('remote-desktop-session'));
|
|
},
|
|
);
|
|
|
|
test('drops only stale mouse moves when data channel is backed up', () {
|
|
expect(
|
|
desktopShouldDropInputEvent({
|
|
'type': 'mouse_move',
|
|
}, bufferedAmount: 80 * 1024),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
desktopShouldDropInputEvent({
|
|
'type': 'mouse_down',
|
|
}, bufferedAmount: 80 * 1024),
|
|
isFalse,
|
|
);
|
|
expect(
|
|
desktopShouldDropInputEvent({
|
|
'type': 'mouse_move',
|
|
}, bufferedAmount: 1024),
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
test('treats decoded video stats as a rendered first frame', () {
|
|
expect(
|
|
desktopHasRenderedVideoFrame(
|
|
hasStream: true,
|
|
rendererVideoWidth: 0,
|
|
rendererVideoHeight: 0,
|
|
hasDecodedFrames: true,
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
desktopHasRenderedVideoFrame(
|
|
hasStream: true,
|
|
rendererVideoWidth: 1280,
|
|
rendererVideoHeight: 720,
|
|
hasDecodedFrames: false,
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
desktopHasRenderedVideoFrame(
|
|
hasStream: false,
|
|
rendererVideoWidth: 1280,
|
|
rendererVideoHeight: 720,
|
|
hasDecodedFrames: true,
|
|
),
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
test('uses bridge-provided remote stream when present', () async {
|
|
var fallbackCreated = false;
|
|
final providedStream = FakeMediaStream('provided-stream');
|
|
final track = FakeMediaStreamTrack(
|
|
trackId: 'video-track-1',
|
|
trackKind: 'video',
|
|
);
|
|
|
|
final stream = await desktopRemoteVideoStreamForTrack(
|
|
RTCTrackEvent(streams: [providedStream], track: track),
|
|
createFallbackStream: (label) async {
|
|
fallbackCreated = true;
|
|
return FakeMediaStream(label);
|
|
},
|
|
);
|
|
|
|
expect(stream, same(providedStream));
|
|
expect(fallbackCreated, isFalse);
|
|
expect(providedStream.tracks, isEmpty);
|
|
});
|
|
|
|
test('synthesizes stream for streamless remote video track', () async {
|
|
final track = FakeMediaStreamTrack(
|
|
trackId: 'video-track-1',
|
|
trackKind: 'video',
|
|
);
|
|
final fallbackStream = FakeMediaStream('fallback-stream');
|
|
|
|
final stream = await desktopRemoteVideoStreamForTrack(
|
|
RTCTrackEvent(streams: const [], track: track),
|
|
createFallbackStream: (label) async => fallbackStream,
|
|
);
|
|
|
|
expect(stream, same(fallbackStream));
|
|
expect(fallbackStream.tracks, [same(track)]);
|
|
expect(fallbackStream.addToNativeValues, [isTrue]);
|
|
});
|
|
|
|
test('ignores streamless non-video tracks', () async {
|
|
var fallbackCreated = false;
|
|
final track = FakeMediaStreamTrack(
|
|
trackId: 'audio-track-1',
|
|
trackKind: 'audio',
|
|
);
|
|
|
|
final stream = await desktopRemoteVideoStreamForTrack(
|
|
RTCTrackEvent(streams: const [], track: track),
|
|
createFallbackStream: (label) async {
|
|
fallbackCreated = true;
|
|
return FakeMediaStream(label);
|
|
},
|
|
);
|
|
|
|
expect(stream, isNull);
|
|
expect(fallbackCreated, isFalse);
|
|
});
|
|
|
|
test('summarizes inbound video stats for first-frame diagnostics', () {
|
|
final snapshot = desktopVideoStatsSnapshotFromReports([
|
|
StatsReport('RTCInboundRTPVideoStream_1', 'inbound-rtp', 1, {
|
|
'id': 'RTCInboundRTPVideoStream_1',
|
|
'type': 'inbound-rtp',
|
|
'kind': 'video',
|
|
'packetsReceived': 120,
|
|
'bytesReceived': 48000,
|
|
'framesDecoded': 0,
|
|
'framesDropped': 0,
|
|
'keyFramesDecoded': 0,
|
|
'jitter': 0.003,
|
|
'jitterBufferDelay': 0.12,
|
|
}),
|
|
]);
|
|
|
|
expect(snapshot.inboundVideoReports, 1);
|
|
expect(snapshot.packetsReceived, 120);
|
|
expect(snapshot.bytesReceived, 48000);
|
|
expect(snapshot.framesDecoded, 0);
|
|
expect(snapshot.keyFramesDecoded, 0);
|
|
expect(snapshot.hasRtpPackets, isTrue);
|
|
expect(snapshot.hasDecodedFrames, isFalse);
|
|
expect(snapshot.toString(), contains('packetsReceived=120'));
|
|
});
|
|
});
|
|
}
|