diff --git a/Cargo.toml b/Cargo.toml index 4388fb062..100258694 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -273,7 +273,7 @@ os-version = "0.2" [dev-dependencies] hound = "3.5" docopt = "1.1" -tokio = { version = "1.44", features = ["test-util"] } +tokio = "1.44" [package.metadata.bundle] name = "RustDesk" diff --git a/flutter/lib/common/widgets/autocomplete.dart b/flutter/lib/common/widgets/autocomplete.dart index e83f5fb41..295258392 100644 --- a/flutter/lib/common/widgets/autocomplete.dart +++ b/flutter/lib/common/widgets/autocomplete.dart @@ -271,11 +271,6 @@ class AllPeersLoader { void updateOnlineStateForTesting(Map evt) { _updateOnlineState(evt); } - - @visibleForTesting - bool applyLastOnlineStateForTesting(List peers) { - return _applyLastOnlineState(peers); - } } class AutocompletePeerTile extends StatefulWidget { diff --git a/flutter/lib/mobile/terminal_keyboard_utils.dart b/flutter/lib/mobile/terminal_keyboard_utils.dart index 9248d1d4a..e0d5f4ee4 100644 --- a/flutter/lib/mobile/terminal_keyboard_utils.dart +++ b/flutter/lib/mobile/terminal_keyboard_utils.dart @@ -11,10 +11,3 @@ const terminalKeyboardKeySpacing = 2.0; /// Empty 48dp slots keep expanded Row3 aligned with the two rows above it. const terminalKeyboardRow3TrailingPlaceholderCount = 2; - -/// Returns the fixed width occupied by a row of equally sized key slots. -double terminalKeyboardRowWidth(int slotCount) { - if (slotCount <= 0) return 0; - return slotCount * terminalKeyboardKeyWidth + - (slotCount - 1) * terminalKeyboardKeySpacing; -} diff --git a/flutter/lib/models/terminal_model.dart b/flutter/lib/models/terminal_model.dart index 80725b5bc..9c1eb97a2 100644 --- a/flutter/lib/models/terminal_model.dart +++ b/flutter/lib/models/terminal_model.dart @@ -70,10 +70,6 @@ class TerminalModel with ChangeNotifier { final _inputBuffer = []; - /// Exposes buffered input only for lifecycle regression tests. - @visibleForTesting - int get debugBufferedInputCount => _inputBuffer.length; - // Buffer for output data received before terminal view has valid dimensions. // This prevents NaN errors when writing to terminal before layout is complete. final _pendingOutputChunks = []; diff --git a/flutter/test/autocomplete_peer_merge_test.dart b/flutter/test/autocomplete_peer_merge_test.dart index e3a4b318c..6407d4dac 100644 --- a/flutter/test/autocomplete_peer_merge_test.dart +++ b/flutter/test/autocomplete_peer_merge_test.dart @@ -48,74 +48,6 @@ void main() { expect(peers.single.online, isTrue); }); - test('peer copies preserve online state', () { - final peer = _peer(id: '987654321', online: true); - - expect(Peer.copy(peer).online, isTrue); - }); - - test('online callbacks update autocomplete-only peers', () { - final peers = mergeAutocompletePeers(restRecentPeerIds: ['112233445']); - - final changed = updateAutocompletePeerOnlineStates( - peers, - onlines: {'112233445'}, - offlines: {}, - ); - - expect(changed, isTrue); - expect(peers.single.online, isTrue); - }); - - test('online query ids are deduplicated and limited', () { - final peers = List.generate( - 25, - (index) => _peer(id: index.toString()), - )..insert(1, _peer(id: '0')); - - final ids = autocompleteOnlineQueryIds(peers, limit: 20); - - expect(ids, hasLength(20)); - expect(ids.first, '0'); - expect(ids.where((id) => id == '0'), hasLength(1)); - expect(ids.last, '19'); - }); - - test('empty online query ids cancel pending debounce', () async { - final queriedIds = >[]; - final loader = AllPeersLoader( - queryOnlines: (ids) async { - queriedIds.add(ids); - }, - queryOnlineDebounce: Duration(milliseconds: 1), - ); - - loader.queryOnlines([_peer(id: '123456789')]); - loader.queryOnlines([]); - await Future.delayed(Duration(milliseconds: 2)); - - expect(queriedIds, isEmpty); - }); - - test('failed online query enqueue does not suppress retry', () async { - var queryCount = 0; - final loader = AllPeersLoader( - queryOnlines: (ids) { - queryCount += 1; - return Future.error(Exception('queue full')); - }, - queryOnlineDebounce: Duration(milliseconds: 1), - ); - - loader.queryOnlines([_peer(id: '123456789')]); - await Future.delayed(Duration(milliseconds: 2)); - - loader.queryOnlines([_peer(id: '123456789')]); - await Future.delayed(Duration(milliseconds: 2)); - - expect(queryCount, 2); - }); - test('online callback updates currently displayed options', () async { final loader = AllPeersLoader( queryOnlines: (ids) async {}, @@ -132,17 +64,4 @@ void main() { expect(displayedOptions.single.online, isTrue); await Future.delayed(Duration(milliseconds: 2)); }); - - test('cached online callback state is reapplied after peers merge', () { - final loader = AllPeersLoader(); - loader.updateOnlineStateForTesting({ - 'onlines': '123456789', - 'offlines': '', - }); - - final mergedPeers = [_peer(id: '123456789')]; - loader.applyLastOnlineStateForTesting(mergedPeers); - - expect(mergedPeers.single.online, isTrue); - }); } diff --git a/flutter/test/cm_test.dart b/flutter/test/cm_test.dart deleted file mode 100644 index 4766e3c24..000000000 --- a/flutter/test/cm_test.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'cm_demo.dart' as cm_demo; - -void main() { - test('connection manager demo clients match the current Client API', () { - expect(cm_demo.testClients, hasLength(4)); - expect(cm_demo.testClients.map((client) => client.name), [ - 'UserAAAAAA', - 'UserBBBBB', - 'UserC', - 'UserDDDDDDDDDDDd', - ]); - expect( - cm_demo.testClients.every( - (client) => client.keyboard && !client.clipboard && !client.audio), - isTrue, - ); - }); -} diff --git a/flutter/test/cursor_replay_test.dart b/flutter/test/cursor_replay_test.dart index f24151a62..ea9c47bae 100644 --- a/flutter/test/cursor_replay_test.dart +++ b/flutter/test/cursor_replay_test.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:io'; import 'package:flutter/services.dart'; @@ -15,36 +14,14 @@ class _Cursor extends CursorModel { _Cursor(FFI ffi) : super(WeakReference(ffi)); final core = {}; final fetched = []; - // While set, a fetch answers only when [answerFetches] is called. - bool holdFetches = false; - // While set, a fetch throws before it returns a future. - bool throwAtOnce = false; - final _held = , String>{}; @override Future fetchCursorShape(String id) { fetched.add(id); - if (throwAtOnce) throw StateError('no core'); - if (!holdFetches) return Future.value(core[id]); - final answer = Completer(); - _held[answer] = id; - return answer.future; + return Future.value(core[id]); } - void answerFetches() { - final held = Map.of(_held); - _held.clear(); - held.forEach((answer, id) => answer.complete(core[id])); - } - - void failFetches() { - final held = Map.of(_held); - _held.clear(); - held.forEach((answer, _) => answer.completeError(StateError('failed'))); - } - - bool showRemoteCursor = false; @override - bool get showsRemoteCursor => showRemoteCursor; + bool get showsRemoteCursor => false; } String? _key(MouseCursor cursor) => @@ -181,28 +158,6 @@ void main() { expect(registered.length, 100); }); - test( - 'a raster made before is found again after the shape was switched away from', - () async { - final cursor = ffi.cursorModel; - await _feed(ffi, 'A', size: 32); - buildCursorOfCache(cursor, 1.0, cursor.cache); - await _settle(); - final first = registered.single; - buildCursorOfCache(cursor, 0.5, cursor.cache); - await _settle(); - await _feed(ffi, 'B'); - buildCursorOfCache(cursor, 1.0, cursor.cache); - await _settle(); - final count = registered.length; - - _select(ffi, 'A'); // its pixels went; its cursors at both rasters stay - expect(_key(buildCursorOfCache(cursor, 1.0, cursor.cache)), first); - await _settle(); - expect(ffi.cursor.fetched, isEmpty); - expect(registered.length, count); - }); - test('a new raster of a shape without pixels keeps the cursor shown before', () async { final cursor = ffi.cursorModel; @@ -220,134 +175,6 @@ void main() { cursor.nativeKey(cursor.cache!, 0.5)); }); - test('shapes that keep decoding late still get their native cursors', - () async { - final cursor = ffi.cursorModel; - await _feed(ffi, 'arrow'); - buildCursorOfCache(cursor, 1.0, cursor.cache); - await _settle(); - // A and B arrive, each decoding only after the peer moved on. - for (final (id, seed) in [('A', 1), ('B', 2)]) { - final pixels = _pixels(8, seed); - ffi.cursor.core[id] = - CursorShape(hotx: 0, hoty: 0, width: 8, height: 8, colors: pixels); - final decoding = ffi.ffiModel.handleCursorData(id, 0, 0, 8, 8, pixels); - _select(ffi, 'arrow'); - await decoding; - } - // Each fetch answers only after the next switch. - ffi.cursor.holdFetches = true; - for (var i = 0; i < 6; i++) { - _select(ffi, i.isEven ? 'A' : 'B'); - ffi.cursor.answerFetches(); - await _settle(); - buildCursorOfCache(cursor, 1.0, cursor.cache); - await _settle(); - } - expect(registered.where((key) => key.contains('_A_')), isNotEmpty); - expect(registered.where((key) => key.contains('_B_')), isNotEmpty); - }); - - test('a zoom still gets native cursors for shapes that keep decoding late', - () async { - final cursor = ffi.cursorModel; - // An animation whose frames have native cursors at 1.0 only. - for (final (id, seed) in [('A', 1), ('B', 2)]) { - await _feed(ffi, id, size: 32, seed: seed); - buildCursorOfCache(cursor, 1.0, cursor.cache); - await _settle(); - } - // At 0.5 each frame is fetched again, and each fetch answers after the next switch. - ffi.cursor.holdFetches = true; - for (var i = 0; i < 6; i++) { - _select(ffi, i.isEven ? 'A' : 'B'); - ffi.cursor.answerFetches(); - await _settle(); - buildCursorOfCache(cursor, 0.5, cursor.cache); - await _settle(); - } - expect( - registered - .where((key) => key.contains('_A_') && key.endsWith('_16_16')), - isNotEmpty); - expect( - registered - .where((key) => key.contains('_B_') && key.endsWith('_16_16')), - isNotEmpty); - }); - - test( - 'a raster still missing keeps a late restore, whatever was shown meanwhile', - () async { - final cursor = ffi.cursorModel; - await _feed(ffi, 'A', size: 32); - buildCursorOfCache(cursor, 1.0, cursor.cache); - await _settle(); - await _feed(ffi, 'B'); - buildCursorOfCache(cursor, 1.0, cursor.cache); - await _settle(); - - _select(ffi, 'A'); - ffi.cursor.holdFetches = true; - buildCursorOfCache(cursor, 0.5, cursor.cache); // no cursor at 0.5: fetched - buildCursorOfCache(cursor, 1.0, cursor.cache); // 1.0 has one meanwhile - _select(ffi, 'B'); - ffi.cursor.answerFetches(); - await _settle(); - ffi.cursor.fetched.clear(); - - _select(ffi, 'A'); - buildCursorOfCache(cursor, 0.5, cursor.cache); - await _settle(); - expect(ffi.cursor.fetched, isEmpty, - reason: 'its pixels waited for the cursor at 0.5'); - expect( - registered - .where((key) => key.contains('_A_') && key.endsWith('_16_16')), - isNotEmpty); - }); - - test('shapes waiting for a native cursor keep their pixels, within the limit', - () async { - const max = CursorModel.kRecentShapes; - for (var i = 0; i <= max + 1; i++) { - await _feed(ffi, '$i'); - } - expect(ffi.cursorModel.cachedShape('0')!.hasPixels, isFalse, - reason: 'the one waiting longest is let go'); - expect(ffi.cursorModel.cachedShape('1')!.hasPixels, isTrue); - expect(ffi.cursorModel.cachedShape('$max')!.hasPixels, isTrue); - }); - - test('two animated cursors and the everyday set keep their native cursors', - () async { - // Each frame is a shape of its own: 18 for the Windows busy cursor, 23 for - // KDE Breeze's wait and progress cursors on X11. - const frames = 23; - const statics = 12; - final cursor = ffi.cursorModel; - final shapes = [ - for (var i = 0; i < statics; i++) 's$i', - for (var i = 0; i < frames; i++) 'wait$i', - for (var i = 0; i < frames; i++) 'progress$i', - ]; - for (var i = 0; i < shapes.length; i++) { - await _feed(ffi, shapes[i], seed: i); - buildCursorOfCache(cursor, 1.0, cursor.cache); - await _settle(); - } - ffi.cursor.fetched.clear(); - for (final id in shapes) { - _select(ffi, id); - buildCursorOfCache(cursor, 1.0, cursor.cache); - await _settle(); - } - expect(ffi.cursor.fetched, isEmpty, - reason: 'every shape kept its native cursor'); - expect(registered.length, shapes.length); - expect(deleted, isEmpty); - }); - test('a shape painted again is decoded again, the last one shown meanwhile', () async { await _feed(ffi, '1', size: 16); @@ -360,89 +187,6 @@ void main() { expect(ffi.cursorModel.shapeIds, ['1']); }); - test('a shape the core gives but that does not decode is asked for once', - () async { - await _feed(ffi, '1'); - await _feed(ffi, '2'); - ffi.cursor.core['1'] = CursorShape( - hotx: 0, hoty: 0, width: 8, height: 8, colors: Uint8List(3)); - _select(ffi, '1'); - for (var i = 0; i < 5; i++) { - ffi.cursorModel.image; - await _settle(); - } - expect(ffi.cursor.fetched, ['1']); - }); - - test('a shape switched away from while it decoded is asked for again', - () async { - await _feed(ffi, '1', size: 16); - await _feed(ffi, '2'); - _select(ffi, '1'); - ffi.cursorModel.image; // asks the core - // The core has answered and the shape is decoding when the peer moves on. - await Future.delayed(Duration.zero); - _select(ffi, '2'); - await _settle(); - expect(ffi.cursorModel.cachedShape('1')!.hasPixels, isTrue, - reason: - 'decoded after the peer moved on, it waits for its native cursor'); - _select(ffi, '1'); - ffi.cursorModel.image; - await _settle(); - expect(ffi.cursor.fetched, ['1', '1']); - expect(ffi.cursorModel.image?.width, 16); - }); - - test('a shape decoded after the session was cleared is not kept', () async { - await _feed(ffi, '1', size: 16); - await _feed(ffi, '2'); - _select(ffi, '1'); - ffi.cursorModel.image; // asks the core - ffi.cursorModel.clear(); - await _settle(); - expect(ffi.cursorModel.shapeIds, isEmpty); - expect(ffi.cursorModel.cache, isNull); - }); - - test('a shape the core does not have is asked for once', () async { - await _feed(ffi, '1'); - await _feed(ffi, '2'); - ffi.cursor.core.remove('1'); - _select(ffi, '1'); - for (var i = 0; i < 5; i++) { - ffi.cursorModel.image; - await _settle(); - } - expect(ffi.cursor.fetched, ['1']); - }); - - test('a shape the core lacks is remembered only while it is in use', - () async { - await _feed(ffi, '1'); - for (var i = 0; i < 1000; i++) { - _select(ffi, 'missing$i'); - ffi.cursorModel.image; - } - await _settle(); - expect(ffi.cursor.fetched.length, 1000, reason: 'each is asked for once'); - _select(ffi, 'missing0'); - await _settle(); - expect(ffi.cursor.fetched.length, 1001, - reason: 'nothing is kept for a shape no longer in use'); - }); - - test('a shape the core lacks is asked for again after another was in use', - () async { - await _feed(ffi, '1'); - _select(ffi, 'missing0'); - await _settle(); - _select(ffi, '1'); - _select(ffi, 'missing0'); - await _settle(); - expect(ffi.cursor.fetched, ['missing0', 'missing0']); - }); - test('a moved tab carries the id in use, and its window asks the core', () async { await _feed(ffi, '1', size: 16); @@ -463,30 +207,6 @@ void main() { expect(moved.cursorModel.image?.width, 16); }); - test('a shape keeps a native cursor for each raster it was shown at', - () async { - final cursor = ffi.cursorModel; - await _feed(ffi, '1', size: 32); - for (final scale in [1.0, 0.5, 1.0, 0.5]) { - buildCursorOfCache(cursor, scale, cursor.cache); - await _settle(); - } - expect(registered.length, 2, reason: 'a raster returned to is reused'); - expect(deleted, isEmpty); - expect(cursor.cachedKeys, registered.toSet()); - }); - - test('a cleared session forgets the native cursors it deleted', () async { - final cursor = ffi.cursorModel; - await _feed(ffi, '1'); - buildCursorOfCache(cursor, 1.0, cursor.cache); - await _settle(); - cursor.clear(); - await _settle(); - expect(deleted, registered); - expect(cursor.cachedKeys, isEmpty); - }); - test('a tab closing does not delete the cursors of another tab', () async { final other = _FFI(); addTearDown(other.cursorModel.disposeImages); @@ -502,182 +222,4 @@ void main() { await _settle(); expect(other.cursorModel.cachedKeys.single, isNot(isIn(deleted))); }); - - test('a shape given as a view into a larger buffer is read from the view', - () async { - final pixels = _pixels(8, 3); - final padded = Uint8List(16 + pixels.length) - ..setRange(16, 16 + pixels.length, pixels); - await ffi.ffiModel - .handleCursorData('1', 0, 0, 8, 8, Uint8List.sublistView(padded, 16)); - final p = ffi.cursorModel.cache!.image.getPixel(0, 0); - expect([p.r, p.g, p.b, p.a], [3, 4, 5, 6]); - }); - - test('a shape still decoding leaves the one shown before in place', () async { - final cursor = ffi.cursorModel; - await _feed(ffi, '1'); - final shown = _key(buildCursorOfCache(cursor, 1.0, cursor.cache)); - await _settle(); - _select(ffi, '2'); // its shape has not decoded yet - expect(cursor.cache?.id, '1'); - expect(_key(buildCursorOfCache(cursor, 1.0, cursor.cache)), shown); - expect(cursor.image, isNotNull); - await _feed(ffi, '2'); - expect(cursor.cache?.id, '2'); - }); - - for (final how in ['does not have', 'cannot decode']) { - test('a shape the core $how leaves the default cursor, not the one before', - () async { - final cursor = ffi.cursorModel; - await _feed(ffi, '1'); - if (how == 'cannot decode') { - ffi.cursor.core['2'] = CursorShape( - hotx: 0, hoty: 0, width: 8, height: 8, colors: Uint8List(3)); - } - _select(ffi, '2'); - expect(cursor.cache?.id, '1', reason: 'kept while the shape may come'); - var notified = 0; - cursor.addListener(() => notified++); - await _settle(); - expect(cursor.cache, isNull, - reason: 'the desktop shows preDefaultCursor'); - expect(cursor.image, isNull, reason: 'and so does a painted cursor'); - expect(notified, greaterThan(0)); - }); - } - - test('a shape sent that does not decode leaves the default cursor', () async { - final cursor = ffi.cursorModel; - await _feed(ffi, '1'); - ffi.cursor.core['2'] = CursorShape( - hotx: 0, hoty: 0, width: 8, height: 8, colors: Uint8List(3)); - await ffi.ffiModel.handleCursorData('2', 0, 0, 8, 8, Uint8List(3)); - expect(cursor.cache, isNull); - expect(cursor.image, isNull); - _select(ffi, '2'); - await _settle(); - expect(ffi.cursor.fetched, isEmpty, reason: 'it is not asked for either'); - }); - - test('a fetch failing after the session was cleared marks nothing in it', - () async { - final cursor = ffi.cursorModel; - ffi.cursor.holdFetches = true; - _select(ffi, '1'); // not decoded here yet, so it is asked for - cursor.clear(); - ffi.cursor.failFetches(); - await _settle(); - ffi.cursor.holdFetches = false; - _select(ffi, '1'); // the same id, now in the new session - await _settle(); - expect(ffi.cursor.fetched, ['1', '1']); - }); - - test('a fetch that throws at once tells the listeners after the build', - () async { - final cursor = ffi.cursorModel; - await _feed(ffi, '1'); - ffi.cursor.throwAtOnce = true; - cursor.id = '2'; - var notified = 0; - cursor.addListener(() => notified++); - cursor.restorePixels('2'); - expect(notified, 0, reason: 'a build may be what asked'); - await _settle(); - expect(notified, greaterThan(0)); - expect(cursor.cache, isNull); - }); - - test('a raster still being made falls back to a shape, not the forbidden one', - () async { - final cursor = ffi.cursorModel; - for (var i = 0; preForbiddenCursor.cache == null && i < 100; i++) { - await _settle(); - } - expect(preForbiddenCursor.cache, isNotNull); - await _feed(ffi, '1', size: 32); - buildCursorOfCache(cursor, 1.0, cursor.cache); - await _settle(); - await _feed(ffi, '2', size: 32); - final shown = _key(buildCursorOfCache(cursor, 1.0, cursor.cache)); - await _settle(); - buildCursorOfCache(cursor, 1.0, preForbiddenCursor.cache); // input disabled - _select(ffi, '1'); // its native cursor held its pixels, so they went - expect(_key(buildCursorOfCache(cursor, 0.5, cursor.cache)), shown); - }); - - group('a painted cursor', () { - void painted(String how) { - if (how == 'mobile') { - final was = isMobile; - isMobile = true; - addTearDown(() => isMobile = was); - } else { - ffi.cursor.showRemoteCursor = true; - } - } - - for (final how in ['mobile', 'remote cursor shown']) { - test('keeps the images of two animations and the everyday set ($how)', - () async { - painted(how); - final cursor = ffi.cursorModel; - final shapes = [ - for (var i = 0; i < 12; i++) 's$i', - for (var i = 0; i < 23; i++) 'wait$i', - for (var i = 0; i < 23; i++) 'progress$i', - ]; - for (var i = 0; i < shapes.length; i++) { - await _feed(ffi, shapes[i], seed: i); - } - for (final id in shapes) { - _select(ffi, id); - expect(cursor.shapeIds.last, id, reason: 'its image is at hand'); - } - await _settle(); - expect(ffi.cursor.fetched, isEmpty); - }); - } - - test('keeps the images used last, and decodes the others again', () async { - painted('mobile'); - const max = CursorModel.kRecentShapes; - final cursor = ffi.cursorModel; - for (var i = 0; i <= max; i++) { - await _feed(ffi, '$i'); - } - expect(cursor.shapeIds.length, max); - _select(ffi, '$max'); - _select(ffi, '1'); - await _settle(); - expect(ffi.cursor.fetched, isEmpty, reason: 'shape 1 was used lately'); - _select(ffi, '0'); - expect(cursor.image, isNotNull, reason: 'shape 1 until 0 is back'); - await _settle(); - expect(ffi.cursor.fetched, ['0']); - expect(cursor.shapeIds.length, max); - }); - - test('keeps a shape that finished decoding after the peer moved on', - () async { - painted('mobile'); - const max = CursorModel.kRecentShapes; - final cursor = ffi.cursorModel; - for (var i = 0; i <= max; i++) { - await _feed(ffi, '$i'); - } - _select(ffi, '0'); - cursor.image; // let go, so it is asked for - await Future.delayed(Duration.zero); - _select(ffi, '1'); - await _settle(); - _select(ffi, '0'); - cursor.image; - await _settle(); - expect(ffi.cursor.fetched, ['0'], reason: 'the late one was kept'); - expect(cursor.shapeIds.last, '0'); - }); - }); } diff --git a/flutter/test/cursor_size_test.dart b/flutter/test/cursor_size_test.dart index 254fe4766..ecb941db9 100644 --- a/flutter/test/cursor_size_test.dart +++ b/flutter/test/cursor_size_test.dart @@ -108,17 +108,11 @@ void main() { binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null)); for (final scenario in [ ((4, 64), 0.5, (2, 32)), - ((64, 4), 0.5, (32, 2)), - ((1, 64), 0.25, (1, 16)), ((8, 8), 0.5, (12, 12)), - ((4, 64), 1.0, (4, 64)), - ((1, 512), 10.0, (1, 512)), ]) { test('native cursor size $scenario', () => _checkSize(scenario, registrations)); } - test('native raster boundaries rebuild buffers and distinguish cache keys', - () => _checkRasterTransitions(registrations)); test('cursor resize limits preserve the last valid raster', () async { for (final size in [(30, 20), (20, 30)]) { await _checkResizeLimits(size, registrations); @@ -132,15 +126,15 @@ void main() { test('Windows peer cursor alpha survives resizing $pattern', () => _checkWindowsPeerAlpha(pattern, registrations)); } - for (final style in [ - kRemoteViewStyleOriginal, - kRemoteViewStyleAdaptive, - kRemoteViewStyleCustom + for (final (style, zoom) in [ + (kRemoteViewStyleOriginal, false), + (kRemoteViewStyleAdaptive, false), + (kRemoteViewStyleAdaptive, true), + (kRemoteViewStyleCustom, false), + (kRemoteViewStyleCustom, true), ]) { - for (final zoom in [false, true]) { - testWidgets('$style zoom=$zoom follows the live DPR and peer scale', - (tester) => _checkView(tester, (style, zoom), registrations)); - } + testWidgets('$style zoom=$zoom follows the live DPR and peer scale', + (tester) => _checkView(tester, (style, zoom), registrations)); } } @@ -241,31 +235,6 @@ void _expectSize(Map args, (int, int) expected) { } } -Future _checkRasterTransitions( - List> registrations) async { - const delta = 3e-8; - const scaleAboveOne = (2.25 / 1.75) / 2.25 * 1.75; - final ffi = _FFI(_Canvas(kRemoteViewStyleAdaptive)); - final cursor = _Cursor(_data((64, 64)), ffi); - addTearDown(() => _dispose(cursor)); - addTearDown(ffi.canvasModel.dispose); - for (final (scale, expected) in [ - (scaleAboveOne, (65, 65)), - (0.5 - delta, (32, 32)), - (0.5 + delta, (33, 33)), - (1.0, (64, 64)), - (scaleAboveOne, (65, 65)), - (1.0, (64, 64)), - ]) { - buildCursorOfCache(cursor, scale, cursor.cache); - await Future.delayed(Duration.zero); - final key = cursor.nativeKey(cursor.cache, scale); - _expectSize( - registrations.singleWhere((args) => args['name'] == key), expected); - } - expect(registrations.length, 4); -} - Future _checkResizeLimits( (int, int) size, List> registrations) async { const maxSide = 512; diff --git a/flutter/test/file_model_test.dart b/flutter/test/file_model_test.dart index 9455f2cab..c56fbcd7f 100644 --- a/flutter/test/file_model_test.dart +++ b/flutter/test/file_model_test.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:convert'; import 'package:flutter_hbb/models/file_model.dart'; @@ -62,19 +61,6 @@ void main() { expect(directory.path, '/fast'); }); - test('a send failure fails and removes its registered task', () async { - final failure = StateError('send failed'); - final fileFetcher = FileFetcher( - () => _sessionId, - readRemoteDirectory: (_, __, ___) => Future.error(failure), - ); - await expectLater( - fileFetcher.fetchDirectory('/failed', false, false), - throwsA(same(failure)), - ); - expect(fileFetcher.hasPendingRemoteRead('/failed'), isFalse); - }); - test('a resolved Home path completes the sole empty-path request', () async { final sent = <_SentRead>[]; final fileFetcher = FileFetcher( @@ -107,35 +93,6 @@ void main() { expect(controller.directory.value.path, '/home/user'); }); - test('an exact path response is not taken by a pending Home request', - () async { - final sent = <_SentRead>[]; - final fileFetcher = FileFetcher( - () => _sessionId, - readRemoteDirectory: (_, path, includeHidden) async { - sent.add(_SentRead(path, includeHidden)); - }, - ); - final home = fileFetcher.fetchDirectory('', false, false); - final regular = fileFetcher.fetchDirectory('/regular', false, false); - await Future.delayed(Duration.zero); - var homeCompleted = false; - home.then((_) => homeCompleted = true); - - fileFetcher.tryCompleteTask(_directoryJson('/unmatched'), 'false'); - await Future.delayed(Duration.zero); - expect(homeCompleted, isFalse); - - fileFetcher.tryCompleteTask(_directoryJson('/regular'), 'false'); - expect((await regular).path, '/regular'); - await Future.delayed(Duration.zero); - expect(homeCompleted, isFalse); - - fileFetcher.tryCompleteTask(_directoryJson('/home/user'), 'false'); - expect((await home).path, '/home/user'); - expect(sent.map((request) => request.path), ['', '/regular']); - }); - test('a read error completes the sole pending request', () async { final fileFetcher = FileFetcher( () => _sessionId, @@ -230,35 +187,6 @@ void main() { expect((await replacement).path, '/same'); }); - test('a late dispatch failure cannot remove a replacement task', () async { - final dispatches = >[]; - final fileFetcher = FileFetcher( - () => _sessionId, - readRemoteDirectory: (_, __, ___) { - final dispatch = Completer(); - dispatches.add(dispatch); - return dispatch.future; - }, - ); - final first = fileFetcher.fetchDirectory('/same', false, false); - await Future.delayed(Duration.zero); - final firstError = expectLater(first, throwsA(isA())); - fileFetcher.beginRemoteSession(); - await firstError; - - final replacement = fileFetcher.fetchDirectory('/same', false, false); - await Future.delayed(Duration.zero); - expect(dispatches, hasLength(2)); - dispatches.first.completeError(StateError('late dispatch failure')); - await Future.delayed(Duration.zero); - - expect(fileFetcher.hasPendingRemoteRead('/same'), isTrue); - fileFetcher.tryCompleteTask(_directoryJson('/same'), 'false'); - expect((await replacement).path, '/same'); - dispatches.last.complete(); - await Future.delayed(Duration.zero); - }); - test('navigation ignores stale directory responses', () async { final fileFetcher = FileFetcher( () => _sessionId, diff --git a/flutter/test/input_modifier_utils_test.dart b/flutter/test/input_modifier_utils_test.dart index bebf8d8af..4f5fb2af9 100644 --- a/flutter/test/input_modifier_utils_test.dart +++ b/flutter/test/input_modifier_utils_test.dart @@ -4,19 +4,6 @@ import 'package:flutter_hbb/models/input_modifier_utils.dart'; void main() { group('shouldReleaseStaleMobileShift', () { - test('does not release when cached shift is already false', () { - expect( - shouldReleaseStaleMobileShift( - isMobile: true, - cachedShiftPressed: false, - actualShiftPressed: false, - logicalKey: LogicalKeyboardKey.keyD, - hasTrackedShiftKeyDown: true, - ), - isFalse, - ); - }); - test('releases one-shot mobile shift after a text key', () { expect( shouldReleaseStaleMobileShift( @@ -30,20 +17,6 @@ void main() { ); }); - test('does not release manually toggled shift without tracked key down', - () { - expect( - shouldReleaseStaleMobileShift( - isMobile: true, - cachedShiftPressed: true, - actualShiftPressed: false, - logicalKey: LogicalKeyboardKey.keyD, - hasTrackedShiftKeyDown: false, - ), - isFalse, - ); - }); - test('does not release when shift is still physically pressed', () { expect( shouldReleaseStaleMobileShift( @@ -56,84 +29,9 @@ void main() { isFalse, ); }); - - test('does not release on non-mobile platforms', () { - expect( - shouldReleaseStaleMobileShift( - isMobile: false, - cachedShiftPressed: true, - actualShiftPressed: false, - logicalKey: LogicalKeyboardKey.keyD, - hasTrackedShiftKeyDown: true, - ), - isFalse, - ); - }); - - test('releases on enter key', () { - expect( - shouldReleaseStaleMobileShift( - isMobile: true, - cachedShiftPressed: true, - actualShiftPressed: false, - logicalKey: LogicalKeyboardKey.enter, - hasTrackedShiftKeyDown: true, - ), - isTrue, - ); - }); - - test('releases on arrow key', () { - expect( - shouldReleaseStaleMobileShift( - isMobile: true, - cachedShiftPressed: true, - actualShiftPressed: false, - logicalKey: LogicalKeyboardKey.arrowLeft, - hasTrackedShiftKeyDown: true, - ), - isTrue, - ); - }); - - test('does not release on modifier events', () { - expect( - shouldReleaseStaleMobileShift( - isMobile: true, - cachedShiftPressed: true, - actualShiftPressed: false, - logicalKey: LogicalKeyboardKey.shiftLeft, - hasTrackedShiftKeyDown: true, - ), - isFalse, - ); - }); - - test('does not release on shiftRight modifier events', () { - expect( - shouldReleaseStaleMobileShift( - isMobile: true, - cachedShiftPressed: true, - actualShiftPressed: false, - logicalKey: LogicalKeyboardKey.shiftRight, - hasTrackedShiftKeyDown: true, - ), - isFalse, - ); - }); }); group('shouldApplyTerminalInputModifiers', () { - test('accepts ordinary single-character keyboard input', () { - expect(shouldApplyTerminalInputModifiers('a'), isTrue); - expect(shouldApplyTerminalInputModifiers(' '), isTrue); - expect(shouldApplyTerminalInputModifiers('/'), isTrue); - }); - - test('accepts supplementary-plane single-character keyboard input', () { - expect(shouldApplyTerminalInputModifiers('šŸ˜€'), isTrue); - }); - test('rejects terminal control bytes and multi-character sequences', () { for (final input in ['\x00', '\x03', '\t', '\n', '\r', '\x1B', '\x7F']) { expect( @@ -147,43 +45,6 @@ void main() { }); group('applyTerminalInputModifiers', () { - test('keeps decomposed graphemes intact under Ctrl', () { - const decomposedEAcute = 'e\u0301'; - - expect( - applyTerminalInputModifiers( - decomposedEAcute, - ctrlLocked: true, - altLocked: false, - ), - decomposedEAcute, - ); - }); - - test('keeps non-ASCII graphemes intact under Ctrl', () { - for (final input in ['Ć©', 'šŸ˜€']) { - expect( - applyTerminalInputModifiers( - input, - ctrlLocked: true, - altLocked: false, - ), - input, - ); - } - }); - - test('maps Ctrl underscore to unit separator', () { - expect( - applyTerminalInputModifiers( - '_', - ctrlLocked: true, - altLocked: false, - ), - '\x1F', - ); - }); - test('maps the complete Ctrl symbol range', () { const mappings = { '[': '\x1B', @@ -219,22 +80,6 @@ void main() { }); }); - group('terminalPastePayload', () { - test('wraps paste text when bracketed paste mode is active', () { - expect( - terminalPastePayload('d', bracketedPasteMode: true), - '\x1B[200~d\x1B[201~', - ); - }); - - test('keeps a lone newline unchanged when bracketed paste is disabled', () { - expect( - terminalPastePayload('\n', bracketedPasteMode: false), - '\n', - ); - }); - }); - group('prepareTerminalInputPayload', () { test('normalizes a mobile keyboard Enter to carriage return', () { expect( @@ -278,26 +123,9 @@ void main() { ); }); - test('keeps large keyboard payloads unchanged when modifiers are inactive', - () { - final payload = 'd' * (1024 * 1024); - - expect( - prepareTerminalInputPayload( - payload, - source: TerminalInputSource.keyboard, - isMobileOrWebMobile: false, - bracketedPasteMode: false, - ctrlLocked: false, - altLocked: false, - ), - payload, - ); - }); - test('keeps decomposed graphemes intact with locked keyboard modifiers', () { - const decomposedEAcute = 'e\u0301'; + const decomposedEAcute = 'é'; expect( prepareTerminalInputPayload( @@ -312,20 +140,6 @@ void main() { ); }); - test('preserves a lone pasted newline when modifiers are locked', () { - expect( - prepareTerminalInputPayload( - '\n', - source: TerminalInputSource.paste, - isMobileOrWebMobile: true, - bracketedPasteMode: false, - ctrlLocked: true, - altLocked: true, - ), - '\n', - ); - }); - test('wraps paste without applying locked modifiers', () { expect( prepareTerminalInputPayload( @@ -373,25 +187,6 @@ void main() { ); }); - test( - 'keeps default xterm paste behavior when virtual modifiers are inactive', - () { - expect( - shouldHandleTerminalPasteShortcut( - platform: TargetPlatform.windows, - logicalKey: LogicalKeyboardKey.keyV, - isKeyDown: true, - isKeyRepeat: false, - controlPressed: true, - metaPressed: false, - altPressed: false, - shiftPressed: false, - modifierLockActive: false, - ), - isFalse, - ); - }); - test('handles Ctrl+V and Meta+V when a virtual modifier lock is active', () { expect( @@ -423,95 +218,6 @@ void main() { isTrue, ); }); - - test('handles paste shortcut repeats while a virtual lock is active', () { - expect( - shouldHandleTerminalPasteShortcut( - platform: TargetPlatform.windows, - logicalKey: LogicalKeyboardKey.keyV, - isKeyDown: false, - isKeyRepeat: true, - controlPressed: true, - metaPressed: false, - altPressed: false, - shiftPressed: false, - modifierLockActive: true, - ), - isTrue, - ); - }); - - test('ignores key-up and unmodified V events', () { - expect( - shouldHandleTerminalPasteShortcut( - platform: TargetPlatform.windows, - logicalKey: LogicalKeyboardKey.keyV, - isKeyDown: false, - isKeyRepeat: false, - controlPressed: true, - metaPressed: false, - altPressed: false, - shiftPressed: false, - modifierLockActive: true, - ), - isFalse, - ); - expect( - shouldHandleTerminalPasteShortcut( - platform: TargetPlatform.windows, - logicalKey: LogicalKeyboardKey.keyV, - isKeyDown: true, - isKeyRepeat: false, - controlPressed: false, - metaPressed: false, - altPressed: false, - shiftPressed: false, - modifierLockActive: true, - ), - isFalse, - ); - }); - - test('ignores paste shortcuts with extra modifiers', () { - for (final state in [ - (control: true, meta: false, alt: true, shift: false), - (control: true, meta: false, alt: false, shift: true), - (control: false, meta: true, alt: false, shift: true), - (control: true, meta: true, alt: false, shift: false), - ]) { - expect( - shouldHandleTerminalPasteShortcut( - platform: TargetPlatform.windows, - logicalKey: LogicalKeyboardKey.keyV, - isKeyDown: true, - isKeyRepeat: false, - controlPressed: state.control, - metaPressed: state.meta, - altPressed: state.alt, - shiftPressed: state.shift, - modifierLockActive: true, - ), - isFalse, - ); - } - }); - - test('ignores non-V key events', () { - expect( - shouldHandleTerminalPasteShortcut( - platform: TargetPlatform.windows, - logicalKey: LogicalKeyboardKey.keyC, - isKeyDown: true, - isKeyRepeat: false, - controlPressed: true, - metaPressed: false, - altPressed: false, - shiftPressed: false, - modifierLockActive: true, - ), - isFalse, - ); - }); }); group('shouldClearTerminalModifiersWhenRow3Collapses', () { @@ -526,29 +232,5 @@ void main() { isTrue, ); }); - - test('does not clear modifiers when row expands', () { - expect( - shouldClearTerminalModifiersWhenRow3Collapses( - wasExpanded: false, - willExpand: true, - ctrlLocked: true, - altLocked: true, - ), - isFalse, - ); - }); - - test('clears Alt state when expanded row is collapsed', () { - expect( - shouldClearTerminalModifiersWhenRow3Collapses( - wasExpanded: true, - willExpand: false, - ctrlLocked: false, - altLocked: true, - ), - isTrue, - ); - }); }); } diff --git a/flutter/test/terminal_alt_screen_scroll_test.dart b/flutter/test/terminal_alt_screen_scroll_test.dart index 7ed8ebc81..d8e37ee9e 100644 --- a/flutter/test/terminal_alt_screen_scroll_test.dart +++ b/flutter/test/terminal_alt_screen_scroll_test.dart @@ -50,54 +50,20 @@ void main() { await tester.pump(); } - for (final trackpad in [false, true]) { - final device = trackpad ? 'trackpad' : 'wheel'; - testWidgets('alternate screen reports $device after main-screen history', - (tester) async { - final view = await mount(tester); - terminal.write('\x1b[?1049h$_captureMouse'); - await tester.pump(); - final render = view.renderTerminal; - final point = render.localToGlobal( - Offset(render.cellSize.width * 2.5, render.lineHeight * 1.5)); - if (trackpad) { - await scrollTrackpad(tester, point, render.lineHeight * 3); - } else { - await tester.sendEventToBinding(PointerScrollEvent( - position: point, - scrollDelta: Offset(0, -render.lineHeight * 3), - )); - await tester.pump(); - } - - expect(output, isNotEmpty); - expect(output, everyElement('\x1b[<64;3;2M')); - expect(view.widget.scrollController!.offset, 0); - expect(controller.selection, isNull); - }, variant: TargetPlatformVariant.only(TargetPlatform.macOS)); - } - - testWidgets('trackpad scrolling keeps subsequent drag coordinates aligned', + testWidgets('alternate screen reports trackpad after main-screen history', (tester) async { final view = await mount(tester); terminal.write('\x1b[?1049h$_captureMouse'); await tester.pump(); final render = view.renderTerminal; - final start = render.localToGlobal( + final point = render.localToGlobal( Offset(render.cellSize.width * 2.5, render.lineHeight * 1.5)); - final end = render.localToGlobal( - Offset(render.cellSize.width * 6.5, render.lineHeight * 2.5)); - await scrollTrackpad(tester, start, render.lineHeight * 3); - output.clear(); - final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); - await mouse.down(start); - await mouse.moveTo(end); - await mouse.up(); - await tester.pump(kDoubleTapTimeout); + await scrollTrackpad(tester, point, render.lineHeight * 3); - expect(output, ['\x1b[<0;3;2M', '\x1b[<32;7;3M', '\x1b[<0;7;3m']); + expect(output, isNotEmpty); + expect(output, everyElement('\x1b[<64;3;2M')); + expect(view.widget.scrollController!.offset, 0); expect(controller.selection, isNull); - expect(controller.suspendedPointerInputs, isFalse); }, variant: TargetPlatformVariant.only(TargetPlatform.macOS)); testWidgets('leaving alternate screen restores local trackpad scrollback', diff --git a/flutter/test/terminal_keyboard_utils_test.dart b/flutter/test/terminal_keyboard_utils_test.dart index c93a42413..a62c66189 100644 --- a/flutter/test/terminal_keyboard_utils_test.dart +++ b/flutter/test/terminal_keyboard_utils_test.dart @@ -17,24 +17,5 @@ void main() { ['Ctrl', 'Alt', '-', 'PgUp', 'PgDn'], ); }); - - test('keeps two trailing Row3 placeholders for row alignment', () { - expect(terminalKeyboardRow3TrailingPlaceholderCount, 2); - }); - - test('keeps every expanded row aligned at 348dp', () { - final rowWidths = [ - terminalKeyboardRowWidth(terminalKeyboardRow1Keys.length), - terminalKeyboardRowWidth(terminalKeyboardRow2Keys.length + 1), - terminalKeyboardRowWidth( - terminalKeyboardRow3Keys.length + - terminalKeyboardRow3TrailingPlaceholderCount, - ), - ]; - - expect(terminalKeyboardKeyWidth, 48); - expect(terminalKeyboardKeySpacing, 2); - expect(rowWidths, everyElement(348)); - }); }); } diff --git a/flutter/test/terminal_model_lifecycle_test.dart b/flutter/test/terminal_model_lifecycle_test.dart index 5581886b7..770eeb9f1 100644 --- a/flutter/test/terminal_model_lifecycle_test.dart +++ b/flutter/test/terminal_model_lifecycle_test.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter_hbb/models/model.dart'; import 'package:flutter_hbb/models/terminal_model.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -14,42 +12,6 @@ class _FakeFFI implements FFI { } void main() { - test('ignores paste that completes after the terminal model is disposed', - () async { - final model = TerminalModel(_FakeFFI()); - final delayedClipboardText = Completer(); - - // This mirrors Ctrl/Cmd+V: clipboard access starts first, then the page and - // model are disposed before the asynchronous read supplies its text. - final paste = delayedClipboardText.future.then(model.pasteText); - model.dispose(); - delayedClipboardText.complete('late clipboard text'); - await paste; - - expect(model.debugBufferedInputCount, 0); - }); - - test('ignores terminal text input after the terminal model is disposed', () { - final model = TerminalModel(_FakeFFI()); - var checkedCtrlLock = false; - var clearedCtrlLock = false; - - model.isCtrlLocked = () { - checkedCtrlLock = true; - return true; - }; - model.clearCtrlLock = () { - clearedCtrlLock = true; - }; - - model.dispose(); - model.terminal.textInput('d'); - - expect(checkedCtrlLock, isFalse); - expect(clearedCtrlLock, isFalse); - expect(model.debugBufferedInputCount, 0); - }); - test('builds its terminal with the wheel button fix', () { final model = TerminalModel(_FakeFFI()); addTearDown(model.dispose); diff --git a/flutter/test/terminal_mouse_handler_test.dart b/flutter/test/terminal_mouse_handler_test.dart index 04af45250..3e3046dc3 100644 --- a/flutter/test/terminal_mouse_handler_test.dart +++ b/flutter/test/terminal_mouse_handler_test.dart @@ -71,10 +71,9 @@ void main() { String? report( TerminalMouseButton button, [ TerminalMouseButtonState state = TerminalMouseButtonState.down, - CellOffset position = const CellOffset(10, 5), ]) { output.clear(); - terminal.mouseInput(button, state, position); + terminal.mouseInput(button, state, const CellOffset(10, 5)); return output.isEmpty ? null : output.single; } @@ -102,40 +101,6 @@ void main() { ); }); - test('reports utf-encoding wheel buttons beyond the normal-mode range', () { - terminal.write('\x1b[?1000h\x1b[?1005h'); - - expect( - report( - TerminalMouseButton.wheelDown, - TerminalMouseButtonState.down, - const CellOffset(400, 300), - ), - '\x1b[M${String.fromCharCode(32 + 65)}' - '${String.fromCharCode(32 + 401)}${String.fromCharCode(32 + 301)}', - ); - }); - - test('reports urxvt-encoding wheel buttons shifted by 32', () { - terminal.write('\x1b[?1000h\x1b[?1015h'); - - expect(report(TerminalMouseButton.wheelUp), '\x1b[96;11;6M'); - expect(report(TerminalMouseButton.wheelDown), '\x1b[97;11;6M'); - }); - - test('sends a null byte for coordinates past the encoding limit', () { - terminal.write('\x1b[?1000h'); - - expect( - report( - TerminalMouseButton.wheelUp, - TerminalMouseButtonState.down, - const CellOffset(300, 300), - ), - '\x1b[M${String.fromCharCode(32 + 64)}\x00\x00', - ); - }); - test('leaves non-wheel buttons to the upstream handler', () { terminal.write('\x1b[?1000h\x1b[?1006h'); @@ -147,27 +112,6 @@ void main() { ); }); - test('stays silent when the peer has not enabled mouse reporting', () { - expect(report(TerminalMouseButton.wheelDown), isNull); - expect(report(TerminalMouseButton.left), isNull); - }); - - test('stays silent for the wheel in click-only mode', () { - terminal.write('\x1b[?9h\x1b[?1006h'); - - expect(report(TerminalMouseButton.wheelDown), isNull); - expect(report(TerminalMouseButton.left), '\x1b[<0;11;6M'); - }); - - test('does not report wheel button releases', () { - terminal.write('\x1b[?1000h\x1b[?1006h'); - - expect( - report(TerminalMouseButton.wheelDown, TerminalMouseButtonState.up), - isNull, - ); - }); - testWidgets('dragging below scrolls and extends selection', (tester) async { final controller = TerminalController(); _writeLines(terminal, 80); @@ -258,42 +202,4 @@ void main() { expect(output.last, '\x1b[<0;${endCell.x + 1};${endCell.y + 1}m'); expect(controller.suspendedPointerInputs, isFalse); }); - - testWidgets('tmux drag stays suppressed after mouse mode is disabled', - (tester) async { - final controller = TerminalController(); - terminal.write('\x1b[?1049h\x1b[?1002h\x1b[?1006hword'); - await tester.pumpWidget(_terminalHarness(terminal, controller)); - final renderTerminal = tester - .state(find.byType(TerminalView)) - .renderTerminal; - final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); - final start = renderTerminal.localToGlobal(const Offset(40, 40)); - final end = renderTerminal.localToGlobal(const Offset(240, 80)); - await mouse.down(start); - output.clear(); - terminal.write('\x1b[?1002l'); - await mouse.moveTo(end); - - expect(output, isEmpty); - expect(controller.selection, isNull); - expect(controller.suspendedPointerInputs, isTrue); - terminal.write('\x1b[?1002h'); - await mouse.moveTo(start); - expect(output, isEmpty); - expect(controller.selection, isNull); - await mouse.up(); - expect(output, isEmpty); - expect(controller.suspendedPointerInputs, isFalse); - - await mouse.down(start); - output.clear(); - terminal.write('\x1b[?1002l'); - await mouse.up(); - await tester.pump(kDoubleTapTimeout); - - expect(output, isEmpty); - expect(controller.selection, isNull); - expect(controller.suspendedPointerInputs, isFalse); - }); } diff --git a/libs/base/src/fs.rs b/libs/base/src/fs.rs index 1adb59077..ae481fc44 100644 --- a/libs/base/src/fs.rs +++ b/libs/base/src/fs.rs @@ -1677,15 +1677,6 @@ mod tests { assert_err_contains(err, "empty file name"); } - #[test] - fn set_files_rejects_null_byte_name() { - let mut job = new_validation_job(103); - let err = job - .set_files(vec![new_file_entry("bad\0name.txt")]) - .expect_err("null byte in file name must be rejected"); - assert_err_contains(err, "null bytes"); - } - #[test] fn set_files_rejects_mixed_entries_when_one_is_traversal() { let mut job = new_validation_job(104); @@ -1708,39 +1699,6 @@ mod tests { assert_err_contains(err, "absolute path"); } - #[cfg(not(windows))] - #[test] - fn set_files_allows_backslash_prefixed_name_on_unix() { - let mut job = new_validation_job(105); - assert!(job - .set_files(vec![new_file_entry("\\\\server\\share\\payload.txt")]) - .is_ok()); - } - - #[test] - fn remove_file_rejects_empty_path() { - let err = remove_file("").expect_err("empty file path must be rejected"); - assert_err_contains(err, "cannot be empty"); - } - - #[test] - fn remove_file_rejects_null_byte_path() { - let err = remove_file("bad\0path").expect_err("null byte path must be rejected"); - assert_err_contains(err, "null bytes"); - } - - #[test] - fn create_dir_rejects_empty_path() { - let err = create_dir("").expect_err("empty directory path must be rejected"); - assert_err_contains(err, "cannot be empty"); - } - - #[test] - fn create_dir_rejects_null_byte_path() { - let err = create_dir("bad\0path").expect_err("null byte path must be rejected"); - assert_err_contains(err, "null bytes"); - } - #[test] fn rename_file_rejects_invalid_new_name() { let tmp_root = TestTempDir::new("rustdesk_rename_invalid"); @@ -1790,24 +1748,4 @@ mod tests { assert!(!src.exists()); assert!(dst.exists()); } - - #[cfg(windows)] - #[test] - fn set_files_rejects_windows_drive_absolute_path() { - let mut job = new_validation_job(106); - let err = job - .set_files(vec![new_file_entry("C:\\Windows\\Temp\\payload.txt")]) - .expect_err("drive-letter absolute path must be rejected"); - assert_err_contains(err, "absolute path"); - } - - #[cfg(windows)] - #[test] - fn set_files_rejects_windows_verbatim_drive_absolute_path() { - let mut job = new_validation_job(1061); - let err = job - .set_files(vec![new_file_entry(r"\\?\C:\Windows\Temp\x.txt")]) - .expect_err("verbatim drive absolute path must be rejected"); - assert_err_contains(err, "absolute path"); - } } diff --git a/libs/base/src/platform/linux.rs b/libs/base/src/platform/linux.rs index aa1228ca6..bc1492beb 100644 --- a/libs/base/src/platform/linux.rs +++ b/libs/base/src/platform/linux.rs @@ -515,48 +515,6 @@ pub fn get_home_dir_trusted() -> Option { mod tests { use super::*; - #[test] - fn test_transform_degrees_maps_all_eight_variants() { - use sctk::reexports::client::protocol::wl_output::Transform; - // Flipped variants report their rotation: the frame still needs that turn to read - // upright, and the mirror half has no producer among desktop compositors to test. - for (t, deg) in [ - (Transform::Normal, 0), - (Transform::_90, 90), - (Transform::_180, 180), - (Transform::_270, 270), - (Transform::Flipped, 0), - (Transform::Flipped90, 90), - (Transform::Flipped180, 180), - (Transform::Flipped270, 270), - ] { - assert_eq!(transform_degrees(t), deg, "{t:?}"); - } - } - - #[test] - fn test_display_info_without_transform_defaults_to_zero() { - // A snapshot serialized by an older probe child carries no transform field; it must - // deserialize with 0 rather than fail, or a greeter-side child update becomes a - // lockstep upgrade. - let old = r#"{"name":"HDMI-1","x":0,"y":0,"width":1920,"height":1080,"logical_size":null,"refresh_rate":60}"#; - let info: WaylandDisplayInfo = serde_json::from_str(old).unwrap(); - assert_eq!(info.transform, 0); - let roundtrip: WaylandDisplayInfo = - serde_json::from_str(&serde_json::to_string(&info).unwrap()).unwrap(); - assert_eq!(roundtrip.transform, 0); - } - - #[test] - fn test_run_cmds_trim_newline() { - assert_eq!(run_cmds_trim_newline("echo -n 123").unwrap(), "123"); - assert_eq!(run_cmds_trim_newline("echo 123").unwrap(), "123"); - assert_eq!( - run_cmds_trim_newline("whoami").unwrap() + "\n", - run_cmds("whoami").unwrap() - ); - } - /// Test get_home_dir_trusted: returns valid path and ignores HOME env var #[test] fn test_get_home_dir_trusted() { @@ -584,20 +542,6 @@ mod tests { } } - /// Test shell_quote with normal strings - #[test] - fn test_shell_quote_normal() { - assert_eq!(shell_quote("hello"), "'hello'"); - assert_eq!(shell_quote("/home/user"), "'/home/user'"); - } - - /// Test shell_quote with spaces - #[test] - fn test_shell_quote_spaces() { - assert_eq!(shell_quote("/home/my user/file"), "'/home/my user/file'"); - assert_eq!(shell_quote("path with spaces"), "'path with spaces'"); - } - /// Test shell_quote with single quotes (the tricky case) #[test] fn test_shell_quote_single_quotes() { diff --git a/libs/clipboard/src/lib.rs b/libs/clipboard/src/lib.rs index 5ce9afe28..a28c6da70 100644 --- a/libs/clipboard/src/lib.rs +++ b/libs/clipboard/src/lib.rs @@ -288,11 +288,3 @@ fn send_data_to_all(data: ClipboardFile) { allow_err!(msg_channel.sender.send(data.clone())); } } - -#[cfg(test)] -mod tests { - // #[test] - // fn test_cliprdr_run() { - // super::cliprdr_run(); - // } -} diff --git a/libs/clipboard/src/platform/unix/filetype.rs b/libs/clipboard/src/platform/unix/filetype.rs index 408daaecc..08014384f 100644 --- a/libs/clipboard/src/platform/unix/filetype.rs +++ b/libs/clipboard/src/platform/unix/filetype.rs @@ -244,8 +244,6 @@ mod tests { const ATTRIBUTES_OFFSET: usize = PDU_HEADER_SIZE + 36; const LAST_WRITE_TIME_OFFSET: usize = PDU_HEADER_SIZE + 56; const NAME_OFFSET: usize = PDU_HEADER_SIZE + 72; - const FILE_NAME_CODE_UNITS: usize = 260; - const INVALID_UTF16_UNIT: u16 = 0xdc00; const FILE_ATTRIBUTE_NORMAL: u32 = 0x80; fn descriptor_pdu(name: &str) -> Vec { @@ -291,18 +289,6 @@ mod tests { assert_eq!(files[0].name, PathBuf::from("folder/nested/file.txt")); } - #[test] - fn ignores_data_after_null_terminator() { - let name = "file.txt"; - let mut pdu = descriptor_pdu(name); - let padding_offset = NAME_OFFSET + (name.encode_utf16().count() + 1) * size_of::(); - pdu[padding_offset..padding_offset + size_of::()] - .copy_from_slice(&INVALID_UTF16_UNIT.to_le_bytes()); - - let files = FileDescription::parse_file_descriptors(pdu, 0).unwrap(); - assert_eq!(files[0].name, PathBuf::from("file.txt")); - } - #[test] fn file_list_id_is_fnv1a() { // Reference vectors; the Windows side computes the same id in C. @@ -319,15 +305,6 @@ mod tests { assert_eq!(files[0].clip_data_id, id); } - #[test] - fn rejects_non_terminated_file_name() { - let name = "a".repeat(FILE_NAME_CODE_UNITS); - assert!(matches!( - parse_name(&name), - Err(CliprdrError::InvalidRequest { .. }) - )); - } - fn parse_perm(attributes: u32) -> u16 { let mut pdu = descriptor_pdu("folder"); pdu[ATTRIBUTES_OFFSET..ATTRIBUTES_OFFSET + size_of::()] diff --git a/libs/clipboard/src/platform/unix/fuse/cs.rs b/libs/clipboard/src/platform/unix/fuse/cs.rs index dc15c55e3..8dd03939f 100644 --- a/libs/clipboard/src/platform/unix/fuse/cs.rs +++ b/libs/clipboard/src/platform/unix/fuse/cs.rs @@ -966,35 +966,6 @@ mod fuse_test { } } - fn build_single_file(prefix: &str) { - let raw_name = "simple_test_file.txt"; - let f_name = if prefix == "" { - raw_name.to_string() - } else { - prefix.to_string() + "/" + raw_name - }; - let desc = desc_gen(&f_name, FileType::File); - let tree = FuseNode::build_tree(vec![desc]).unwrap(); - - assert_eq!(tree.len(), 2); - assert_eq!(tree[0].name, "/"); - assert_eq!(tree[0].children, vec![2]); - - assert_eq!(tree[1].name, raw_name); - assert_eq!(tree[1].index, 0); - assert_eq!(tree[1].attributes.kind, FileType::File); - } - - #[test] - fn test_parse_single() { - build_single_file(""); - build_single_file("/"); - build_single_file("test"); - build_single_file("/test"); - build_single_file("šŸ—‚"); - build_single_file("/šŸ—‚"); - } - #[test] fn test_parse_tree() { build_tree(""); diff --git a/libs/clipboard/src/platform/unix/local_file.rs b/libs/clipboard/src/platform/unix/local_file.rs index 1aff129ed..ff3402608 100644 --- a/libs/clipboard/src/platform/unix/local_file.rs +++ b/libs/clipboard/src/platform/unix/local_file.rs @@ -322,7 +322,7 @@ pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result, C mod file_list_test { use std::{ path::{Path, PathBuf}, - sync::atomic::{AtomicU64, Ordering}, + sync::atomic::AtomicU64, time::{Duration, SystemTime}, }; @@ -466,32 +466,6 @@ mod file_list_test { Ok(()) } - #[test] - fn read_exact_at_reopens_after_read_failure() -> Result<(), Box> { - let file_path = std::env::temp_dir().join(format!( - "rustdesk-clipboard-local-file-{}", - std::process::id() - )); - std::fs::write(&file_path, b"")?; - - let mut file = LocalFile::try_open(&std::env::temp_dir(), &file_path)?; - file.size = 1; - - let mut buf = [0u8; 1]; - assert!(file.read_exact_at(&mut buf, 0).is_err()); - assert!(file.handle.is_none()); - assert_eq!(file.offset.load(Ordering::Relaxed), 0); - - std::fs::write(&file_path, [42u8])?; - - file.read_exact_at(&mut buf, 0)?; - assert_eq!(buf, [42u8]); - assert!(file.handle.is_none()); - - std::fs::remove_file(file_path)?; - Ok(()) - } - const LAST_WRITE_TIME_OFFSET: usize = 56; fn file_with_last_write_time(last_write_time: SystemTime) -> LocalFile { @@ -519,18 +493,4 @@ mod file_list_test { assert_eq!(encoded_last_write_time(time)?, 116_444_736_000_000_000); Ok(()) } - - #[test] - fn last_write_time_round_trips() -> Result<(), CliprdrError> { - let file = file_with_last_write_time( - SystemTime::UNIX_EPOCH + Duration::new(1_700_000_000, 123_456_700), - ); - let mut pdu = BytesMut::with_capacity(4 + FILE_DESCRIPTOR_SIZE); - pdu.put_u32_le(1); - pdu.put(file.as_bin()?.as_slice()); - - let parsed = FileDescription::parse_file_descriptors(pdu.to_vec(), 0)?; - assert_eq!(parsed[0].last_modified, file.last_write_time); - Ok(()) - } } diff --git a/libs/clipboard/src/platform/unix/macos/paste_task.rs b/libs/clipboard/src/platform/unix/macos/paste_task.rs index 043926c62..012eb79f9 100644 --- a/libs/clipboard/src/platform/unix/macos/paste_task.rs +++ b/libs/clipboard/src/platform/unix/macos/paste_task.rs @@ -809,18 +809,6 @@ mod tests { assert!(!Path::new(&task.progress.download_file_path).exists()); } - #[test] - fn finder_progress_rejects_unrepresentable_byte_count() { - let (_temp, target, _) = test_directories(); - let file = file_description("large.bin", FileType::File, i64::MAX as u64 + 1); - let mut task = paste_task_handle(target.clone(), vec![file]); - let error = task.update_next(0).expect_err("byte count must not wrap"); - assert!(matches!(&error, CliprdrError::InvalidRequest { .. })); - task.on_error(error); - assert!(task.progress.finder_progress.is_none()); - assert!(std::fs::read_dir(target).unwrap().next().is_none()); - } - #[test] fn validates_all_paths_before_creating_files() { let (_temp, target, outside) = test_directories(); @@ -866,18 +854,4 @@ mod tests { assert!(!download_path.exists()); assert!(!outside.join("file.txt").exists()); } - - #[test] - fn rejects_symlink_component_when_creating_directory() { - let (_temp, target, outside) = test_directories(); - symlink(&outside, target.join("link")).unwrap(); - - let directory = file_description("link/escaped", FileType::Directory, 0); - let mut task = paste_task_handle(target, vec![directory]); - assert!(matches!( - task.update_next(0), - Err(CliprdrError::InvalidRequest { .. }) - )); - assert!(!outside.join("escaped").exists()); - } } diff --git a/libs/clipboard/src/platform/unix/macos/pasteboard_context.rs b/libs/clipboard/src/platform/unix/macos/pasteboard_context.rs index 4c7474093..dbd9917d8 100644 --- a/libs/clipboard/src/platform/unix/macos/pasteboard_context.rs +++ b/libs/clipboard/src/platform/unix/macos/pasteboard_context.rs @@ -430,31 +430,3 @@ pub fn create_pasteboard_context() -> ResultType> { context.init(); Ok(context) } - -#[cfg(test)] -mod tests { - #[test] - fn test_temp_files_count() { - let mut c = super::PasteboardContext::temp_files_count(); - - let mut created_files = vec![]; - for _ in 0..10 { - let path = format!( - "/tmp/{}{}", - super::TEMP_FILE_PREFIX, - uuid::Uuid::new_v4().to_string() - ); - if std::fs::File::create(&path).is_ok() { - created_files.push(path); - c += 1; - } - } - - assert_eq!(c, super::PasteboardContext::temp_files_count()); - - // Clean up the created files. - for file in created_files { - std::fs::remove_file(&file).ok(); - } - } -} diff --git a/libs/clipboard/src/platform/unix/serv_files.rs b/libs/clipboard/src/platform/unix/serv_files.rs index 3fb73af53..d77506eeb 100644 --- a/libs/clipboard/src/platform/unix/serv_files.rs +++ b/libs/clipboard/src/platform/unix/serv_files.rs @@ -523,48 +523,6 @@ mod sig_test { } } - #[test] - fn fingerprint_missing_path_is_default() { - let tmp = TmpDir::new("missing"); - let missing = path_str(&tmp.join("does_not_exist.bin")); - let sigs = fingerprint(&[missing]); - assert_eq!(sigs.len(), 1); - // A path that can't be stat'd -> default sig, which forces a rebuild. - assert_eq!(sigs[0], FileSig::default()); - assert_eq!(sigs[0].mtime, None); - } - - #[test] - fn fingerprint_detects_inplace_edit() { - let tmp = TmpDir::new("edit"); - let file = tmp.join("a.bin"); - fs::write(&file, b"small").unwrap(); - let p = path_str(&file); - - let before = fingerprint(&[p.clone()]); - // Same content, same path: fingerprint must be stable. - let again = fingerprint(&[p.clone()]); - assert_eq!(before, again); - assert_eq!(before[0].size, 5); - assert!(!before[0].is_dir); - - // Edit in place so the file grows. - fs::write(&file, b"much larger contents than before").unwrap(); - let after = fingerprint(&[p]); - assert_ne!(before, after); - assert!(after[0].size > before[0].size); - } - - #[test] - fn fingerprint_flags_directory() { - let tmp = TmpDir::new("dir"); - let sub = tmp.join("subdir"); - fs::create_dir_all(&sub).unwrap(); - let sigs = fingerprint(&[path_str(&sub)]); - assert_eq!(sigs.len(), 1); - assert!(sigs[0].is_dir); - } - #[test] fn recopy_after_edit_refreshes_cached_size() { let tmp = TmpDir::new("recopy"); @@ -597,37 +555,6 @@ mod sig_test { clear_files(); // leave the global clean for other tests } - #[test] - fn range_request_huge_length_is_clamped() { - let tmp = TmpDir::new("range"); - let file = tmp.join("data.bin"); - fs::write(&file, b"0123456789").unwrap(); - let files = vec![path_str(&file)]; - - let mut clip = ClipFiles::default(); - clip.sync_files(&files, fingerprint(&files)).unwrap(); - let file_idx = clip.first_file_index; - - // offset + length used to wrap to 0, skip the clamp and ask for a u64::MAX buffer. - let resp = clip - .serve_file_contents( - 0, - FileContentsRequest::Range { - stream_id: 0, - file_idx, - offset: 1, - length: u64::MAX, - }, - ) - .unwrap(); - match resp { - ClipboardFile::FileContentsResponse { requested_data, .. } => { - assert_eq!(requested_data, b"123456789"); - } - _ => panic!("unexpected response"), - } - } - #[test] fn range_request_decodes_wire_fields_as_u32() { let tmp = TmpDir::new("decode"); @@ -756,172 +683,6 @@ mod sig_test { clear_files(); } - #[test] - fn failed_recopy_keeps_the_current_list() { - let tmp = TmpDir::new("failed_recopy"); - let first = tmp.join("first.bin"); - fs::write(&first, b"AAAAAAAA").unwrap(); - let missing = tmp.join("missing.bin"); - - let _guard = lock_clip_files(); - clear_files(); - sync_files(&[path_str(&first)]).unwrap(); - let pdu = get_file_list_pdu(1); - - assert!(sync_files(&[path_str(&missing)]).is_err()); - assert_eq!(get_file_list_pdu(1), pdu); - - clear_files(); - } - - fn file_index(path: &PathBuf) -> i32 { - let files = CLIP_FILES.lock(); - files - .file_list - .iter() - .position(|f| &f.path == path) - .unwrap() as i32 - } - - // Replaces `path` the way editors save: write aside, then rename over it. - fn replace_atomically(path: &PathBuf, data: &[u8]) { - let aside = path.with_extension("new"); - fs::write(&aside, data).unwrap(); - fs::rename(&aside, path).unwrap(); - } - - #[test] - fn retired_list_closes_only_files_not_being_read() { - let tmp = TmpDir::new("retired_handles"); - let first = tmp.join("first.bin"); - fs::write(&first, b"AAAABBBB").unwrap(); - let next = tmp.join("next.bin"); - fs::write(&next, b"CCCCDDDD").unwrap(); - let other = tmp.join("other.bin"); - fs::write(&other, b"EEEEEEEE").unwrap(); - - let _guard = lock_clip_files(); - clear_files(); - sync_files(&[path_str(&first), path_str(&next)]).unwrap(); - let id = file_list_id(&get_file_list_pdu(1)); - let (first_idx, next_idx) = (file_index(&first), file_index(&next)); - let read = - |idx, offset| range_data(read_file_contents(1, 7, idx, 0x2, offset, 0, 4, Some(id))); - // Leaves `first` open at offset 4, and preloads `next`. - assert_eq!(read(first_idx, 0), b"AAAA"); - - sync_files(&[path_str(&other)]).unwrap(); - { - let retired = RETIRED_CLIP_FILES.lock(); - let open = |idx: i32| retired[0].file_list[idx as usize].handle.is_some(); - assert!(open(first_idx)); - assert!(!open(next_idx)); - } - assert_eq!(read(first_idx, 4), b"BBBB"); - assert_eq!(read(next_idx, 0), b"CCCC"); - - clear_files(); - } - - #[test] - fn files_being_read_keep_their_content_when_their_paths_are_replaced() { - let tmp = TmpDir::new("replaced_paths"); - let first = tmp.join("first.bin"); - fs::write(&first, b"1111aaaa").unwrap(); - let second = tmp.join("second.bin"); - fs::write(&second, b"2222bbbb").unwrap(); - let other = tmp.join("other.bin"); - fs::write(&other, b"EEEEEEEE").unwrap(); - - let _guard = lock_clip_files(); - clear_files(); - sync_files(&[path_str(&first), path_str(&second)]).unwrap(); - let id = file_list_id(&get_file_list_pdu(1)); - let (first_idx, second_idx) = (file_index(&first), file_index(&second)); - let read = - |idx, offset| range_data(read_file_contents(1, 7, idx, 0x2, offset, 0, 4, Some(id))); - // Two files part-way through at once. - assert_eq!(read(first_idx, 0), b"1111"); - assert_eq!(read(second_idx, 0), b"2222"); - - sync_files(&[path_str(&other)]).unwrap(); - replace_atomically(&first, b"3333cccc"); - replace_atomically(&second, b"4444dddd"); - - assert_eq!(read(first_idx, 4), b"aaaa"); - assert_eq!(read(second_idx, 4), b"bbbb"); - - clear_files(); - } - - #[test] - fn same_file_metadata_in_another_folder_is_another_list() { - let tmp = TmpDir::new("same_metadata"); - let (dir_a, dir_b) = (tmp.join("a"), tmp.join("b")); - fs::create_dir_all(&dir_a).unwrap(); - fs::create_dir_all(&dir_b).unwrap(); - let (first, second) = (dir_a.join("report.bin"), dir_b.join("report.bin")); - fs::write(&first, b"AAAAAAAA").unwrap(); - fs::write(&second, b"BBBBBBBB").unwrap(); - // Same name, size and mtime: the descriptors differ only by the nonce. - let mtime = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000); - for path in [&first, &second] { - fs::File::options() - .write(true) - .open(path) - .unwrap() - .set_modified(mtime) - .unwrap(); - } - - let _guard = lock_clip_files(); - clear_files(); - sync_files(&[path_str(&first)]).unwrap(); - let first_id = file_list_id(&get_file_list_pdu(1)); - let idx = CLIP_FILES.lock().first_file_index as i32; - sync_files(&[path_str(&second)]).unwrap(); - let second_id = file_list_id(&get_file_list_pdu(1)); - assert_ne!(first_id, second_id); - - let read = |id| range_data(read_file_contents(1, 7, idx, 0x2, 4, 0, 4, Some(id))); - assert_eq!(read(first_id), b"AAAA"); - assert_eq!(read(second_id), b"BBBB"); - - clear_files(); - } - - #[test] - fn a_retired_list_refuses_a_file_replaced_before_its_read() { - let tmp = TmpDir::new("replaced_unread"); - let first = tmp.join("first.bin"); - fs::write(&first, b"AAAAAAAA").unwrap(); - let second = tmp.join("second.bin"); - fs::write(&second, b"BBBBBBBB").unwrap(); - let other = tmp.join("other.bin"); - fs::write(&other, b"EEEEEEEE").unwrap(); - - let _guard = lock_clip_files(); - clear_files(); - sync_files(&[path_str(&first), path_str(&second)]).unwrap(); - let id = file_list_id(&get_file_list_pdu(1)); - let (first_idx, second_idx) = (file_index(&first), file_index(&second)); - let read = |idx, offset| read_file_contents(1, 7, idx, 0x2, offset, 0, 4, Some(id)); - assert_eq!(range_data(read(first_idx, 0)), b"AAAA"); - - // Retiring closes `second`, which has not been read yet; it is then replaced. - sync_files(&[path_str(&other)]).unwrap(); - replace_atomically(&second, b"CCCCCCCCCC"); - - assert!(matches!( - read(second_idx, 0).last(), - Some(Err(CliprdrError::InvalidRequest { .. })) - )); - // The file part-way through is unaffected. - assert_eq!(range_data(read(first_idx, 4)), b"AAAA"); - - clear_files(); - } - fn list_files(selection: &[&PathBuf]) -> ClipFiles { let files: Vec = selection.iter().map(|p| path_str(p)).collect(); let mut clip = ClipFiles::default(); @@ -975,23 +736,6 @@ mod sig_test { assert_only_its_own_requests_fail(&mut clip, &second); } - #[test] - fn an_unreadable_next_file_fails_only_its_own_requests() { - use std::os::unix::fs::PermissionsExt; - let tmp = TmpDir::new("next_unreadable"); - let (first, second) = (tmp.join("first.bin"), tmp.join("second.bin")); - fs::write(&first, b"AAAAAAAA").unwrap(); - fs::write(&second, b"BBBBBBBB").unwrap(); - let mut clip = list_files(&[&first, &second]); - - fs::set_permissions(&second, fs::Permissions::from_mode(0o000)).unwrap(); - // Root opens it regardless of its mode. - if fs::File::open(&second).is_ok() { - return; - } - assert_only_its_own_requests_fail(&mut clip, &second); - } - #[test] fn the_next_file_is_preloaded_past_a_directory() { let tmp = TmpDir::new("next_preloaded"); diff --git a/libs/enigo/src/dsl.rs b/libs/enigo/src/dsl.rs index dfb8adbb6..89af3eef8 100644 --- a/libs/enigo/src/dsl.rs +++ b/libs/enigo/src/dsl.rs @@ -146,39 +146,3 @@ fn tokenize(input: &str) -> Result, ParseError> { Ok(tokens) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn success() { - assert_eq!( - tokenize("{{Hello World!}} {+CTRL}hi{-CTRL}"), - Ok(vec![ - Token::Sequence("{Hello World!} ".into()), - Token::KeyDown(Key::Control), - Token::Sequence("hi".into()), - Token::KeyUp(Key::Control) - ]) - ); - } - #[test] - fn unexpected_open() { - assert_eq!(tokenize("{hello{}world}"), Err(ParseError::UnexpectedOpen)); - } - #[test] - fn unmatched_open() { - assert_eq!( - tokenize("{this is going to fail"), - Err(ParseError::UnmatchedOpen) - ); - } - #[test] - fn unmatched_close() { - assert_eq!( - tokenize("{+CTRL}{{this}} is going to fail}"), - Err(ParseError::UnmatchedClose) - ); - } -} diff --git a/libs/enigo/src/lib.rs b/libs/enigo/src/lib.rs index ec91068c0..970d73d82 100644 --- a/libs/enigo/src/lib.rs +++ b/libs/enigo/src/lib.rs @@ -525,28 +525,3 @@ impl fmt::Debug for Enigo { write!(f, "Enigo") } } - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn test_get_key_state() { - let mut enigo = Enigo::new(); - let keys = [Key::CapsLock, Key::NumLock]; - for k in keys.iter() { - enigo.key_click(k.clone()); - let a = enigo.get_key_state(k.clone()); - enigo.key_click(k.clone()); - let b = enigo.get_key_state(k.clone()); - assert!(a != b); - } - let keys = [Key::Control, Key::Alt, Key::Shift]; - for k in keys.iter() { - enigo.key_down(k.clone()).ok(); - let a = enigo.get_key_state(k.clone()); - enigo.key_up(k.clone()); - let b = enigo.get_key_state(k.clone()); - assert!(a != b); - } - } -} diff --git a/libs/enigo/src/linux/nix_impl.rs b/libs/enigo/src/linux/nix_impl.rs index ffb07ea9a..64b5c2481 100644 --- a/libs/enigo/src/linux/nix_impl.rs +++ b/libs/enigo/src/linux/nix_impl.rs @@ -391,13 +391,6 @@ fn convert_to_tfc_key(key: Key) -> Option { Some(key) } -#[test] -fn test_key_seq() { - // Get 6^ in French. - let mut en = Enigo::new(); - en.key_sequence("^^"); -} - /// Both directions: the failure is silent, so a one-directional test passes against the bug. #[test] fn test_custom_mouse_dispatch_follows_is_x11() { diff --git a/libs/portable/src/bin_reader.rs b/libs/portable/src/bin_reader.rs index d488e0d1d..da0006346 100644 --- a/libs/portable/src/bin_reader.rs +++ b/libs/portable/src/bin_reader.rs @@ -279,26 +279,6 @@ mod tests { .find(|file| normalize_path(&file.path) == normalize_path(path)) } - #[test] - fn parses_the_generate_py_layout() { - let (files, exe) = parse(blob( - &[("./rustdesk.exe", b"app"), ("./custom.txt", b"cfg")], - "./rustdesk.exe", - )) - .unwrap(); - assert_eq!(exe, "./rustdesk.exe"); - assert_eq!(files.len(), 2); - assert_eq!(entry(&files, "./custom.txt").unwrap().raw, b"cfg"); - } - - #[test] - fn rejects_malformed_blobs() { - assert!(parse(b"".as_slice()).is_none()); - assert!(parse(b"notrustd".as_slice()).is_none()); - // Truncated mid-record rather than panicking on a slice out of range. - assert!(parse(b"rustdesk\x00\x00\x00\x40partial".as_slice()).is_none()); - } - #[test] fn distinguishes_an_absent_package_from_a_malformed_one() { assert!(parse_package_blob(None).unwrap().0.is_empty()); @@ -372,34 +352,4 @@ mod tests { ); assert_eq!(entry(&files, "./librustdesk.dll").unwrap().raw, b"core"); } - - #[test] - fn package_paths_are_recorded_for_the_dropped_file_sweep() { - let package = parse(blob( - &[("./custom.txt", b"cfg"), ("./data/logo.png", b"img")], - "./acme.exe", - )) - .unwrap(); - let mut paths: Vec = package.0.iter().map(|f| f.path.clone()).collect(); - paths.sort(); - assert_eq!(paths, vec!["./custom.txt", "./data/logo.png"]); - - // Merging must not disturb them: the generic payload contributes none. - let embedded = parse(blob(&[("./librustdesk.dll", b"core")], "./rustdesk.exe")).unwrap(); - let (files, _) = merge(embedded, package); - assert!(entry(&files, "./data/logo.png").is_some()); - } - - #[test] - fn matches_paths_across_separator_styles() { - // generate.py emits backslashes when it runs on Windows. - let embedded = parse(blob(&[(".\\rustdesk.exe", b"app")], ".\\rustdesk.exe")).unwrap(); - let package = parse(blob(&[("./custom.txt", b"cfg")], "./acme.exe")).unwrap(); - - let (files, exe) = merge(embedded, package); - - assert_eq!(exe, "./acme.exe"); - assert!(entry(&files, "./acme.exe").is_some()); - assert!(entry(&files, ".\\rustdesk.exe").is_none()); - } } diff --git a/libs/scrap/src/common/aom.rs b/libs/scrap/src/common/aom.rs index c33a0a387..d77c30639 100644 --- a/libs/scrap/src/common/aom.rs +++ b/libs/scrap/src/common/aom.rs @@ -199,23 +199,6 @@ mod webrtc { Ok(()) } - - #[cfg(test)] - mod tests { - use super::*; - use std::os::raw::c_uint; - - #[test] - fn tile_log2_uses_c_uint_and_rounds_up() { - let one_thread: c_uint = tile_log2(1); - let three_threads: c_uint = tile_log2(3); - let max_threads: c_uint = tile_log2(64); - - assert_eq!(one_thread, 0); - assert_eq!(three_threads, 2); - assert_eq!(max_threads, 6); - } - } } impl EncoderApi for AomEncoder { diff --git a/libs/scrap/src/common/drm_reader.rs b/libs/scrap/src/common/drm_reader.rs index 737616aff..0086ae1ce 100644 --- a/libs/scrap/src/common/drm_reader.rs +++ b/libs/scrap/src/common/drm_reader.rs @@ -795,47 +795,7 @@ impl Drop for DrmReader { #[cfg(test)] mod hotspot_provenance_tests { - use super::{cursor_id, hot_measured_from, provenance_code, provenance_is_news}; - - /// The log this makes observable is one line per CHANGE, not one per cursor read. A cursor is - /// read far more often than its provenance changes, and a per-sample line here would be the - /// same defect libdrmtap already had once. - #[test] - fn provenance_is_logged_once_and_then_only_on_a_change() { - let (none, guessed, measured) = ( - provenance_code(None), - provenance_code(Some(false)), - provenance_code(Some(true)), - ); - assert_ne!(none, guessed); - assert_ne!(guessed, measured); - assert_ne!(none, measured); - // First sample of a run: -1 is the unset sentinel, so anything is news. - assert!(provenance_is_news(-1, measured)); - // The same state again is not. - assert!(!provenance_is_news(measured, measured)); - assert!(!provenance_is_news(guessed, guessed)); - // A real transition is. - assert!(provenance_is_news(guessed, measured)); - assert!(provenance_is_news(measured, none)); - } - - /// The dedup key has to move when the provenance does, or the consumer is never told that the - /// same picture now means something different. This is the transition that produces it: the - /// properties become readable, the driver's answer is the corner, and the bitmap is unchanged. - #[test] - fn provenance_changes_the_cursor_identity() { - let guessed = cursor_id(0xabc, 24, 24, 0, 0, false); - let measured = cursor_id(0xabc, 24, 24, 0, 0, true); - assert_ne!( - guessed, measured, - "same pixels, same (0, 0), different meaning: the producer must not dedupe this away" - ); - // And the pre-existing parts still count, so this did not trade one blind spot for another. - assert_ne!(cursor_id(0xabc, 24, 24, 0, 0, true), cursor_id(0xabc, 32, 24, 0, 0, true)); - assert_ne!(cursor_id(0xabc, 24, 24, 0, 0, true), cursor_id(0xabc, 24, 24, 1, 0, true)); - assert_ne!(cursor_id(0xabc, 24, 24, 0, 0, true), cursor_id(0xdef, 24, 24, 0, 0, true)); - } + use super::hot_measured_from; /// The case the whole entry point exists for, and the one the old heuristic got backwards: a /// para-virtualized driver that really publishes the hotspot at the image's top-left corner. @@ -845,25 +805,6 @@ mod hotspot_provenance_tests { fn a_measured_zero_hotspot_is_measured() { assert!(hot_measured_from(Some(true), 0, 0)); } - - /// And the mirror: when the library says it was NOT measured, that is the answer even if the - /// coordinates happen to be non-zero. The old test would have trusted them. - #[test] - fn the_librarys_no_beats_the_coordinates() { - assert!(!hot_measured_from(Some(false), 12, 11)); - assert!(!hot_measured_from(Some(false), 0, 0)); - } - - /// No answer available: either the symbol is absent (a library older than 0.5.6) or nothing - /// recorded one for this sample (a cursor read through an older privileged helper). Neither - /// is "it was a guess", so the pre-existing behaviour is kept rather than resolved either way - /// -- an older deployed .so must keep behaving as it used to. - #[test] - fn without_an_answer_the_old_heuristic_stands() { - assert!(!hot_measured_from(None, 0, 0)); - assert!(hot_measured_from(None, 12, 11)); - assert!(hot_measured_from(None, 0, 5)); - } } #[cfg(test)] @@ -924,45 +865,6 @@ mod hotspot_guess_tests { assert_eq!(infer_hotspot(&px, w, h), (4, 4)); } - /// Each axis is decided by its OWN mirror. This shape is symmetric left-to-right and not - /// top-to-bottom, which is the single-direction resize family: the click point is centred - /// across the axis that mirrors and stays at the edge on the axis that does not. - #[test] - fn one_symmetric_axis_centres_that_axis_only() { - let (px, w, h) = sprite(&[ - "....#....", - "...###...", - "..#####..", - ".#######.", - "....#....", - "....#....", - "....#....", - "....#....", - "....#....", - ]); - assert_eq!(infer_hotspot(&px, w, h), (4, 0)); - } - - /// A two-headed DIAGONAL arrow mirrors onto the other diagonal about either axis alone, so - /// neither axis reads as symmetric - and it is still a shape with no tip. The half turn is - /// what catches it. - #[test] - fn a_diagonal_two_headed_arrow_is_held_by_its_middle_too() { - let (px, w, h) = sprite(&[ - "###......", - "##.......", - "#.#......", - "...#.....", - "....#....", - ".....#...", - "......#.#", - ".......##", - "......###", - ]); - let (hx, hy) = infer_hotspot(&px, w, h); - assert_eq!((hx, hy), (4, 4), "a half turn maps this shape onto itself"); - } - /// A single-direction resize cursor is an arrow plus the bar of the edge it resizes, and the /// theme puts the click point on the BAR. The bar is the heavy end, and it is the only thing /// that separates `e-resize` from `w-resize`: they are the same picture reflected, so a rule @@ -992,25 +894,6 @@ mod hotspot_guess_tests { assert_eq!(infer_hotspot(&px, w, h), (0, 3)); } - /// A corner resize cursor is a bracket hugging one corner, and it mirrors onto itself about a - /// DIAGONAL rather than about either axis, so neither per-axis test sees it. The click point - /// is the inner corner of the bracket, which is the heavy end on both axes. - /// - /// A real one's box is usually not square, and a diagonal reflection only lines up inside a - /// square, so which corner of that square the box is placed against decides the answer: - /// `ne-resize` scores 0.62 anchored one way and 0.98 the other, and `nw-resize` is the - /// reverse. Both anchorings are therefore tried. That case cannot be built as a small - /// picture - a non-square box can never be exactly diagonal-symmetric - so it is covered by - /// the theme corpus instead, where dropping the second anchoring sends `ne-resize` back past - /// master. - #[test] - fn a_corner_bracket_is_held_by_its_inner_corner() { - let (px, w, h) = sprite(&[ - ".....##", ".....##", ".....##", ".....##", ".....##", "#######", "#######", - ]); - assert_eq!(infer_hotspot(&px, w, h), (6, 6)); - } - /// The elongation rule stays ahead of the mirror test, so a bar that is NOT symmetric still /// keeps its centre. Nothing in the installed theme is shaped like this - its two I-beams are /// both symmetric - but a custom cursor can be, and this is the rustdesk#16242 round-one fix. @@ -1019,14 +902,6 @@ mod hotspot_guess_tests { let (px, w, h) = sprite(&["##########.", "#####......", "##########."]); assert_eq!(infer_hotspot(&px, w, h), (4, 1)); } - - /// Nothing opaque is not a shape, and must not read as a box at the origin with a hotspot in - /// the middle of it. - #[test] - fn a_blank_sprite_has_no_hotspot_to_guess() { - let (px, w, h) = sprite(&["...", "...", "..."]); - assert_eq!(infer_hotspot(&px, w, h), (0, 0)); - } } /// DRM_MODE_ROTATE_0: the `rotation` bitmask of a plane that turned nothing. @@ -1050,8 +925,6 @@ fn plane_rotation_answer(rc: c_int, rotation: u32) -> Option { #[cfg(test)] mod plane_rotation_tests { use super::*; - use std::os::fd::AsRawFd; - use std::sync::atomic::{AtomicI32, AtomicU32, Ordering}; #[test] fn a_plane_without_the_property_turned_nothing() { @@ -1062,65 +935,6 @@ mod plane_rotation_tests { assert_eq!(plane_rotation_answer(-hbb_common::libc::EINVAL, 0), None); assert_eq!(plane_rotation_answer(-hbb_common::libc::EIO, 7), None); } - - // What the plane answers whenever it is asked. The fake release turns it, the way a compositor - // may once the frame is out, so a read taken after the release or outside the grab sees 0x1. - static PLANE: AtomicU32 = AtomicU32::new(0x1); - static FD: AtomicI32 = AtomicI32::new(-1); - static PIXELS: [u8; 16] = [0; 16]; - - unsafe extern "C" fn plane_rotation(_: *mut drmtap_ctx, rotation: *mut u32) -> c_int { - *rotation = PLANE.load(Ordering::SeqCst); - 0 - } - unsafe extern "C" fn frame_release(_: *mut drmtap_ctx, _: *mut drmtap_frame_info) { - PLANE.store(0x1, Ordering::SeqCst); - } - unsafe extern "C" fn grab_mapped(_: *mut drmtap_ctx, f: *mut drmtap_frame_info) -> c_int { - (*f).data = PIXELS.as_ptr() as *mut _; - (*f).width = 2; - (*f).height = 2; - (*f).stride = 8; - (*f).format = DRM_FORMAT_XRGB8888; - 0 - } - unsafe extern "C" fn grab_desc( - _: *mut drmtap_ctx, - d: *mut drmtap_dmabuf_desc, - _: *mut drmtap_frame_info, - ) -> c_int { - (*d).dma_buf_fd = FD.load(Ordering::SeqCst); - (*d).width = 2; - (*d).height = 2; - (*d).num_planes = 1; - (*d).pitches[0] = 8; - 0 - } - - #[test] - fn a_frame_keeps_the_rotation_its_plane_had_when_it_was_grabbed() { - let lib: &'static DrmtapLib = Box::leak(Box::new(DrmtapLib::fake( - grab_mapped, - frame_release, - grab_desc, - plane_rotation, - ))); - let mut r = DrmReader { - lib, - ctx: std::ptr::null_mut(), - buf: Vec::new(), - last_provenance: -1, - last_plane_rotation: None, - }; - PLANE.store(0x4, Ordering::SeqCst); - r.grab().expect("the fake grab succeeds"); - assert_eq!(r.plane_rotation(), Some(0x4), "cpu grab"); - let (fd, _peer) = std::os::unix::net::UnixStream::pair().unwrap(); - FD.store(fd.as_raw_fd(), Ordering::SeqCst); - PLANE.store(0x8, Ordering::SeqCst); - r.grab_desc().expect("the fake export succeeds"); - assert_eq!(r.plane_rotation(), Some(0x8), "dma-buf export"); - } } #[cfg(test)] diff --git a/libs/scrap/src/common/drmtap_dl.rs b/libs/scrap/src/common/drmtap_dl.rs index 3f043e905..0759c6393 100644 --- a/libs/scrap/src/common/drmtap_dl.rs +++ b/libs/scrap/src/common/drmtap_dl.rs @@ -464,41 +464,6 @@ pub fn get() -> Option<&'static DrmtapLib> { mod tests { use super::{abi_accepted, DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, DRMTAP_MIN_MINOR_PATCH}; - #[test] - fn abi_gate_rejects_a_library_from_before_the_split() { - // These are refused because their MINOR differs from the verified one, which is the only - // reason the gate needs. Naming the pre-split releases keeps the intent readable, but do - // not read this as the floor doing the work: see the test below. - for (minor, patch) in [(3, 3), (4, 0), (4, 8), (4, 9)] { - assert!( - !abi_accepted(DRMTAP_ABI_MAJOR, minor, patch), - "v0.{minor}.{patch} is not the verified minor and must be refused" - ); - } - } - - #[test] - fn the_patch_floor_is_currently_vacuous_and_that_is_deliberate() { - // With MIN_MINOR_PATCH.0 == DRMTAP_ABI_MINOR the floor can never reject anything: the - // minor equality already forces `(minor, patch) >= (minor, 0)`. It is kept because it is - // the mechanism that WOULD do the work the next time a floor lands mid-minor, as (4, 10) - // did for the split API. This test exists so nobody reads the pre-split test above as - // evidence that the floor is live -- if that ever matters, this assert is the tripwire. - let (floor_minor, floor_patch) = DRMTAP_MIN_MINOR_PATCH; - assert_eq!( - floor_minor, DRMTAP_ABI_MINOR, - "the floor is inside the verified minor; a floor in a DIFFERENT minor is unreachable" - ); - if floor_patch == 0 { - assert!( - abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, 0), - "patch 0 of the verified minor must be accepted while the floor is 0" - ); - } else { - assert!(!abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, floor_patch - 1)); - } - } - #[test] fn abi_gate_accepts_the_floor_and_later_patches_of_the_same_minor() { let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH; @@ -528,10 +493,4 @@ mod tests { ); } } - - #[test] - fn abi_gate_rejects_another_major_in_both_directions() { - assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 0, 0)); - assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 99, 99)); - } } diff --git a/libs/scrap/src/dxgi/gdi.rs b/libs/scrap/src/dxgi/gdi.rs index 30440643b..fe22f3124 100644 --- a/libs/scrap/src/dxgi/gdi.rs +++ b/libs/scrap/src/dxgi/gdi.rs @@ -176,38 +176,3 @@ impl Drop for CapturerGDI { } } } - -#[cfg(test)] -mod tests { - use super::super::*; - use super::*; - #[test] - fn test() { - match Displays::new().unwrap().next() { - Some(d) => { - let w = d.width(); - let h = d.height(); - let c = CapturerGDI::new(d.name(), w, h).unwrap(); - let mut data = Vec::new(); - c.frame(&mut data).unwrap(); - let mut bitflipped = Vec::with_capacity((w * h * 4) as usize); - for y in 0..h { - for x in 0..w { - let i = (w * 4 * y + 4 * x) as usize; - bitflipped.extend_from_slice(&[data[i + 2], data[i + 1], data[i], 255]); - } - } - repng::encode( - std::fs::File::create("gdi_screen.png").unwrap(), - d.width() as u32, - d.height() as u32, - &bitflipped, - ) - .unwrap(); - } - _ => { - assert!(false); - } - } - } -} diff --git a/libs/scrap/src/dxgi/mag.rs b/libs/scrap/src/dxgi/mag.rs index 313e1c694..6778a08f8 100644 --- a/libs/scrap/src/dxgi/mag.rs +++ b/libs/scrap/src/dxgi/mag.rs @@ -692,17 +692,3 @@ impl CapturerMag { TRUE } } - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn test() { - let mut capture_mag = CapturerMag::new((0, 0), 1920, 1080).unwrap(); - capture_mag.exclude("", "RustDeskPrivacyWindow").unwrap(); - std::thread::sleep(std::time::Duration::from_millis(1000 * 10)); - let mut data = Vec::new(); - capture_mag.frame(&mut data).unwrap(); - println!("capture data len: {}", data.len()); - } -} diff --git a/libs/scrap/src/wayland/display.rs b/libs/scrap/src/wayland/display.rs index 6ff4177cc..85ce001a8 100644 --- a/libs/scrap/src/wayland/display.rs +++ b/libs/scrap/src/wayland/display.rs @@ -21,10 +21,10 @@ const COMMAND_TIMEOUT: Duration = Duration::from_millis(1000); // drm builds only: an unnamed-endpoint failure there forks the probe child, and the pollers // turn every few hundred milliseconds. Every other failure is one cheap in-process error. -#[cfg(any(test, feature = "drm"))] +#[cfg(feature = "drm")] const FAILED_LOOKUP_BACKOFF: Duration = Duration::from_secs(5); -#[cfg(any(test, feature = "drm"))] +#[cfg(feature = "drm")] static LAST_FAILED_LOOKUP: Mutex> = Mutex::new(None); #[cfg(feature = "drm")] @@ -183,8 +183,7 @@ fn get_primary_monitor() -> Option { .or_else(try_gdbus_primary) } -// Pure, so the backoff policy is testable without a compositor. -#[cfg(any(test, feature = "drm"))] +#[cfg(feature = "drm")] fn lookup_allowed(failed_at: Option, now: Instant) -> bool { failed_at.map_or(true, |at| { now.saturating_duration_since(at) >= FAILED_LOOKUP_BACKOFF @@ -302,6 +301,7 @@ pub fn clear_wayland_displays_cache() { // every OTHER live capturer on a multi-display session. A capturer records this at build and // treats a later bump as "the layout changed under me, rebuild" — the only trigger a rotation // has, since it changes neither the CRTC mode nor the framebuffer size (rustdesk#15886). +#[cfg(feature = "drm")] static SNAPSHOT_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); /// Whether no snapshot has been cached: the signature of an enumeration that failed at session @@ -311,7 +311,7 @@ pub fn wayland_snapshot_missing() -> bool { DISPLAYS.lock().unwrap().is_none() } -#[cfg(any(test, feature = "drm"))] +#[cfg(feature = "drm")] pub fn bump_layout_generation() { SNAPSHOT_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Release); } @@ -516,40 +516,6 @@ fn map_axis(v: i32, base_origin: i32, base_extent: i32, live_origin: i32, live_e mod tests { use super::*; - #[test] - fn test_lookup_backoff_boundaries() { - // Future `now`s sidestep Instant subtraction, which can panic near boot. - let failed_at = Instant::now(); - assert!(lookup_allowed(None, failed_at)); - assert!(!lookup_allowed( - Some(failed_at), - failed_at + FAILED_LOOKUP_BACKOFF / 2 - )); - assert!(lookup_allowed( - Some(failed_at), - failed_at + FAILED_LOOKUP_BACKOFF - )); - } - - #[test] - fn test_lookup_stamp_from_the_future_only_waits() { - // saturating_duration_since answers zero rather than underflowing. - let now = Instant::now(); - assert!(!lookup_allowed(Some(now + FAILED_LOOKUP_BACKOFF), now)); - } - - #[test] - fn test_clear_keeps_the_failure_stamp() { - // The stamp describes the seat, not the cache: the ~1/s capturer rebuild loop clears, - // and dropping the stamp with it would defeat the backoff. The generation test also - // calls clear now; both only assert monotonic/unchanged state, so they can interleave. - *LAST_FAILED_LOOKUP.lock().unwrap() = Some(Instant::now()); - clear_wayland_displays_cache(); - let stamp = *LAST_FAILED_LOOKUP.lock().unwrap(); - assert!(stamp.is_some()); - *LAST_FAILED_LOOKUP.lock().unwrap() = None; - } - fn display( x: i32, y: i32, @@ -569,11 +535,6 @@ mod tests { } } - #[test] - fn test_desktop_rect_empty() { - assert_eq!(desktop_rect_of(&[]), None); - } - #[test] fn test_desktop_rect_single_display_uses_physical_size() { let displays = [display(0, 0, 2880, 1800, Some((1859, 1162)))]; @@ -591,15 +552,6 @@ mod tests { assert_eq!(desktop_rect_of(&displays), Some((0, 3779, 0, 2160))); } - #[test] - fn test_desktop_rect_missing_logical_size_falls_back_to_physical() { - let displays = [ - display(0, 0, 2560, 1440, None), - display(2560, 0, 2560, 1440, Some((2560, 1440))), - ]; - assert_eq!(desktop_rect_of(&displays), Some((0, 5120, 0, 1440))); - } - #[test] fn a_single_rotated_display_swaps_the_uinput_rect() { // Review finding 1 on rustdesk#15889: the single-display branch served the unrotated @@ -611,31 +563,6 @@ mod tests { assert_eq!((rects[0].w, rects[0].h), (1080, 1920)); } - #[test] - fn a_transform_flip_is_visible_to_the_drift_comparison() { - // Review finding 5: 0<->180 and 90<->270 leave every rect identical; the transform - // field is what lets `baseline != live` fire on them. - let mut a = display(0, 0, 1920, 1080, Some((1920, 1080))); - let mut b = a.clone(); - a.transform = 90; - b.transform = 270; - assert_ne!(logical_rects_of(&[a.clone(), a.clone()]), logical_rects_of(&[b.clone(), b])); - } - - #[test] - fn only_the_explicit_bump_moves_the_generation() { - // A cache clear must NOT bump: session inits clear too, and a bump there rebuilds - // every other live capturer (adversarial finding on the first version of this). - let before = SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire); - clear_wayland_displays_cache(); - assert_eq!( - SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire), - before - ); - bump_layout_generation(); - assert!(SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire) > before); - } - fn rect(name: &str, x: i32, y: i32, w: i32, h: i32) -> DisplayRect { DisplayRect { name: name.to_owned(), @@ -671,16 +598,6 @@ mod tests { ); } - // A point on the rescaled display itself is squeezed to its new logical width. - #[test] - fn test_remap_scales_within_resized_display() { - let baseline = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)]; - let live = [rect("DP-1", 0, 0, 2048, 1440), rect("DP-2", 2048, 0, 2560, 1440)]; - // x=1280 across the 2560-wide baseline DP-1 -> proportionally across the 2048-wide - // live DP-1 (endpoint-preserving scale, so ~1px off the naive midpoint). - assert_eq!(remap_to_live_layout(1280, 500, &baseline, &live), (1023, 500)); - } - // The far edge of the followed display stays reachable when it is enlarged, so hot // corners keep working. Baseline DP-1 is 2048 wide, live DP-1 is 2560 wide; the // client's last column (2047) must map to the live last column (2559), not 2558. @@ -692,57 +609,6 @@ mod tests { assert_eq!(remap_to_live_layout(0, 0, &baseline, &live), (0, 0)); } - // No drift: identical layouts map every point to itself. - #[test] - fn test_remap_identity_when_unchanged() { - let layout = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)]; - assert_eq!(remap_to_live_layout(3000, 700, &layout, &layout), (3000, 700)); - } - - // Point outside every baseline display is left untouched. - #[test] - fn test_remap_point_outside_all_displays_unchanged() { - let baseline = [rect("DP-1", 0, 0, 2560, 1440)]; - let live = [rect("DP-1", 0, 0, 2048, 1440)]; - assert_eq!(remap_to_live_layout(9000, 9000, &baseline, &live), (9000, 9000)); - } - - // Matched display gone from the live layout (e.g. unplugged): leave the point be - // rather than mapping it somewhere wrong. - #[test] - fn test_remap_display_removed_unchanged() { - let baseline = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)]; - let live = [rect("DP-1", 0, 0, 2560, 1440)]; - assert_eq!(remap_to_live_layout(2600, 100, &baseline, &live), (2600, 100)); - } - - // Nameless compositor: fall back to index matching while the count is unchanged. - #[test] - fn test_remap_nameless_index_fallback() { - let baseline = [rect("", 0, 0, 2560, 1440), rect("", 2560, 0, 2560, 1440)]; - let live = [rect("", 0, 0, 2048, 1440), rect("", 2048, 0, 2560, 1440)]; - assert_eq!(remap_to_live_layout(2560, 0, &baseline, &live), (2048, 0)); - } - - // Nameless compositor with a changed count: cannot index-match safely, so no-op. - #[test] - fn test_remap_nameless_count_changed_unchanged() { - let baseline = [rect("", 0, 0, 2560, 1440), rect("", 2560, 0, 2560, 1440)]; - let live = [rect("", 0, 0, 2048, 1440)]; - assert_eq!(remap_to_live_layout(2560, 0, &baseline, &live), (2560, 0)); - } - - // A named display absent from the live layout, but the count is unchanged (e.g. a - // monitor was swapped for a different one at the same index): the index fallback is - // for nameless layouts only, so a named miss stays unchanged rather than mapping to - // whatever now occupies that index. - #[test] - fn test_remap_named_miss_equal_count_unchanged() { - let baseline = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)]; - let live = [rect("DP-1", 0, 0, 2048, 1440), rect("HDMI-1", 2048, 0, 1920, 1080)]; - assert_eq!(remap_to_live_layout(2600, 100, &baseline, &live), (2600, 100)); - } - // A single display uses physical size in both baseline and live (scale reported as // 1.0), so it never drifts and the remap is a no-op even across a rescale. #[test] @@ -753,17 +619,4 @@ mod tests { vec![rect("", 0, 0, 2560, 1440)] ); } - - // Multiple displays use logical size, falling back to physical when absent. - #[test] - fn test_logical_rects_multi_display_uses_logical() { - let displays = [ - display(0, 0, 2560, 1440, Some((2048, 1152))), - display(2048, 0, 1920, 1080, None), - ]; - assert_eq!( - logical_rects_of(&displays), - vec![rect("", 0, 0, 2048, 1152), rect("", 2048, 0, 1920, 1080)] - ); - } } diff --git a/libs/scrap/src/wayland/pipewire.rs b/libs/scrap/src/wayland/pipewire.rs index 0ca5a5df3..79e6e0838 100644 --- a/libs/scrap/src/wayland/pipewire.rs +++ b/libs/scrap/src/wayland/pipewire.rs @@ -1802,7 +1802,7 @@ fn sort_streams( #[cfg(test)] mod tests { - use super::{corrected_origin, reconciled_origin, stage_err, WaylandDisplayInfo}; + use super::{corrected_origin, WaylandDisplayInfo}; fn output(x: i32, y: i32, mode: (i32, i32)) -> WaylandDisplayInfo { WaylandDisplayInfo { @@ -1867,43 +1867,4 @@ mod tests { Some((2560, 0)) ); } - - #[test] - fn a_cached_position_is_not_overridden_by_an_unmeasured_size() { - let outputs = [output(0, 0, (1440, 900)), output(1920, 0, (2880, 1800))]; - // Restored from the cache, or sent by the portal: get_res() failed, so the size is the - // portal size, here the mode of the other output, and it moves nothing. - assert_eq!( - reconciled_origin((1920, 0), (1440, 900), false, &outputs), - None - ); - // A measured size still moves a position that names no output of that size: xdph sends - // (0, 0) for every stream. - let pair = [output(0, 0, (1920, 1080)), output(1920, 0, (2880, 1800))]; - assert_eq!( - reconciled_origin((0, 0), (2880, 1800), true, &pair), - Some((1920, 0)) - ); - } - - #[test] - fn stage_err_keeps_the_detail_safe_for_a_placeholder() { - assert_eq!( - stage_err("start", "declined", ""), - "wl-stage:start:declined:" - ); - // Braces of its own would break the placeholder lookup on the peer. - assert_eq!( - stage_err("create-session", "dbus", "org.freedesktop.{Error}"), - "wl-stage:create-session:dbus:org.freedesktop.Error" - ); - assert_eq!( - stage_err("select-sources", "internal", "one\ntwo"), - "wl-stage:select-sources:internal:one two" - ); - assert_eq!( - stage_err("start", "internal", &"x".repeat(300)), - format!("wl-stage:start:internal:{}", "x".repeat(200)) - ); - } } diff --git a/src/audio_resampler.rs b/src/audio_resampler.rs index a95600c75..7a86b9bff 100644 --- a/src/audio_resampler.rs +++ b/src/audio_resampler.rs @@ -237,6 +237,3 @@ fn validate_input(input: &[f32], channels: usize) -> Result<(), AudioResamplerEr #[cfg(all(test, not(all(feature = "use_samplerate", not(feature = "use_dasp")))))] mod tests; - -#[cfg(all(test, feature = "use_samplerate", not(feature = "use_dasp")))] -mod samplerate_tests; diff --git a/src/audio_resampler/samplerate_tests.rs b/src/audio_resampler/samplerate_tests.rs deleted file mode 100644 index 0ba78063a..000000000 --- a/src/audio_resampler/samplerate_tests.rs +++ /dev/null @@ -1,157 +0,0 @@ -use super::{AudioResampler, AudioResamplerConfig, AudioResamplerError, FixedFrameAudioResampler}; - -const INPUT_RATE: u32 = 44_100; -const OUTPUT_RATE: u32 = 48_000; -const CHANNELS: u16 = 2; -const INPUT_PACKET_FRAMES: usize = INPUT_RATE as usize / PACKETS_PER_SECOND; -const OUTPUT_PACKET_FRAMES: usize = OUTPUT_RATE as usize / PACKETS_PER_SECOND; -const PACKET_COUNT: usize = 20; -const PACKETS_PER_SECOND: usize = 100; -const MIN_CONTINUITY_PACKETS: usize = 2; -const TONE_FREQUENCY_HZ: f32 = 997.0; -const TONE_AMPLITUDE: f32 = 0.5; -const MAX_BOUNDARY_RESIDUAL: f32 = 0.02; -const INCOMPLETE_SAMPLE_COUNT: usize = 1; -const DOWNSAMPLE_RATE: u32 = 24_000; -const REJECTED_TONE_HZ: f64 = 18_000.0; -const MAX_ALIAS_RMS: f64 = 0.01; -const MIN_PASSBAND_RMS: f64 = 0.3; - -fn stereo_tone(frames: usize) -> Vec { - (0..frames) - .flat_map(|frame| { - let phase = - std::f32::consts::TAU * TONE_FREQUENCY_HZ * frame as f32 / INPUT_RATE as f32; - let sample = TONE_AMPLITUDE * phase.sin(); - [sample, sample] - }) - .collect() -} - -fn maximum_boundary_residual(packets: &[Vec]) -> f32 { - packets.windows(2).fold(0.0, |maximum, pair| { - let previous = &pair[0]; - let current = &pair[1]; - let last = previous.len() - CHANNELS as usize; - let penultimate = last - CHANNELS as usize; - (0..CHANNELS as usize).fold(maximum, |maximum, channel| { - let predicted = previous[last + channel] - + (previous[last + channel] - previous[penultimate + channel]); - maximum.max((current[channel] - predicted).abs()) - }) - }) -} - -fn stereo_config() -> AudioResamplerConfig { - AudioResamplerConfig { - input_rate: INPUT_RATE, - output_rate: OUTPUT_RATE, - channels: CHANNELS, - } -} - -#[test] -fn moving_capture_resampler_preserves_pending_audio() { - let input = stereo_tone(INPUT_PACKET_FRAMES * PACKET_COUNT); - let packet_samples = INPUT_PACKET_FRAMES * CHANNELS as usize; - let mut expected_resampler = - FixedFrameAudioResampler::new(stereo_config(), OUTPUT_PACKET_FRAMES).unwrap(); - let expected: Vec<_> = input - .chunks(packet_samples) - .flat_map(|packet| expected_resampler.process(packet).unwrap()) - .collect(); - let mut moved_resampler = - FixedFrameAudioResampler::new(stereo_config(), OUTPUT_PACKET_FRAMES).unwrap(); - let mut output = moved_resampler.process(&input[..packet_samples]).unwrap(); - let remaining = std::thread::spawn(move || { - input[packet_samples..] - .chunks(packet_samples) - .flat_map(|packet| moved_resampler.process(packet).unwrap()) - .collect::>() - }) - .join() - .unwrap(); - output.extend(remaining); - - assert!(output.len() >= MIN_CONTINUITY_PACKETS); - assert!(output - .iter() - .all(|packet| packet.len() == OUTPUT_PACKET_FRAMES * CHANNELS as usize)); - assert!(maximum_boundary_residual(&output) <= MAX_BOUNDARY_RESIDUAL); - assert_eq!(output, expected); -} - -fn downsampled_rms(input: &[f32]) -> f64 { - let config = AudioResamplerConfig { - output_rate: DOWNSAMPLE_RATE, - ..stereo_config() - }; - let output_frames = DOWNSAMPLE_RATE as usize / PACKETS_PER_SECOND; - let mut resampler = FixedFrameAudioResampler::new(config, output_frames).unwrap(); - let output: Vec = input - .chunks(INPUT_PACKET_FRAMES * CHANNELS as usize) - .flat_map(|packet| resampler.process(packet).unwrap().into_iter().flatten()) - .collect(); - - assert!(output.len() >= output_frames * CHANNELS as usize * MIN_CONTINUITY_PACKETS); - let mean_square = output - .iter() - .map(|sample| f64::from(*sample).powi(2)) - .sum::() - / output.len() as f64; - mean_square.sqrt() -} - -#[test] -fn capture_downsampling_filters_out_of_band_audio() { - let input: Vec<_> = (0..INPUT_PACKET_FRAMES * PACKET_COUNT) - .flat_map(|frame| { - let phase = - std::f64::consts::TAU * REJECTED_TONE_HZ * frame as f64 / f64::from(INPUT_RATE); - let sample = (f64::from(TONE_AMPLITUDE) * phase.sin()) as f32; - [sample, sample] - }) - .collect(); - let rms = downsampled_rms(&input); - assert!( - rms < MAX_ALIAS_RMS, - "out-of-band output RMS {rms} exceeded {MAX_ALIAS_RMS}" - ); - - let input = stereo_tone(INPUT_PACKET_FRAMES * PACKET_COUNT); - let rms = downsampled_rms(&input); - assert!( - rms > MIN_PASSBAND_RMS, - "in-band output RMS {rms} fell below {MIN_PASSBAND_RMS}" - ); -} - -#[test] -fn samplerate_backend_preserves_streaming_continuity() { - let input = stereo_tone(INPUT_PACKET_FRAMES * PACKET_COUNT); - let mut resampler = AudioResampler::new(stereo_config()).unwrap(); - let packets: Vec<_> = input - .chunks(INPUT_PACKET_FRAMES * CHANNELS as usize) - .map(|packet| resampler.process(packet).unwrap()) - .filter(|packet| !packet.is_empty()) - .collect(); - - assert!(packets.len() >= MIN_CONTINUITY_PACKETS); - assert!(packets - .iter() - .all(|packet| packet.len() % CHANNELS as usize == 0)); - assert!(maximum_boundary_residual(&packets) <= MAX_BOUNDARY_RESIDUAL); -} - -#[test] -fn samplerate_backend_reports_incomplete_frame_context() { - let mut resampler = AudioResampler::new(stereo_config()).unwrap(); - - assert_eq!( - resampler.process(&[0.0]).unwrap_err(), - AudioResamplerError::IncompleteFrame { - samples: INCOMPLETE_SAMPLE_COUNT, - channels: CHANNELS as usize, - } - ); -} diff --git a/src/audio_resampler/tests.rs b/src/audio_resampler/tests.rs index 566af6512..df5ac4738 100644 --- a/src/audio_resampler/tests.rs +++ b/src/audio_resampler/tests.rs @@ -18,8 +18,6 @@ const SECOND_DOWNSAMPLE_PACKET: [f32; 4] = [3.0, 4.0, 5.0, 6.0]; const EXPECTED_DOWNSAMPLED_OUTPUT: [f32; 4] = [0.0, 2.0, 4.0, 6.0]; const PACKETS_PER_SECOND: usize = 100; const OUTPUT_PACKET_FRAMES: usize = OUTPUT_RATE as usize / PACKETS_PER_SECOND; -const RATE_44_1_KHZ: u32 = 44_100; -const FLOAT_TOLERANCE: f32 = 0.000_001; const MIN_CONTINUITY_PACKETS: usize = 2; fn stereo_tone_at_rate(frames: usize, sample_rate: u32) -> Vec { @@ -37,11 +35,6 @@ fn stereo_tone(frames: usize) -> Vec { stereo_tone_at_rate(frames, INPUT_RATE) } -fn maximum_tone_prediction_residual(sample_rate: u32) -> f32 { - let half_step = std::f32::consts::PI * TONE_FREQUENCY_HZ / sample_rate as f32; - 4.0 * TONE_AMPLITUDE * half_step.sin().powi(2) -} - fn maximum_boundary_residual(chunks: &[Vec]) -> f32 { chunks.windows(2).fold(0.0, |maximum, pair| { let previous = &pair[0]; @@ -89,39 +82,6 @@ fn preserves_decoded_packet_continuity_and_output_ratio() { } } -#[test] -fn rejects_incomplete_interleaved_frames() { - let mut resampler = AudioResampler::new(stereo_config()).unwrap(); - - assert!(resampler.process(&[TONE_AMPLITUDE]).is_err()); -} - -#[test] -fn interpolates_mono_samples() { - let config = AudioResamplerConfig { - input_rate: UNIT_RATE, - output_rate: DOUBLE_RATE, - channels: MONO_CHANNELS, - }; - let mut resampler = AudioResampler::new(config).unwrap(); - - assert_eq!( - resampler.process(&[0.0, 1.0, 2.0]).unwrap(), - [0.0, 0.5, 1.0, 1.5, 2.0] - ); -} - -#[test] -fn rejects_zero_rate_configuration() { - let config = AudioResamplerConfig { - input_rate: 0, - output_rate: OUTPUT_RATE, - channels: CHANNELS, - }; - - assert!(AudioResampler::new(config).is_err()); -} - #[test] fn downsamples_across_packet_boundaries() { let config = AudioResamplerConfig { @@ -152,27 +112,3 @@ fn sender_resampler_emits_only_complete_continuous_frames() { .all(|packet| packet.len() == OUTPUT_PACKET_FRAMES * CHANNELS as usize)); assert!(maximum_boundary_residual(&output) <= MAX_BOUNDARY_RESIDUAL); } - -#[test] -fn sender_downsampling_preserves_packet_continuity() { - let input_packet_frames = RATE_44_1_KHZ as usize / PACKETS_PER_SECOND; - let input = stereo_tone_at_rate(input_packet_frames * CHUNK_COUNT, RATE_44_1_KHZ); - let config = AudioResamplerConfig { - input_rate: RATE_44_1_KHZ, - output_rate: INPUT_RATE, - channels: CHANNELS, - }; - let mut resampler = - FixedFrameAudioResampler::new(config, INPUT_RATE as usize / PACKETS_PER_SECOND).unwrap(); - let packets: Vec<_> = input - .chunks(input_packet_frames * CHANNELS as usize) - .flat_map(|packet| resampler.process(packet).unwrap()) - .collect(); - let residual = maximum_boundary_residual(&packets); - - assert_eq!(packets.len(), CHUNK_COUNT); - assert!( - residual <= maximum_tone_prediction_residual(INPUT_RATE) + FLOAT_TOLERANCE, - "sender packet boundary residual {residual} exceeded the tone curvature" - ); -} diff --git a/src/client.rs b/src/client.rs index 6d752d498..b7163dafc 100644 --- a/src/client.rs +++ b/src/client.rs @@ -2328,41 +2328,6 @@ impl AudioBuffer { } } -#[cfg(all(test, not(target_os = "linux")))] -mod audio_buffer_discontinuity_tests { - use super::AudioBuffer; - use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, Mutex, - }; - - const BUFFER_CAPACITY: usize = 4; - const BUFFER_LEVELS: usize = 30; - const FIRST_INPUT: [f32; 2] = [0.1, 0.2]; - const OVERFLOWING_INPUT: [f32; 3] = [0.3, 0.4, 0.5]; - const OVERSIZED_INPUT: [f32; 5] = [0.6, 0.7, 0.8, 0.9, 1.0]; - - #[test] - fn capacity_discards_signal_discontinuities() { - let audio_buffer = AudioBuffer( - Arc::new(Mutex::new(ringbuf::HeapRb::new(BUFFER_CAPACITY))), - BUFFER_CAPACITY, - [0; BUFFER_LEVELS], - Arc::new(AtomicUsize::new(0)), - ); - - assert_eq!(audio_buffer.append_pcm2(&FIRST_INPUT), FIRST_INPUT.len()); - assert_eq!(audio_buffer.3.load(Ordering::Relaxed), 0); - assert_eq!( - audio_buffer.append_pcm2(&OVERFLOWING_INPUT), - BUFFER_CAPACITY - ); - assert_eq!(audio_buffer.3.load(Ordering::Relaxed), 1); - assert_eq!(audio_buffer.append_pcm2(&OVERSIZED_INPUT), BUFFER_CAPACITY); - assert_eq!(audio_buffer.3.load(Ordering::Relaxed), 2); - } -} - impl AudioHandler { #[cfg(target_os = "linux")] fn start_audio(&mut self, format0: AudioFormat) -> ResultType<()> { @@ -5227,26 +5192,6 @@ mod retry_tests { } } -#[cfg(test)] -mod port_forward_mux_tests { - use super::*; - - #[test] - fn a_login_asks_for_the_tunnel_when_its_mapping_probes() { - let mut lc = LoginConfigHandler::default(); - lc.conn_type = ConnType::PORT_FORWARD; - let asks = |lc: &LoginConfigHandler| { - lc.create_login_msg(String::new(), String::new(), vec![]) - .login_request() - .port_forward() - .multiplex - }; - assert!(!asks(&lc)); - lc.port_forward_multiplex = true; - assert!(asks(&lc)); - } -} - pub async fn hc_connection( feedback: i32, rendezvous_server: String, @@ -5439,7 +5384,6 @@ pub mod peer_online { mod tests { use crate::client::Client; use hbb_common::rendezvous_proto::IceCandidate; - use hbb_common::tokio; #[test] fn accepts_webrtc_ice_by_session_key_only() { @@ -5452,22 +5396,6 @@ pub mod peer_online { assert!(Client::is_expected_webrtc_ice_candidate(&ice, "session-a")); assert!(!Client::is_expected_webrtc_ice_candidate(&ice, "session-b")); } - - #[tokio::test] - async fn test_query_onlines() { - super::query_online_states( - vec![ - "152183996".to_owned(), - "165782066".to_owned(), - "155323351".to_owned(), - "460952777".to_owned(), - ], - |onlines: Vec, offlines: Vec| { - println!("onlines: {:?}, offlines: {:?}", &onlines, &offlines); - }, - ) - .await; - } } } @@ -5593,7 +5521,7 @@ async fn udp_nat_connect( #[cfg(test)] mod webrtc_race_tests { - use super::{race_transports_prefer_webrtc, request_allows_tcp_punch}; + use super::race_transports_prefer_webrtc; use hbb_common::{ anyhow::anyhow, futures::future::{BoxFuture, FutureExt}, @@ -5620,12 +5548,6 @@ mod webrtc_race_tests { const NOT_P2P: fn(&&'static str) -> bool = |_| false; - #[test] - fn webrtc_request_never_reuses_its_signaling_socket_for_tcp_punch() { - assert!(request_allows_tcp_punch("")); - assert!(!request_allows_tcp_punch("webrtc://offer")); - } - // A direct result must win even when it lands first — the LAN ordering, where ICE beats the // relay's TCP connect. Parking it as if it were a relay commits the relay on arrival. #[tokio::test] @@ -5683,32 +5605,6 @@ mod webrtc_race_tests { assert_eq!(got, "preferred-relay"); } - #[tokio::test] - async fn relay_from_preferred_branch_committed_when_window_expires() { - let got = race_transports_prefer_webrtc( - ok_after(10, "preferred-relay"), - vec![ok_after(60_000, "too-slow")], - 100, - NOT_P2P, - ) - .await - .unwrap(); - assert_eq!(got, "preferred-relay"); - } - - #[tokio::test] - async fn preference_window_starts_when_relay_is_ready() { - let got = race_transports_prefer_webrtc( - ok_after(350, "webrtc"), - vec![ok_after(250, "relay")], - 200, - NOT_P2P, - ) - .await - .unwrap(); - assert_eq!(got, "webrtc"); - } - #[tokio::test] async fn unfinished_ipv6_still_beats_held_relay() { let start = Instant::now(); @@ -5739,36 +5635,6 @@ mod webrtc_race_tests { assert_eq!(got, "ipv6"); } - // A relay is held with a direct attempt racing behind it, then WebRTC fails. The held relay - // must not be committed while the direct attempt is still in flight. - #[tokio::test] - async fn held_relay_waits_for_racing_direct_when_webrtc_fails() { - let got = race_transports_prefer_webrtc( - err_after(50, "webrtc dead"), - vec![ok_after(10, "relay"), ok_after(100, "ipv6")], - 60_000, - |t| *t == "ipv6", - ) - .await - .unwrap(); - assert_eq!(got, "ipv6"); - } - - // Both attempts error, but a relay was parked before they did — it is the outcome, not a - // composed error. - #[tokio::test] - async fn held_relay_survives_both_errors() { - let got = race_transports_prefer_webrtc( - err_after(50, "webrtc dead"), - vec![ok_after(10, "relay"), err_after(100, "ipv6 dead")], - 60_000, - |t| *t == "ipv6", - ) - .await - .unwrap(); - assert_eq!(got, "relay"); - } - #[tokio::test] async fn held_relay_committed_when_window_expires() { let start = Instant::now(); @@ -5839,20 +5705,6 @@ mod webrtc_race_tests { assert_eq!(got, "ipv6"); assert!(start.elapsed() < Duration::from_secs(5)); } - - #[tokio::test] - async fn both_failing_compose_error() { - let err = race_transports_prefer_webrtc( - err_after(10, "webrtc dead"), - vec![err_after(20, "relay dead")], - 1_000, - NOT_P2P, - ) - .await - .unwrap_err() - .to_string(); - assert!(err.contains("webrtc dead") && err.contains("relay dead"), "{}", err); - } } #[cfg(test)] diff --git a/src/client/audio_playback_tests.rs b/src/client/audio_playback_tests.rs index bbe3e1646..388b66304 100644 --- a/src/client/audio_playback_tests.rs +++ b/src/client/audio_playback_tests.rs @@ -1,7 +1,4 @@ -use super::{ - AudioPlaybackConfig, AudioPlaybackError, AudioPlaybackRecovery, AudioPlaybackStatus, - AudioPlaybackWriter, -}; +use super::{AudioPlaybackConfig, AudioPlaybackRecovery, AudioPlaybackWriter}; use ringbuf::{ring_buffer::RbBase, Rb}; use std::{ sync::{atomic::Ordering, mpsc, Arc, Mutex}, @@ -93,31 +90,6 @@ fn smooths_underflow_and_explicit_audio_discontinuities() { } } -#[test] -fn validates_configuration_and_frame_size() { - let invalid_config = AudioPlaybackConfig { - sample_rate: 0, - channels: CHANNELS, - }; - assert_eq!( - AudioPlaybackRecovery::new(invalid_config).err(), - Some(AudioPlaybackError::InvalidConfig(invalid_config)) - ); - - let config = AudioPlaybackConfig { - sample_rate: SAMPLE_RATE, - channels: CHANNELS, - }; - let mut recovery = AudioPlaybackRecovery::new(config).unwrap(); - assert_eq!( - recovery.process_frame(Some(&[0.5])).err(), - Some(AudioPlaybackError::IncompleteFrame { - samples: 1, - channels: CHANNELS, - }) - ); -} - const CALLBACK_SAMPLES: usize = 64; const CALLBACK_TIMEOUT: Duration = Duration::from_secs(2); const DISCARD_GENERATION: usize = 1; @@ -187,76 +159,3 @@ fn playback_contention_preserves_queued_audio_and_recovers_after_release() { assert_eq!(&output[samples - CHANNELS..], &OPPOSITE_ACTIVE_FRAME); assert_eq!(buffer.lock().unwrap().occupied_len(), 0); } - -#[test] -fn poisoned_playback_buffer_reports_once_without_panicking_in_the_callback() { - let buffer = Arc::new(Mutex::new(ringbuf::HeapRb::new(CALLBACK_SAMPLES))); - let config = AudioPlaybackConfig { - sample_rate: SAMPLE_RATE, - channels: CHANNELS, - }; - let mut writer = - AudioPlaybackWriter::new(config, buffer.clone(), Arc::new(super::AtomicUsize::new(0))) - .unwrap(); - assert!(std::thread::spawn(move || { - let _guard = buffer.lock().unwrap(); - panic!("Injected PCM buffer failure"); - }) - .join() - .is_err()); - let mut output = [ACTIVE_FRAME[0]; CALLBACK_SAMPLES]; - - crate::audio_resampler::allocation_tests::assert_no_allocations(|| { - writer.write_output(&mut output); - }); - - assert_eq!(output, [0.0; CALLBACK_SAMPLES]); - assert!(!writer.status.ready.load(Ordering::Acquire)); - assert!(writer.status.buffer_poisoned.load(Ordering::Relaxed)); - writer.status.report_errors(); - writer.write_output(&mut output); - assert!(!writer.status.buffer_poisoned.load(Ordering::Relaxed)); - assert_eq!(writer.status.contentions.load(Ordering::Relaxed), 0); - assert!(!writer.status.ready.load(Ordering::Acquire)); -} - -#[test] -fn contention_counts_accumulate_until_the_next_report() { - let status = AudioPlaybackStatus::default(); - status.report_errors(); - status.contentions.fetch_add(1, Ordering::Relaxed); - status.report_errors(); - assert_eq!(status.contentions.load(Ordering::Relaxed), 0); - - let mut total = 0; - for callbacks in [3, 7, 2] { - total += callbacks; - status.contentions.fetch_add(callbacks, Ordering::Relaxed); - status.report_errors(); - assert_eq!(status.contentions.load(Ordering::Relaxed), total); - } - status.buffer_poisoned.store(true, Ordering::Relaxed); - status.report_errors(); - assert!(!status.buffer_poisoned.load(Ordering::Relaxed)); - assert_eq!(status.contentions.load(Ordering::Relaxed), total); - - std::thread::sleep(super::AUDIO_PLAYBACK_LOG_INTERVAL); - status.report_errors(); - assert_eq!(status.contentions.load(Ordering::Relaxed), 0); -} - -#[test] -fn contention_reporting_is_independent_between_playbacks() { - let first = AudioPlaybackStatus::default(); - let second = AudioPlaybackStatus::default(); - for status in [&first, &second] { - status.contentions.fetch_add(1, Ordering::Relaxed); - status.report_errors(); - assert_eq!(status.contentions.load(Ordering::Relaxed), 0); - } - for status in [&first, &second] { - status.contentions.fetch_add(1, Ordering::Relaxed); - status.report_errors(); - assert_eq!(status.contentions.load(Ordering::Relaxed), 1); - } -} diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index fcbc0901e..f9e6e2952 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -2767,7 +2767,7 @@ impl Remote { /// A shape that decoded, still compressed, under the id the UI knows it by. Copied once it /// decoded: the message's colors may be a slice of a larger received buffer, which a clone /// would keep alive. -#[cfg(any(feature = "flutter", test))] +#[cfg(feature = "flutter")] fn cursor_shape(id: u64, cd: &CursorData, compressed: &[u8]) -> CursorData { CursorData { id, @@ -3022,74 +3022,6 @@ mod cursor_dedupe_tests { assert_eq!(dedupe.id(2), first); assert_eq!(dedupe.id(1), first); } - - #[test] - fn the_hotspot_is_part_of_the_shape() { - let a = CursorDedupe::name(&shape(1, 0, b"arrow")); - let b = CursorDedupe::name(&shape(2, 1, b"arrow")); - assert_ne!(a, b); - } - - #[test] - fn a_reused_handle_names_its_latest_shape() { - let mut dedupe = CursorDedupe::default(); - dedupe.record(1, CursorDedupe::name(&shape(1, 0, b"arrow"))); - let beam = CursorDedupe::name(&shape(1, 0, b"beam")); - dedupe.record(1, beam); - assert_eq!(dedupe.id(1), beam); - } - - #[test] - fn a_rejected_shape_names_its_handle_and_is_never_decoded() { - let mut dedupe = CursorDedupe::default(); - let arrow = CursorDedupe::name(&shape(7, 0, b"arrow")); - dedupe.record(7, arrow); - let beam = CursorDedupe::name(&shape(8, 0, b"beam")); - dedupe.record(8, beam); - let broken = CursorDedupe::name(&shape(7, 0, b"broken")); - assert_eq!( - dedupe.rejected(7, broken), - broken, - "not the shape it named before" - ); - assert_eq!( - dedupe.id(7), - broken, - "nor when the handle is selected again" - ); - assert_eq!(dedupe.id(8), beam, "another handle keeps its shape"); - assert!(!dedupe.has_decoded(broken)); - assert!( - dedupe.has_decoded(arrow), - "kept for a handle that brings it again" - ); - dedupe.record(7, arrow); - assert_eq!(dedupe.id(7), arrow); - } - - #[test] - fn a_shape_rejected_under_handles_never_seen_files_nothing() { - let mut dedupe = CursorDedupe::default(); - let broken = CursorDedupe::name(&shape(0, 0, b"broken")); - for handle in 0..1000 { - assert_eq!(dedupe.rejected(handle, broken), broken); - } - assert!(dedupe.ids.is_empty()); - assert!(!dedupe.has_decoded(broken)); - } - - #[test] - fn an_id_the_peer_never_sent_is_passed_on() { - assert_eq!(CursorDedupe::default().id(7), 7); - } - - #[test] - fn peers_from_1_5_0_name_cursors_by_content() { - use hbb_common::get_version_number as v; - assert!(!crate::is_peer_naming_cursors_by_content(v("1.4.9"))); - assert!(crate::is_peer_naming_cursors_by_content(v("1.5.0"))); - assert!(crate::is_peer_naming_cursors_by_content(v("1.5.1"))); - } } #[cfg(test)] @@ -3107,21 +3039,6 @@ mod kept_cursor_tests { } } - #[test] - fn a_kept_shape_is_a_copy_under_the_ui_id() { - let mut cd = compressed(4, 4); - cd.hotx = 1; - let kept = cursor_shape(42, &cd, &cd.colors); - assert_eq!(kept.id, 42); - assert_eq!((kept.hotx, kept.width, kept.height), (1, 4, 4)); - assert_eq!(kept.colors, cd.colors); - assert_ne!( - kept.colors.as_ptr(), - cd.colors.as_ptr(), - "not a view of the received buffer" - ); - } - #[test] fn a_kept_shape_is_checked_again_when_it_is_read() { assert_eq!(kept_cursor_rgba(compressed(4, 4)).unwrap().colors.len(), 64); diff --git a/src/client/tests/audio_playback_recovery_tests.rs b/src/client/tests/audio_playback_recovery_tests.rs index 663e88d48..c7c8e4ff8 100644 --- a/src/client/tests/audio_playback_recovery_tests.rs +++ b/src/client/tests/audio_playback_recovery_tests.rs @@ -5,9 +5,6 @@ use crate::client::{ use cpal::StreamError; use crossbeam_queue::SegQueue; use hbb_common::tokio::time::Instant; -use std::time::Duration; - -const AFTER_COOLDOWN: Duration = Duration::from_secs(2); type PendingOutput = ( Arc, @@ -82,27 +79,6 @@ fn unconfirmed_start_retries_without_callback_or_error() { }); } -#[test] -fn already_terminal_candidate_cannot_replace_compatible_output() { - let (mut handler, dropped) = recovery_handler(); - let candidate_dropped = Arc::new(AtomicBool::new(false)); - let old_buffer = handler.audio_buffer.0.clone(); - handler.handle_format_with_start(format(INPUT_RATE, CHANNELS), |candidate, _| { - install_output(candidate, candidate_dropped.clone()); - candidate - .playback_recovery - .errors - .push(StreamError::DeviceNotAvailable); - Ok(()) - }); - assert!(!dropped.load(Ordering::SeqCst)); - assert!(candidate_dropped.load(Ordering::SeqCst)); - assert!(Arc::ptr_eq(&old_buffer, &handler.audio_buffer.0)); - handler.recover_playback_with(Instant::now() + AFTER_COOLDOWN, |_, _| { - panic!("Compatible active output must not be reopened") - }); -} - #[test] fn pending_output_keeps_playing_and_transfers_decoder_history_on_commit() { let (mut handler, old_dropped) = recovery_handler(); @@ -157,23 +133,6 @@ fn rollback_keeps_the_restarted_decoders_accumulated_history() { ); } -#[test] -fn later_compatible_format_retires_only_the_pending_attempt() { - let (mut handler, old_dropped) = recovery_handler(); - let (first_dropped, _, first_errors) = begin_pending(&mut handler); - let (second_dropped, second_status, _) = begin_pending(&mut handler); - assert!(first_dropped.load(Ordering::SeqCst)); - assert!(!old_dropped.load(Ordering::SeqCst)); - first_errors.push(StreamError::DeviceNotAvailable); - second_status.ready.store(true, Ordering::Release); - handler.recover_playback_with(Instant::now(), |_, _| { - panic!("Retired attempt affected current output") - }); - assert!(old_dropped.load(Ordering::SeqCst)); - assert!(!second_dropped.load(Ordering::SeqCst)); - assert!(Arc::ptr_eq(&second_status, &handler.playback_status)); -} - #[test] fn both_outputs_failing_retains_format_and_paces_recovery() { let (mut handler, old_dropped) = recovery_handler(); @@ -194,33 +153,3 @@ fn both_outputs_failing_retains_format_and_paces_recovery() { }); assert_eq!(attempts, 1); } - -#[test] -fn superseding_format_keeps_ready_candidate_when_active_output_failed() { - let (mut handler, old_dropped) = recovery_handler(); - let old_errors = handler.playback_recovery.errors.clone(); - let (candidate_dropped, candidate_status, _) = begin_pending(&mut handler); - candidate_status.ready.store(true, Ordering::Release); - old_errors.push(StreamError::DeviceNotAvailable); - handler.handle_format_with_start(format(INPUT_RATE, CHANNELS), failed_output); - assert!(!candidate_dropped.load(Ordering::SeqCst)); - assert!(old_dropped.load(Ordering::SeqCst)); - assert!(Arc::ptr_eq(&candidate_status, &handler.playback_status)); - assert!(handler.audio_decoder.is_some()); - assert!(handler.playback_recovery.retry_at.is_none()); -} - -#[test] -fn superseding_format_preserves_failure_when_both_outputs_failed() { - let (mut handler, old_dropped) = recovery_handler(); - let old_errors = handler.playback_recovery.errors.clone(); - let (candidate_dropped, status, errors) = begin_pending(&mut handler); - status.ready.store(true, Ordering::Release); - old_errors.push(StreamError::DeviceNotAvailable); - errors.push(StreamError::DeviceNotAvailable); - handler.handle_format_with_start(format(INPUT_RATE, CHANNELS), failed_output); - assert!(candidate_dropped.load(Ordering::SeqCst)); - assert!(old_dropped.load(Ordering::SeqCst)); - assert!(handler.audio_stream.is_none()); - assert!(handler.playback_recovery.retry_at.is_some()); -} diff --git a/src/common.rs b/src/common.rs index fa5c20334..b9b690265 100644 --- a/src/common.rs +++ b/src/common.rs @@ -3173,55 +3173,6 @@ mod tests { } } - // ThrottledInterval tick at the same time as tokio interval, if no sleeps - #[allow(non_snake_case)] - #[tokio::test] - async fn test_RustDesk_interval() { - let base_intervals = [interval_maker, interval_at_maker]; - for maker in base_intervals.into_iter() { - let mut tokio_timer = maker(); - let mut tokio_times = Vec::new(); - let mut timer = rustdesk_interval(maker()); - let mut times = Vec::new(); - loop { - tokio::select! { - _ = timer.tick() => { - if tokio_times.len() >= 10 && times.len() >= 10 { - break; - } - times.push(get_timestamp_secs()); - } - _ = tokio_timer.tick() => { - if tokio_times.len() >= 10 && times.len() >= 10 { - break; - } - tokio_times.push(get_timestamp_secs()); - } - } - } - assert_eq!(times, tokio_times); - } - } - - #[tokio::test] - async fn test_tokio_time_interval_sleep() { - let mut timer = interval_maker(); - let mut times = Vec::new(); - sleep(Duration::from_secs(3)).await; - loop { - tokio::select! { - _ = timer.tick() => { - times.push(get_timestamp_secs()); - if times.len() == 5 { - break; - } - } - } - } - let times2: HashSet = HashSet::from_iter(times.clone()); - assert_eq!(times.len(), times2.len() + 3); - } - // ThrottledInterval tick less times than tokio interval, if there're sleeps #[allow(non_snake_case)] #[tokio::test] @@ -3243,72 +3194,11 @@ mod tests { } // No multiple ticks in the `interval` time. // Values in "times" are unique and are less than normal tokio interval. - // See previous test (test_tokio_time_interval_sleep) for comparison. let times2: HashSet = HashSet::from_iter(times.clone()); assert_eq!(times.len(), times2.len(), "test: {}", i); } } - #[test] - fn test_duration_multiplication() { - let dur = Duration::from_secs(1); - - assert_eq!(dur * 2, Duration::from_secs(2)); - assert_eq!( - Duration::from_secs_f64(dur.as_secs_f64() * 0.9), - Duration::from_millis(900) - ); - assert_eq!( - Duration::from_secs_f64(dur.as_secs_f64() * 0.923), - Duration::from_millis(923) - ); - assert_eq!( - Duration::from_secs_f64(dur.as_secs_f64() * 0.923 * 1e-3), - Duration::from_micros(923) - ); - assert_eq!( - Duration::from_secs_f64(dur.as_secs_f64() * 0.923 * 1e-6), - Duration::from_nanos(923) - ); - assert_eq!( - Duration::from_secs_f64(dur.as_secs_f64() * 0.923 * 1e-9), - Duration::from_nanos(1) - ); - assert_eq!( - Duration::from_secs_f64(dur.as_secs_f64() * 0.5 * 1e-9), - Duration::from_nanos(1) - ); - assert_eq!( - Duration::from_secs_f64(dur.as_secs_f64() * 0.499 * 1e-9), - Duration::from_nanos(0) - ); - } - - #[test] - fn test_is_public() { - // Test URLs containing "rustdesk.com/" - assert!(is_public("https://rustdesk.com/")); - assert!(is_public("https://www.rustdesk.com/")); - assert!(is_public("https://api.rustdesk.com/v1")); - assert!(is_public("https://API.RUSTDESK.COM/v1")); - assert!(is_public("https://rustdesk.com/path")); - - // Test URLs ending with "rustdesk.com" - assert!(is_public("rustdesk.com")); - assert!(is_public("https://rustdesk.com")); - assert!(is_public("https://RustDesk.com")); - assert!(is_public("http://www.rustdesk.com")); - assert!(is_public("https://api.rustdesk.com")); - - // Test non-public URLs - assert!(!is_public("https://example.com")); - assert!(!is_public("https://custom-server.com")); - assert!(!is_public("http://192.168.1.1")); - assert!(!is_public("localhost")); - assert!(!is_public("https://rustdesk.computer.com")); - assert!(!is_public("rustdesk.comhello.com")); - } - #[test] fn test_is_public_matches_rustdesk_root_domain() { assert!(is_public("rustdesk.com/")); @@ -3347,112 +3237,6 @@ mod tests { )); } - #[test] - fn test_should_throttle_log() { - assert!(should_throttle_log("https://example.com/api/heartbeat")); - assert!(should_throttle_log( - "https://example.com/api/heartbeat?token=secret" - )); - assert!(should_throttle_log("https://example.com/prefix/api/heartbeat")); - assert!(!should_throttle_log("https://example.com/api/heartbeat2")); - assert!(!should_throttle_log("https://example.com/api/sysinfo")); - assert!(!should_throttle_log( - "https://example.com/api/sysinfo?next=/api/heartbeat" - )); - assert!(!should_throttle_log("not a url")); - } - - #[test] - fn test_get_tcp_proxy_addr_normalizes_bare_ipv6_host() { - struct RestoreCustomRendezvousServer(String); - - impl Drop for RestoreCustomRendezvousServer { - fn drop(&mut self) { - Config::set_option( - keys::OPTION_CUSTOM_RENDEZVOUS_SERVER.to_string(), - self.0.clone(), - ); - } - } - - let _restore = RestoreCustomRendezvousServer(Config::get_option( - keys::OPTION_CUSTOM_RENDEZVOUS_SERVER, - )); - Config::set_option( - keys::OPTION_CUSTOM_RENDEZVOUS_SERVER.to_string(), - "1:2".to_string(), - ); - - assert_eq!(get_tcp_proxy_addr(), format!("[1:2]:{RENDEZVOUS_PORT}")); - } - - #[tokio::test] - async fn test_http_request_via_tcp_proxy_rejects_invalid_header_json() { - let result = http_request_via_tcp_proxy("not a url", "get", None, "{").await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_http_request_via_tcp_proxy_rejects_non_object_header_json() { - let err = http_request_via_tcp_proxy("not a url", "get", None, "[]") - .await - .unwrap_err() - .to_string(); - assert!(err.contains("HTTP header information parsing failed!")); - } - - #[test] - fn test_parse_json_header_entries_preserves_single_content_type() { - let headers = parse_json_header_entries( - r#"{"Content-Type":"text/plain","Authorization":"Bearer token"}"#, - ) - .unwrap(); - - assert_eq!( - headers - .iter() - .filter(|entry| entry.name.eq_ignore_ascii_case("Content-Type")) - .count(), - 1 - ); - assert_eq!( - headers - .iter() - .find(|entry| entry.name.eq_ignore_ascii_case("Content-Type")) - .map(|entry| entry.value.as_str()), - Some("text/plain") - ); - } - - #[test] - fn test_parse_json_header_entries_does_not_add_default_content_type() { - let headers = parse_json_header_entries(r#"{"Authorization":"Bearer token"}"#).unwrap(); - - assert!(!headers - .iter() - .any(|entry| entry.name.eq_ignore_ascii_case("Content-Type"))); - } - - #[test] - fn test_parse_simple_header_respects_custom_content_type() { - let headers = parse_simple_header("Content-Type: text/plain"); - - assert_eq!( - headers - .iter() - .filter(|entry| entry.name.eq_ignore_ascii_case("Content-Type")) - .count(), - 1 - ); - assert_eq!( - headers - .iter() - .find(|entry| entry.name.eq_ignore_ascii_case("Content-Type")) - .map(|entry| entry.value.as_str()), - Some("text/plain") - ); - } - #[test] fn test_parse_simple_header_preserves_non_content_type_header() { let headers = parse_simple_header("Authorization: Bearer token"); @@ -3477,22 +3261,6 @@ mod tests { ); } - #[test] - fn test_tcp_proxy_log_target_redacts_query_only() { - assert_eq!( - tcp_proxy_log_target("https://example.com/api/heartbeat?token=secret"), - "https://example.com/api/heartbeat" - ); - } - - #[test] - fn test_tcp_proxy_log_target_brackets_ipv6_host_with_port() { - assert_eq!( - tcp_proxy_log_target("https://[2001:db8::1]:21114/api/heartbeat?token=secret"), - "https://[2001:db8::1]:21114/api/heartbeat" - ); - } - #[test] fn test_http_proxy_response_to_json() { let mut resp = HttpProxyResponse { @@ -3521,38 +3289,6 @@ mod tests { assert!(err.contains("TCP proxy error: dial failed")); } - #[test] - fn test_mouse_event_constants_and_mask_layout() { - use super::input::*; - - // Verify MOUSE_TYPE constants are unique and within the mask range. - let types = [ - MOUSE_TYPE_MOVE, - MOUSE_TYPE_DOWN, - MOUSE_TYPE_UP, - MOUSE_TYPE_WHEEL, - MOUSE_TYPE_TRACKPAD, - MOUSE_TYPE_MOVE_RELATIVE, - ]; - - let mut seen = std::collections::HashSet::new(); - for t in types.iter() { - assert!(seen.insert(*t), "Duplicate mouse type: {}", t); - assert_eq!( - *t & MOUSE_TYPE_MASK, - *t, - "Mouse type {} exceeds mask {}", - t, - MOUSE_TYPE_MASK - ); - } - - // The mask layout is: lower 3 bits for type, upper bits for buttons (shifted by 3). - let combined_mask = MOUSE_TYPE_DOWN | ((MOUSE_BUTTON_LEFT | MOUSE_BUTTON_RIGHT) << 3); - assert_eq!(combined_mask & MOUSE_TYPE_MASK, MOUSE_TYPE_DOWN); - assert_eq!(combined_mask >> 3, MOUSE_BUTTON_LEFT | MOUSE_BUTTON_RIGHT); - } - /// A stand-in rendezvous server on loopback: accepts one connection and hands it to `serve`. async fn rendezvous_stub(serve: F) -> String where @@ -3623,105 +3359,6 @@ mod tests { assert!(!conn.is_secured()); } - #[tokio::test] - async fn test_secure_tcp_required_refuses_a_closed_connection() { - let (key, _) = server_key(); - let host = rendezvous_stub(|s| async move { drop(s) }).await; - let mut conn = connect(&host).await; - assert!(secure_tcp_required(&mut conn, &key).await.is_err()); - assert!(!conn.is_secured()); - } - - #[tokio::test] - async fn test_secure_tcp_required_accepts_a_completed_exchange() { - let (key, sk) = server_key(); - let host = rendezvous_stub(move |mut s| async move { - let (eph_pk, eph_sk) = box_::gen_keypair(); - let mut msg = RendezvousMessage::new(); - msg.set_key_exchange(KeyExchange { - keys: vec![sign::sign(&eph_pk.0, &sk).into()], - ..Default::default() - }); - s.send(&msg).await.unwrap(); - // The client's reply must decode to a key with the ephemeral secret half. - let reply = s.next_timeout(3000).await.unwrap().unwrap(); - let reply = RendezvousMessage::parse_from_bytes(&reply).unwrap(); - let Some(rendezvous_message::Union::KeyExchange(ex)) = reply.union else { - panic!("expected the client's key exchange"); - }; - hbb_common::tcp::Encrypt::decode(&ex.keys[1], &ex.keys[0], &eph_sk).unwrap(); - }) - .await; - let mut conn = connect(&host).await; - secure_tcp_required(&mut conn, &key).await.unwrap(); - assert!(conn.is_secured()); - } - - // The stand-in does what hbbs does at version 1: advertises it and splits the exchanged key - // over the same transcript. Neither side's unit tests can catch a client that puts a - // different byte string into the transcript than the server does; only a frame crossing - // between the two can. - #[tokio::test] - async fn test_secure_tcp_version_1_keys_match_the_server_both_ways() { - let (key, sk) = server_key(); - let host = rendezvous_stub(move |mut s| async move { - let (eph_pk, eph_sk) = box_::gen_keypair(); - let mut msg = RendezvousMessage::new(); - msg.set_key_exchange(KeyExchange { - keys: vec![sign::sign(&eph_pk.0, &sk).into()], - version: 1, - ..Default::default() - }); - s.send(&msg).await.unwrap(); - let reply = s.next_timeout(3000).await.unwrap().unwrap(); - let reply = RendezvousMessage::parse_from_bytes(&reply).unwrap(); - let Some(rendezvous_message::Union::KeyExchange(ex)) = reply.union else { - panic!("expected the client's key exchange"); - }; - let shared = - hbb_common::tcp::Encrypt::decode(&ex.keys[1], &ex.keys[0], &eph_sk).unwrap(); - s.set_key_split( - shared, - false, - &hbb_common::tcp::KxTranscript { - initiator_pk: &ex.keys[0], - responder_pk: &eph_pk.0, - advertised: 1, - picked: ex.version, - }, - ) - .unwrap(); - // Answers with the request's serial, so the client learns from its own reply that - // the request was read under the right key. - let request = s.next_timeout(3000).await.unwrap().unwrap(); - let request = RendezvousMessage::parse_from_bytes(&request).unwrap(); - let Some(rendezvous_message::Union::TestNatRequest(nat)) = request.union else { - panic!("expected the client's nat request"); - }; - let mut msg = RendezvousMessage::new(); - msg.set_test_nat_response(TestNatResponse { - port: nat.serial, - ..Default::default() - }); - s.send(&msg).await.unwrap(); - }) - .await; - let mut conn = connect(&host).await; - secure_tcp_required(&mut conn, &key).await.unwrap(); - let mut msg = RendezvousMessage::new(); - msg.set_test_nat_request(TestNatRequest { - serial: 7, - ..Default::default() - }); - conn.send(&msg).await.unwrap(); - let reply = conn.next_timeout(3000).await.unwrap().unwrap(); - let reply = RendezvousMessage::parse_from_bytes(&reply).unwrap(); - let Some(rendezvous_message::Union::TestNatResponse(nat)) = reply.union else { - panic!("expected the server's nat response"); - }; - assert_eq!(nat.port, 7); - } - #[tokio::test] async fn test_secure_tcp_legacy_and_signed_servers_exchange_application_data() { for (advertised, signed) in [(0, false), (1, true), (3, true)] { @@ -3936,11 +3573,4 @@ mod tests { // non-WebRTC handshakes. assert_eq!(decode_id_pk(&signed, &pk).unwrap(), (id, their_pk)); } - - // The route probe is awaited on the connection path, so whatever it finds - an address, or - // no IPv6 route on this machine - it finds without waiting on the network. - #[tokio::test] - async fn test_ipv6_route_probe_does_not_wait_on_the_network() { - assert!(hbb_common::timeout(1_000, test_bind_ipv6()).await.is_ok()); - } } diff --git a/src/core_main.rs b/src/core_main.rs index 424f2c6ef..19a26703a 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -900,59 +900,6 @@ fn parse_silent_install_args(args: &[String]) -> (Option, bool) { (printer_override, debug) } -#[cfg(test)] -mod tests { - use super::*; - - fn args(values: &[&str]) -> Vec { - values.iter().map(|value| value.to_string()).collect() - } - - #[cfg(any(target_os = "linux", target_os = "macos"))] - #[test] - fn user_main_ipc_scope_cli_command_matches_management_commands_only() { - for command in [ - "--password", - "--set-unlock-pin", - "--get-id", - "--set-id", - "--config", - "--option", - "--assign", - "--deploy", - ] { - assert!(is_user_main_ipc_scope_cli_command(&args(&[command]))); - } - - for command in [ - "--service", - "--server", - "--tray", - "--cm", - "--check-hwcodec-config", - "--connect", - ] { - assert!(!is_user_main_ipc_scope_cli_command(&args(&[command]))); - } - } - - #[cfg(target_os = "macos")] - #[test] - fn macos_manual_update_result_preserves_failure() { - let failure = - finish_macos_update(Err(hbb_common::anyhow::anyhow!("administrator canceled"))) - .expect("failure action"); - - match failure { - CoreMainAction::ExitFailure(err) => { - assert_eq!("administrator canceled", err.to_string()); - } - CoreMainAction::StartUi(_) => panic!("update failure started the UI"), - } - assert!(finish_macos_update(Ok(())).is_none()); - } -} - /// Check if the executable is a Quick Support version. /// Note: This function must be kept in sync with `libs/portable/src/main.rs`. #[cfg(windows)] diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index deba7f671..7d7eb007f 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -3104,15 +3104,3 @@ pub mod server_side { jboolean::from(crate::server::is_clipboard_service_ok()) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sync_common_rejects_verified_download_url() { - let result = main_get_common_sync("verified-download-url-".to_owned()); - - assert_eq!(result.0, "error:unsupported"); - } -} diff --git a/src/ipc.rs b/src/ipc.rs index c220730a4..4e86e8f36 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -2268,43 +2268,9 @@ pub async fn set_install_option(k: String, v: String) -> ResultType<()> { #[cfg(test)] mod test { + #[cfg(any(target_os = "linux", target_os = "macos"))] use super::*; - #[test] - fn verify_ffi_enum_data_size() { - println!("{}", std::mem::size_of::()); - assert!(std::mem::size_of::() <= 120); - } - - #[cfg(any(target_os = "linux", target_os = "macos"))] - #[test] - fn test_service_ipc_path_is_shared_across_uids() { - assert_eq!( - Config::ipc_path_for_uid(0, crate::POSTFIX_SERVICE), - Config::ipc_path_for_uid(501, crate::POSTFIX_SERVICE) - ); - } - - #[cfg(any(target_os = "linux", target_os = "macos"))] - #[test] - fn test_ipc_path_differs_by_uid_for_cm() { - let effective_uid = unsafe { hbb_common::libc::geteuid() as u32 }; - let other_uid = effective_uid.saturating_add(1); - let postfix = "_cm"; - - // Default connect path targets the current effective uid. - assert_eq!( - Config::ipc_path(postfix), - Config::ipc_path_for_uid(effective_uid, postfix) - ); - // A different uid yields a different socket path - this is the root cause of the - // cross-user regression when root spawns a user process but still connects as uid 0. - assert_ne!( - Config::ipc_path(postfix), - Config::ipc_path_for_uid(other_uid, postfix) - ); - } - #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn test_select_server_uid_uses_active_uid_when_no_server_found() { @@ -2346,108 +2312,4 @@ mod test { fn test_select_server_uid_fails_when_multiple_servers_are_ambiguous() { assert!(select_server_uid_for_user_main_ipc(&[501, 502], None, false).is_err()); } - - /// The upgrade window: an old root service is still streaming when a new `--server` starts, - /// and its DrmCursor carries no `hot_measured` at all. Read as `false` the consumer would - /// discard a hotspot the producer did measure and re-infer one from the bitmap, which moves - /// the cursor on a rotated display until the old process is replaced. `bool::default()` is - /// the wrong default here; the right one preserves what the old protocol meant. - #[cfg(all(target_os = "linux", feature = "drm"))] - #[test] - fn a_drm_cursor_without_provenance_keeps_the_hotspot_the_producer_sent() { - // `Data` is adjacently tagged (`tag = "t", content = "c"`), so this is the real shape on - // the socket, not a simplified one. - let legacy = - r#"{"t":"DrmCursor","c":{"id":7,"width":24,"height":24,"hotx":12,"hoty":11}}"#; - let msg: Data = serde_json::from_str(legacy).expect("legacy DrmCursor must deserialize"); - match msg { - Data::DrmCursor { - hotx, - hoty, - hot_measured, - .. - } => { - assert_eq!((hotx, hoty), (12, 11)); - assert!( - hot_measured, - "a message with no provenance field must be treated as the old protocol did, \ - i.e. use the hotspot as sent, not re-infer it" - ); - } - other => panic!("expected DrmCursor, got {other:?}"), - } - - // And a NEW producer that really wants re-inference says so, which serializes: the - // default must not swallow an explicit false. - let modern = r#"{"t":"DrmCursor","c":{"id":7,"width":24,"height":24,"hotx":0,"hoty":0,"hot_measured":false}}"#; - match serde_json::from_str::(modern).expect("modern DrmCursor must deserialize") { - Data::DrmCursor { hot_measured, .. } => assert!(!hot_measured), - other => panic!("expected DrmCursor, got {other:?}"), - } - } - - /// A frame from a producer that predates the plane rotation reads as `None`, in the cpu frame - /// header and in the dma-buf descriptor: the root service and the `--server` are upgraded - /// separately, and `None` keeps the previous rule. - #[cfg(all(target_os = "linux", feature = "drm"))] - #[test] - fn a_frame_without_a_plane_rotation_reads_as_none() { - let legacy = r#"{"t":"DrmFrame","c":{"width":8,"height":8}}"#; - match serde_json::from_str::(legacy).expect("legacy DrmFrame must deserialize") { - Data::DrmFrame { plane_rotation, .. } => assert_eq!(plane_rotation, None), - other => panic!("expected DrmFrame, got {other:?}"), - } - let modern = r#"{"t":"DrmFrame","c":{"width":8,"height":8,"plane_rotation":4}}"#; - match serde_json::from_str::(modern).expect("modern DrmFrame must deserialize") { - Data::DrmFrame { plane_rotation, .. } => assert_eq!(plane_rotation, Some(4)), - other => panic!("expected DrmFrame, got {other:?}"), - } - let desc = DmabufDesc { - buffer_id: 0, - width: 4, - height: 4, - format: 0, - modifier: 0, - fb_id: 0, - num_planes: 1, - offsets: [0; 4], - pitches: [16, 0, 0, 0], - hdr_eotf: 0, - hdr_max_nits: 0, - has_fd: false, - plane_rotation: Some(4), - cursor_pos: None, - }; - let mut value = serde_json::to_value(&desc).unwrap(); - assert!(value.as_object_mut().unwrap().remove("plane_rotation").is_some()); - let legacy: DmabufDesc = - serde_json::from_value(value).expect("legacy DmabufDesc must deserialize"); - assert_eq!(legacy.plane_rotation, None); - } - - /// A frame header from a producer that predates the cursor plane position reads as `None`; - /// a producer that sends one is read back exactly. A pin, not a gate: the consumer only ever - /// measures against `Some`. - #[cfg(all(target_os = "linux", feature = "drm"))] - #[test] - fn a_drm_frame_without_a_cursor_position_reads_as_none() { - let legacy = r#"{"t":"DrmFrame","c":{"width":8,"height":8}}"#; - match serde_json::from_str::(legacy).expect("legacy DrmFrame must deserialize") { - Data::DrmFrame { - width, - height, - cursor_pos, - .. - } => { - assert_eq!((width, height), (8, 8)); - assert_eq!(cursor_pos, None); - } - other => panic!("expected DrmFrame, got {other:?}"), - } - let modern = r#"{"t":"DrmFrame","c":{"width":8,"height":8,"cursor_pos":[3,4]}}"#; - match serde_json::from_str::(modern).expect("modern DrmFrame must deserialize") { - Data::DrmFrame { cursor_pos, .. } => assert_eq!(cursor_pos, Some((3, 4))), - other => panic!("expected DrmFrame, got {other:?}"), - } - } } diff --git a/src/ipc/auth.rs b/src/ipc/auth.rs index 6f40ab115..18f75cd49 100644 --- a/src/ipc/auth.rs +++ b/src/ipc/auth.rs @@ -928,37 +928,6 @@ mod tests { )); } - #[test] - #[cfg(windows)] - fn test_should_allow_everyone_create_on_windows_policy() { - assert!(super::should_allow_everyone_create_on_windows("")); - assert!(super::should_allow_everyone_create_on_windows("_service")); - assert!(!super::should_allow_everyone_create_on_windows( - "_portable_service" - )); - } - - #[test] - #[cfg(windows)] - fn test_executable_paths_match_windows_normalization() { - let left = std::path::PathBuf::from(r"\\?\C:\Program Files\RustDesk\RustDesk.exe"); - let right = std::path::PathBuf::from(r"c:\program files\rustdesk\rustdesk.exe"); - assert!(super::executable_paths_match(&left, &right)); - } - - #[test] - #[cfg(target_os = "macos")] - fn test_os_str_eq_ignore_ascii_case_for_process_names() { - assert!(super::os_str_eq_ignore_ascii_case( - Some(std::ffi::OsStr::new("RustDesk")), - Some(std::ffi::OsStr::new("rustdesk")) - )); - assert!(!super::os_str_eq_ignore_ascii_case( - Some(std::ffi::OsStr::new("RustDesk")), - Some(std::ffi::OsStr::new("service")) - )); - } - #[cfg(all(windows, not(feature = "flutter")))] struct TempDirGuard(std::path::PathBuf); @@ -994,44 +963,4 @@ mod tests { "helper trust check must reject path-match-only binaries with mismatched content" ); } - - #[test] - #[cfg(all(windows, not(feature = "flutter")))] - fn test_portable_service_helper_trust_accepts_matching_content() { - let unique = format!( - "rustdesk-portable-helper-trust-match-test-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - ); - let base = std::env::temp_dir().join(unique); - std::fs::create_dir_all(&base).unwrap(); - let _cleanup = TempDirGuard(base.clone()); - - let current_exe = base.join("current.exe"); - let helper_exe = base.join("helper.exe"); - std::fs::write(¤t_exe, b"trusted-binary").unwrap(); - std::fs::write(&helper_exe, b"trusted-binary").unwrap(); - - assert!(super::portable_service_helper_is_trusted( - &helper_exe, - &helper_exe, - ¤t_exe - )); - } - - #[cfg(target_os = "macos")] - #[test] - fn test_console_owner_uid_matches_get_active_userid() { - let console_uid = - super::console_owner_uid().expect("/dev/console must have a resolvable uid"); - let raw_uid = crate::platform::macos::get_active_userid(); - let parsed_uid: u32 = raw_uid - .trim() - .parse() - .unwrap_or_else(|_| panic!("failed to parse get_active_userid() output: '{raw_uid}'")); - assert_eq!(parsed_uid, console_uid); - } } diff --git a/src/ipc/drm.rs b/src/ipc/drm.rs index b129e1109..7565f8f9e 100644 --- a/src/ipc/drm.rs +++ b/src/ipc/drm.rs @@ -219,22 +219,6 @@ mod cursor_pos_tests { stamp_cursor_pos(&mut shape, Some(&visible)); assert!(matches!(&shape, DrmProducerMsg::Cursor { hotx: 1, hoty: 1, .. })); } - - // A producer that predates the field: the descriptor arrives without it and reads as None. - #[test] - fn a_descriptor_without_a_cursor_position_reads_as_none() { - let mut v = serde_json::to_value(desc()).unwrap(); - v.as_object_mut().unwrap().remove("cursor_pos").expect("the field is serialized"); - let d: DmabufDesc = serde_json::from_value(v).expect("a legacy descriptor must deserialize"); - assert_eq!(d.cursor_pos, None); - let d: DmabufDesc = serde_json::from_value(serde_json::to_value(DmabufDesc { - cursor_pos: Some((3, 4)), - ..desc() - }) - .unwrap()) - .unwrap(); - assert_eq!(d.cursor_pos, Some((3, 4))); - } } struct DrmStopGuard(std::sync::Arc); @@ -1887,91 +1871,12 @@ mod drm_conn_tests { use hbb_common::tokio::{self, io::AsyncWriteExt}; use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd}; - // Added to the wire later: an older peer's message must still decode. - #[test] - fn drm_display_info_decodes_without_render_node() { - let legacy = r#"{"name":"DP-1","crtc_id":386,"x":0,"y":0, - "width":3840,"height":2160,"active":true}"#; - let info: DrmDisplayInfo = - serde_json::from_str(legacy).expect("a pre-render_node payload must still decode"); - assert_eq!(info.name, "DP-1"); - assert_eq!(info.crtc_id, 386); - assert!(info.render_node.is_empty(), "missing node; the consumer auto-selects only where there is one render node"); - assert!(info.device.is_empty(), "missing device means auto-detect"); - - let current = DrmDisplayInfo { - name: "DP-1".to_owned(), - crtc_id: 386, - x: 0, - y: 0, - width: 3840, - height: 2160, - active: true, - render_node: "/dev/dri/renderD129".to_owned(), - device: "/dev/dri/card2".to_owned(), - plane_transposed: false, - }; - let wire = serde_json::to_vec(¤t).unwrap(); - let back: DrmDisplayInfo = serde_json::from_slice(&wire).unwrap(); - assert_eq!(back, current); - } - fn pipe() -> (OwnedFd, OwnedFd) { let mut fds = [0 as libc::c_int; 2]; assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe() failed"); unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) } } - unsafe fn send_with_fds(sock: libc::c_int, data: &[u8], fds: &[libc::c_int]) -> isize { - let mut iov = libc::iovec { - iov_base: data.as_ptr() as *mut libc::c_void, - iov_len: data.len(), - }; - let fdbytes = fds.len() * std::mem::size_of::(); - let space = libc::CMSG_SPACE(fdbytes as u32) as usize; - let mut cbuf = vec![0u8; space]; - let mut msg: libc::msghdr = std::mem::zeroed(); - msg.msg_iov = &mut iov; - msg.msg_iovlen = 1; - msg.msg_control = cbuf.as_mut_ptr() as *mut libc::c_void; - msg.msg_controllen = space as _; - let cmsg = libc::CMSG_FIRSTHDR(&msg); - (*cmsg).cmsg_level = libc::SOL_SOCKET; - (*cmsg).cmsg_type = libc::SCM_RIGHTS; - (*cmsg).cmsg_len = libc::CMSG_LEN(fdbytes as u32) as _; - std::ptr::copy_nonoverlapping(fds.as_ptr() as *const u8, libc::CMSG_DATA(cmsg), fdbytes); - libc::sendmsg(sock, &msg, 0) - } - - #[tokio::test] - async fn roundtrip_msg_no_fd() { - let (a, b) = tokio::net::UnixStream::pair().unwrap(); - let mut tx = DrmConn::new(a); - let mut rx = DrmConn::new(b); - tx.send_msg( - &Data::DrmFrame { - width: 1920, - height: 1080, - plane_rotation: None, - cursor_pos: None, - }, - None, - ) - .await - .unwrap(); - let (data, fd) = rx.recv_msg().await.unwrap(); - assert!(matches!( - data, - Data::DrmFrame { - width: 1920, - height: 1080, - plane_rotation: None, - cursor_pos: None - } - )); - assert!(fd.is_none(), "no fd was sent, none must be reported"); - } - #[tokio::test] async fn roundtrip_msg_with_fd_identity() { let (a, b) = tokio::net::UnixStream::pair().unwrap(); @@ -2037,131 +1942,6 @@ mod drm_conn_tests { ); } - #[tokio::test] - async fn a_body_that_never_arrives_times_out() { - let (mut a, b) = tokio::net::UnixStream::pair().unwrap(); - let mut rx = DrmConn::new(b); - a.write_all(&10u32.to_be_bytes()).await.unwrap(); - let mut got = Vec::new(); - let err = rx - .next_raw_into(&mut got) - .await - .err() - .expect("a body that never arrives must time out"); - assert!( - err.to_string().contains("did not arrive"), - "unexpected error: {err}" - ); - } - - #[tokio::test] - async fn a_dripping_peer_cannot_re_arm_the_send_deadline() { - use tokio::io::AsyncReadExt; - let (mut reader, writer) = tokio::net::UnixStream::pair().unwrap(); - let payload = vec![0u8; 32 * 1024 * 1024]; - // Measured: 1 KiB drains do not re-assert POLLOUT; 64 KiB does, which separates the forms. - let drip = tokio::spawn(async move { - let mut sink = vec![0u8; 64 * 1024]; - loop { - if reader.read(&mut sink).await.unwrap_or(0) == 0 { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(250)).await; - } - }); - let started = std::time::Instant::now(); - let outcome = tokio::time::timeout( - std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS * 4), - drm_write_all(&writer, &payload, None), - ) - .await; - drip.abort(); - let inner = outcome.expect( - "the send deadline did not fire: the budget is being re-armed per readiness wait", - ); - let err = inner.err().expect("a dripping peer must not complete the write"); - assert!( - err.to_string().contains("did not accept the remaining"), - "unexpected error: {err}" - ); - assert!( - started.elapsed() < std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS * 3), - "took {:?}, which is not the send deadline firing", - started.elapsed() - ); - } - - #[tokio::test] - async fn surplus_fds_keep_only_the_first() { - let (mut a, b) = tokio::net::UnixStream::pair().unwrap(); - let mut rx = DrmConn::new(b); - let (rd, wr) = pipe(); - let (rd2, _wr2) = pipe(); - let payload = serde_json::to_vec(&Data::DrmFrame { - width: 8, - height: 8, - plane_rotation: None, - cursor_pos: None, - }) - .unwrap(); - let prefix = (payload.len() as u32).to_be_bytes(); - let n = unsafe { send_with_fds(a.as_raw_fd(), &prefix, &[rd.as_raw_fd(), rd2.as_raw_fd()]) }; - assert!(n >= 0, "sendmsg failed: {}", std::io::Error::last_os_error()); - a.write_all(&payload).await.unwrap(); - let (data, fd) = rx.recv_msg().await.unwrap(); - assert!(matches!( - data, - Data::DrmFrame { - width: 8, - height: 8, - plane_rotation: None, - cursor_pos: None - } - )); - let kept = fd.expect("the first surplus fd must be kept"); - let sentinel = [0x5Au8]; - assert_eq!( - unsafe { libc::write(wr.as_raw_fd(), sentinel.as_ptr() as *const libc::c_void, 1) }, - 1 - ); - let mut got = [0u8; 1]; - assert_eq!( - unsafe { libc::read(kept.as_raw_fd(), got.as_mut_ptr() as *mut libc::c_void, 1) }, - 1 - ); - assert_eq!(got[0], 0x5A, "the kept fd must be the FIRST one sent"); - } - - // 16 fds need CMSG_LEN(64)=80 > the 64-byte DRM_CMSG_CAP, so the kernel sets MSG_CTRUNC. - #[tokio::test] - async fn rejects_truncated_control_message() { - let (a, b) = tokio::net::UnixStream::pair().unwrap(); - let mut rx = DrmConn::new(b); - let (rd, _wr) = pipe(); - let dups: Vec = (0..16).map(|_| rd.try_clone().unwrap()).collect(); - let fds: Vec = dups.iter().map(|f| f.as_raw_fd()).collect(); - let prefix = 0u32.to_be_bytes(); // the fds ride the prefix read; CTRUNC fires before any body - let n = unsafe { send_with_fds(a.as_raw_fd(), &prefix, &fds) }; - assert!(n >= 0, "sendmsg failed: {}", std::io::Error::last_os_error()); - let err = rx - .recv_msg() - .await - .err() - .expect("a truncated control message must be rejected"); - let msg = err.to_string().to_lowercase(); - assert!( - msg.contains("truncat") || msg.contains("ctrunc"), - "unexpected error: {err}" - ); - } - - #[test] - fn peer_uid_from_fd_reads_socket_peer() { - let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap(); - let euid = unsafe { libc::geteuid() }; - assert_eq!(peer_uid_from_fd(a.as_raw_fd()), Some(euid)); - } - #[test] fn drm_peer_authorized_matrix() { assert!(drm_peer_authorized(Some(0), Some(1000))); @@ -2213,24 +1993,4 @@ mod drm_conn_tests { assert!(super::ipc_auth::ensure_peer_executable_matches_current_by_pid_opt(None, "_drm").is_err()); } - - #[test] - fn drm_conn_admission_bound() { - assert!(drm_conn_admitted(0)); - assert!(drm_conn_admitted(MAX_DRM_CONNS - 1)); // last admitted slot - assert!(!drm_conn_admitted(MAX_DRM_CONNS)); // cap reached -> rejected - assert!(!drm_conn_admitted(MAX_DRM_CONNS + 5)); // over cap -> rejected - } - - #[test] - fn drm_auth_admission_bound() { - assert!(drm_auth_admitted(0)); - assert!(drm_auth_admitted(MAX_DRM_AUTH_IN_FLIGHT - 1)); // last admitted slot - assert!(!drm_auth_admitted(MAX_DRM_AUTH_IN_FLIGHT)); // cap reached -> rejected - assert!(!drm_auth_admitted(MAX_DRM_AUTH_IN_FLIGHT + 5)); // over cap -> rejected - assert!( - MAX_DRM_AUTH_IN_FLIGHT <= MAX_DRM_CONNS, - "the pre-auth bound must not be looser than the connection cap" - ); - } } diff --git a/src/ipc/fs.rs b/src/ipc/fs.rs index 2472ecc83..039a8eb4e 100644 --- a/src/ipc/fs.rs +++ b/src/ipc/fs.rs @@ -982,48 +982,4 @@ mod tests { std::fs::remove_dir_all(&base).ok(); } - - #[test] - fn test_ensure_secure_ipc_parent_dir_marks_non_service_mode_repair_for_scrub() { - use std::os::unix::fs::PermissionsExt; - - let unique = format!( - "rustdesk-ipc-nonservice-mode-repair-test-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - ); - let base = std::env::temp_dir().join(unique); - std::fs::create_dir_all(&base).unwrap(); - - let parent_dir = base.join("non_service_parent"); - std::fs::create_dir_all(&parent_dir).unwrap(); - std::fs::set_permissions(&parent_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); - - let ipc_file = parent_dir.join("ipc"); - std::fs::write(&ipc_file, b"socket-placeholder").unwrap(); - - let res = super::ensure_secure_ipc_parent_dir(ipc_file.to_string_lossy().as_ref(), ""); - assert_eq!(res.unwrap(), true); - - std::fs::remove_dir_all(&base).ok(); - } - - #[test] - fn test_should_scrub_parent_entries_after_check_pid_only_when_requested_and_not_alive() { - assert!(!super::should_scrub_parent_entries_after_check_pid( - false, false - )); - assert!(!super::should_scrub_parent_entries_after_check_pid( - false, true - )); - assert!(super::should_scrub_parent_entries_after_check_pid( - true, false - )); - assert!(!super::should_scrub_parent_entries_after_check_pid( - true, true - )); - } } diff --git a/src/kcp_stream.rs b/src/kcp_stream.rs index 50a00296f..1c69c1f63 100644 --- a/src/kcp_stream.rs +++ b/src/kcp_stream.rs @@ -275,40 +275,4 @@ mod tests { } assert_eq!(got, N, "graceful close lost frames"); } - - // Socket errors on the connected UDP socket (ICMP unreachable after the peer - // vanishes) are advisory: the io loop must treat them as loss - keep accepting - // writes, keep running - rather than tearing the session down. Whether the OS - // actually surfaces ECONNREFUSED here is platform-dependent; either way the - // session must stay alive for this window. - #[tokio::test] - async fn test_kcp_io_treats_socket_errors_as_loss() { - let ((_guard_a, mut stream_a), (guard_b, stream_b)) = establish().await; - - // Kill the peer entirely: endpoint stops, socket closes. - drop(stream_b); - drop(guard_b); - tokio::time::sleep(Duration::from_millis(50)).await; - - for _ in 0..10 { - stream_a - .send_bytes(Bytes::from_static(b"into the void")) - .await - .expect("socket errors must be treated as loss, not stream failure"); - tokio::time::sleep(Duration::from_millis(20)).await; - } - } - - // The connect deadline must hold when nothing answers: no hang, prompt error. - #[tokio::test] - async fn test_kcp_connect_timeout_without_peer() { - let (a, _b) = connected_pair().await; - let start = tokio::time::Instant::now(); - let res = KcpStream::connect(a, Duration::from_millis(600)).await; - assert!(res.is_err(), "connect must fail with no peer endpoint"); - assert!( - start.elapsed() < Duration::from_secs(5), - "connect did not honor its deadline" - ); - } } diff --git a/src/lang.rs b/src/lang.rs index c0021fa73..6f02a7ae5 100644 --- a/src/lang.rs +++ b/src/lang.rs @@ -336,16 +336,6 @@ mod test { ); } - #[test] - fn test_resolve_lang_forces_english_for_saved_cjk_when_target_disables_cjk() { - use super::resolve_lang as f; - - assert_eq!(f("zh-cn", "en-US", true), "en"); - assert_eq!(f("zh-tw", "en-US", true), "en"); - assert_eq!(f("ja", "en-US", true), "en"); - assert_eq!(f("ko", "en-US", true), "en"); - } - #[test] fn test_resolve_lang_forces_english_for_cjk_locale_when_target_disables_cjk() { use super::resolve_lang as f; @@ -375,12 +365,4 @@ mod test { assert_eq!(f("", "pt-AO", false), "pt-pt"); assert_eq!(f("", "pt-MZ", false), "pt-pt"); } - - #[test] - fn test_resolve_lang_saved_lang_takes_precedence_over_locale() { - use super::resolve_lang as f; - - assert_eq!(f("pt-br", "pt-PT", false), "pt-br"); - assert_eq!(f("pt-pt", "pt-BR", false), "pt-pt"); - } } diff --git a/src/manual_update.rs b/src/manual_update.rs index d903f5864..4476ec0b2 100644 --- a/src/manual_update.rs +++ b/src/manual_update.rs @@ -183,25 +183,6 @@ mod tests { ); } - #[test] - fn rejects_partial_reordered_or_extra_macos_update_arguments() { - for invalid in [ - args(&["--update", "update.zip"]), - args(&["--update", "update.dmg", "--metadata", "update.json"]), - args(&[ - "--update", - "update.dmg", - "--signature", - "update.sig", - "--metadata", - "update.json", - ]), - args(&["--update", "update.dmg", "unexpected"]), - ] { - assert!(parse_macos_update_args(&invalid).is_err(), "{invalid:?}"); - } - } - #[test] fn resolves_public_and_custom_update_package_identity() { assert_eq!( diff --git a/src/platform/linux.rs b/src/platform/linux.rs index e45b4ef5e..7234dc6d2 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -251,19 +251,6 @@ mod gnome_monitor_layout_tests { Some(GnomeMonitorLayoutMode::Logical) ); } - - #[test] - fn maps_physical_layout() { - assert_eq!( - gnome_monitor_layout_mode_from_value(2), - Some(GnomeMonitorLayoutMode::Physical) - ); - } - - #[test] - fn rejects_unknown_layout() { - assert_eq!(gnome_monitor_layout_mode_from_value(4), None); - } } #[inline] @@ -2467,29 +2454,6 @@ mod desktop { } } } - - #[cfg(test)] - mod tests { - use super::*; - - #[test] - fn test_desktop_env() { - let mut d = Desktop::default(); - d.refresh(); - if d.username == "root" { - assert_eq!(d.home, "/root"); - } else { - if !d.username.is_empty() { - let home = super::super::get_env_var("HOME"); - if !home.is_empty() { - assert_eq!(d.home, home); - } else { - // - } - } - } - } - } } /// A session-bus idle-inhibit interface, tried in order; the first that answers wins. diff --git a/src/platform/macos/update_temp.rs b/src/platform/macos/update_temp.rs index 52bd4626b..8f00317ed 100644 --- a/src/platform/macos/update_temp.rs +++ b/src/platform/macos/update_temp.rs @@ -228,21 +228,4 @@ mod tests { std::fs::remove_dir_all(test_dir).unwrap(); } - #[test] - fn stale_update_temp_dir_name_requires_owned_randomized_dir() { - let euid = unsafe { hbb_common::libc::geteuid() }; - - assert!(is_stale_update_temp_dir_name(&format!( - ".rustdeskupdate-{euid}-123-456" - ))); - for name in [ - format!(".rustdeskupdate-{euid}"), - format!(".rustdeskupdate-{euid}-abc-456"), - format!(".rustdeskupdate-{euid}-123"), - ".rustdeskupdate-999999-123-456".to_owned(), - "rustdeskupdate-123-456".to_owned(), - ] { - assert!(!is_stale_update_temp_dir_name(&name), "{name}"); - } - } } diff --git a/src/platform/macos/verified_dmg_tests.rs b/src/platform/macos/verified_dmg_tests.rs index 8c9ca0e2e..eb1b21435 100644 --- a/src/platform/macos/verified_dmg_tests.rs +++ b/src/platform/macos/verified_dmg_tests.rs @@ -1,7 +1,7 @@ use super::{ verified_dmg::{ - copy_and_verify_dmg_file, create_dmg_mount_point, extract_dmg_inner, verified_dmg_path, - verify_stored_dmg, VerifiedDmg, + copy_and_verify_dmg_file, extract_dmg_inner, verified_dmg_path, verify_stored_dmg, + VerifiedDmg, }, verified_dmg_update_source, ResultType, }; @@ -44,20 +44,6 @@ fn extract_verified_dmg(verified_dmg: &VerifiedDmg, target_dir: &str) -> ResultT extract_dmg_inner(verified_dmg_path(verified_dmg)?, target_dir) } -#[test] -fn update_dmg_mount_points_are_unique() { - let first = create_dmg_mount_point().unwrap(); - let second = create_dmg_mount_point().unwrap(); - let are_unique = first != second; - for mount_point in [&first, &second] { - if mount_point.starts_with("/tmp/.rustdeskmount-") { - std::fs::remove_dir(mount_point).unwrap(); - } - } - - assert!(are_unique); -} - #[test] fn verified_dmg_extracts_from_named_copy() { let test_dir = std::env::temp_dir().join(format!( @@ -175,34 +161,3 @@ fn verified_dmg_rejects_symlink() { .to_string() .contains("not a regular file")); } - -#[test] -fn verified_dmg_handle_survives_path_replacement() { - use std::io::{Read as _, Seek as _}; - - let test_dir = std::env::temp_dir().join(format!( - "rustdesk-verified-dmg-handle-test-{}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&test_dir); - std::fs::create_dir_all(&test_dir).unwrap(); - let file_path = test_dir.join("update.dmg"); - let original_path = test_dir.join("original.dmg"); - std::fs::write(&file_path, b"rustdesk").unwrap(); - - let mut verified_dmg = copy_and_verify_dmg_file( - &file_path.to_string_lossy(), - "304ca1638c5effa6832e0e15b958a8f74847efe4df9c3f3187216e921c168fed", - Some(8), - ) - .unwrap(); - std::fs::rename(&file_path, &original_path).unwrap(); - std::fs::write(&file_path, b"tampered").unwrap(); - - verified_dmg.file.seek(std::io::SeekFrom::Start(0)).unwrap(); - let mut contents = Vec::new(); - verified_dmg.file.read_to_end(&mut contents).unwrap(); - - std::fs::remove_dir_all(test_dir).unwrap(); - assert_eq!(contents, b"rustdesk"); -} diff --git a/src/platform/mobile_wheel.rs b/src/platform/mobile_wheel.rs index ac5ffca42..bce3531cc 100644 --- a/src/platform/mobile_wheel.rs +++ b/src/platform/mobile_wheel.rs @@ -15,18 +15,4 @@ mod tests { assert_eq!(mobile_wheel_delta((2, -3), "Y"), (-2, 3)); assert_eq!(mobile_wheel_delta((0, 4), "Y"), (0, -4)); } - - #[test] - fn preserves_both_axes_when_disabled() { - assert_eq!(mobile_wheel_delta((2, -3), "N"), (2, -3)); - assert_eq!(mobile_wheel_delta((2, -3), ""), (2, -3)); - } - - #[test] - fn reversing_minimum_delta_does_not_overflow() { - assert_eq!( - mobile_wheel_delta((i32::MIN, i32::MIN), "Y"), - (i32::MAX, i32::MAX) - ); - } } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 665c4fc51..2be25e4d0 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -236,11 +236,10 @@ pub fn get_pids_of_process_with_first_arg, S2: AsRef>( } } -#[cfg(test)] +#[cfg(all(test, any(windows, target_os = "macos")))] mod tests { use super::*; - #[cfg(any(windows, target_os = "macos"))] #[test] fn install_app_names_enforce_ascii_command_safety() { for app_name in [ @@ -263,45 +262,4 @@ mod tests { #[cfg(target_os = "macos")] assert!(validate_install_app_name("-RustDesk").is_err()); } - - #[test] - fn test_cursor_data() { - for _ in 0..30 { - if let Some(hc) = get_cursor().unwrap() { - let cd = get_cursor_data(hc).unwrap(); - repng::encode( - std::fs::File::create("cursor.png").unwrap(), - cd.width as _, - cd.height as _, - &cd.colors[..], - ) - .unwrap(); - } - #[cfg(target_os = "macos")] - macos::is_process_trusted(false); - } - // A macOS capture takes the cursor shown, whatever seed it is asked for. - #[cfg(target_os = "macos")] - { - macos::reset_input_cache(); - if let Some(change) = get_cursor().unwrap() { - assert!(get_cursor_data(change.wrapping_add(1)).is_ok()); - } - } - } - #[test] - fn test_get_cursor_pos() { - for _ in 0..30 { - assert!(!get_cursor_pos().is_none()); - } - } - - #[cfg(not(any(target_os = "android", target_os = "ios")))] - #[test] - fn test_resolution() { - let name = r"\\.\DISPLAY1"; - println!("current:{:?}", current_resolution(name)); - println!("change:{:?}", change_resolution(name, 2880, 1800)); - println!("resolutions:{:?}", resolutions(name)); - } } diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 45fec0c0e..ff0f5cf98 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -4754,119 +4754,6 @@ pub(super) fn get_pids_with_first_arg_by_wmic, S2: AsRef>( mod tests { use super::*; - // Test-only reusable Win32 HANDLE RAII helper. - // If a future non-test path needs the same pattern, move it out of this test module. - // - // This struct is similar to `base::platform::windows::RAIIHandle`, - // but `RAIIHandle` depends on `WinApi` crate, while this `HandleGuard` only depends on `windows` crate. - struct HandleGuard(WinHANDLE); - - impl HandleGuard { - #[inline] - fn new(handle: WinHANDLE) -> Self { - Self(handle) - } - - #[inline] - fn get(&self) -> WinHANDLE { - self.0 - } - } - - impl Drop for HandleGuard { - fn drop(&mut self) { - unsafe { - if !self.0.is_invalid() { - let _ = WinCloseHandle(self.0); - } - } - } - } - - #[test] - fn test_is_process_running_as_system_invalid_pid_errors() { - assert!(is_process_running_as_system(u32::MAX).is_err()); - } - - #[test] - fn test_is_process_running_as_system_matches_current_process_token_user() { - let pid = unsafe { windows::Win32::System::Threading::GetCurrentProcessId() }; - let actual = is_process_running_as_system(pid).unwrap(); - - let expected = unsafe { - // Keep this test consistent: use only the `windows` crate APIs/types. - let process = HandleGuard::new( - WinOpenProcess(WIN_PROCESS_QUERY_LIMITED_INFORMATION, false, pid) - .expect("WinOpenProcess should succeed for current process"), - ); - let mut token = WinHANDLE::default(); - WinOpenProcessToken(process.get(), WIN_TOKEN_QUERY, &mut token) - .expect("WinOpenProcessToken should succeed for current process"); - let token = HandleGuard::new(token); - - let mut token_user_size = 0u32; - let _ = WinGetTokenInformation(token.get(), TokenUser, None, 0, &mut token_user_size); - assert_ne!(token_user_size, 0, "TokenUser size should be non-zero"); - - let mut buffer = vec![0u8; token_user_size as usize]; - WinGetTokenInformation( - token.get(), - TokenUser, - Some(buffer.as_mut_ptr() as *mut core::ffi::c_void), - token_user_size, - &mut token_user_size, - ) - .expect("WinGetTokenInformation(TokenUser) should succeed for current process"); - - let min_size = std::mem::size_of::(); - assert!( - buffer.len() >= min_size, - "TokenUser buffer too small (got {}, need >= {})", - buffer.len(), - min_size - ); - let token_user: TOKEN_USER = - std::ptr::read_unaligned(buffer.as_ptr() as *const TOKEN_USER); - let expected = IsWellKnownSid(token_user.User.Sid, WinLocalSystemSid).as_bool(); - expected - }; - - assert_eq!(actual, expected); - } - - #[test] - fn test_uninstall_cert() { - println!("uninstall driver certs: {:?}", cert::uninstall_cert()); - } - - #[test] - fn test_get_unicode_char_by_vk() { - let chr = get_char_from_vk(0x41); // VK_A - assert_eq!(chr, Some('a')); - let chr = get_char_from_vk(VK_ESCAPE as u32); // VK_ESC - assert_eq!(chr, None) - } - - #[test] - fn install_app_names_enforce_ascii_command_safety() { - assert!(validate_install_app_name("RustDesk-Admin1").is_ok()); - for app_name in [ - "", - "RustDesk_Admin", - "RustDesk&whoami", - "RustDesk应用", - "RustDesk앱", - "RustDeskBüro", - "RustDeskFranƧais", - "RustDeskć‚¢ćƒ—ćƒŖ", - ] { - assert!( - validate_install_app_name(app_name).is_err(), - "unsafe application name was accepted: {app_name}" - ); - } - } - #[test] fn vbs_files_use_utf16le_with_bom_and_crlf() { const EXPECTED: &[u8] = &[0xFF, 0xFE, b'a', 0, b'\r', 0, b'\n', 0, b'b', 0]; diff --git a/src/platform/windows/acl.rs b/src/platform/windows/acl.rs index 682e66fed..470c2fe5e 100644 --- a/src/platform/windows/acl.rs +++ b/src/platform/windows/acl.rs @@ -785,34 +785,6 @@ mod tests { let _ = fs::remove_dir_all(&root); } - #[test] - fn test_portable_service_shmem_dir_acl_rejects_file_target() { - let dir = unique_acl_test_path("dir_target_file"); - fs::create_dir_all(&dir).unwrap(); - let file = dir.join("target.txt"); - fs::write(&file, b"x").unwrap(); - let result = set_path_permission_for_portable_service_shmem_dir(&file); - assert!(result.is_err()); - let _ = fs::remove_file(&file); - let _ = fs::remove_dir_all(&dir); - } - - #[test] - fn test_portable_service_shmem_file_acl_rejects_dir_target() { - let dir = unique_acl_test_path("file_target_dir"); - fs::create_dir_all(&dir).unwrap(); - let result = set_path_permission_for_portable_service_shmem_file(&dir); - assert!(result.is_err()); - let _ = fs::remove_dir_all(&dir); - } - - #[test] - fn test_portable_service_shmem_file_acl_rejects_missing_target() { - let path = unique_acl_test_path("missing").join("shared_memory_missing"); - let result = set_path_permission_for_portable_service_shmem_file(&path); - assert!(result.is_err()); - } - #[test] fn test_set_path_permission_rejects_reparse_entrypoint() { let root = unique_acl_test_path("reparse_entry"); diff --git a/src/platform/windows/verified_update.rs b/src/platform/windows/verified_update.rs index 1cfe95044..634ff1548 100644 --- a/src/platform/windows/verified_update.rs +++ b/src/platform/windows/verified_update.rs @@ -219,43 +219,3 @@ fn schedule_file_removal_on_reboot(path: &Path) -> ResultType<()> { })?; Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn schedules_cleanup_for_verified_exe_only() { - assert!(should_schedule_verified_update_cleanup(Path::new( - r"C:\Temp\rustdesk-verified-123-456.exe" - ))); - for path in [ - r"C:\Temp\rustdesk-verified-123-456.msi", - r"C:\Temp\rustdesk.exe", - r"C:\Temp\rustdesk-verified-123-456.exe.bak", - ] { - assert!(!should_schedule_verified_update_cleanup(Path::new(path))); - } - } - - #[test] - fn failed_verified_update_launch_clears_staged_custom_client_file() { - let staging_dir = std::env::temp_dir().join(format!( - "rustdesk-update-staging-cleanup-test-{}-{}", - std::process::id(), - hbb_common::rand::random::() - )); - std::fs::create_dir_all(&staging_dir).unwrap(); - let staged_file = staging_dir.join("custom.txt"); - std::fs::write(&staged_file, b"custom client").unwrap(); - let successful_launch: ResultType<()> = Ok(()); - clear_custom_client_staging_after_launch_failure(&staging_dir, &successful_launch); - assert!(staged_file.exists()); - let launch_result: ResultType<()> = Err(hbb_common::anyhow::anyhow!("launch failed")); - - clear_custom_client_staging_after_launch_failure(&staging_dir, &launch_result); - - assert!(!staged_file.exists()); - std::fs::remove_dir_all(staging_dir).unwrap(); - } -} diff --git a/src/port_forward.rs b/src/port_forward.rs index d1f4df434..05d64ed5a 100644 --- a/src/port_forward.rs +++ b/src/port_forward.rs @@ -733,59 +733,6 @@ mod login_tests { }); } - #[test] - fn a_password_typed_before_this_connections_hash_answers_it_when_it_comes() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - rt.block_on(async { - let ui = window(); - let (mut b, mut b_peer) = loopback().await; - // The prompt's password reached B before its hash, and no other - // mapping has stored it in the handler yet. - let typed = (String::new(), String::new(), "pw".to_owned(), false); - assert!(hash_arrived(&ui, "", hash("b"), Some(typed), "b", 2, false, &mut b).await); - let lr = login_at(&mut b_peer).await; - assert_eq!(lr.password, digest("b")); - assert_eq!(target(&lr), ("b".to_owned(), 2)); - }); - } - - #[test] - fn a_raw_pipe_login_on_a_multiplexed_window_does_not_ask_for_the_tunnel() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - rt.block_on(async { - let ui = window(); - // The window probes for the tunnel, but this mapping latched to - // the raw pipe: its login must read as the raw pipe's, or an - // upgraded peer answers with a tunnel it then never gets. - ui.lc.write().unwrap().port_forward_mux = true; - let (mut a, mut a_peer) = loopback().await; - assert!(login_with_hash(&ui, "pw", hash("a"), "a", 1, false, &mut a).await); - assert!(!login_at(&mut a_peer).await.port_forward().multiplex); - }); - } - - #[test] - fn a_password_typed_at_the_prompt_keeps_a_raw_pipe_login_raw() { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - rt.block_on(async { - let ui = window(); - ui.lc.write().unwrap().port_forward_mux = true; - let (mut b, mut b_peer) = loopback().await; - let typed = (String::new(), String::new(), "pw".to_owned(), false); - login_from_ui(&ui, &hash("b"), typed, "b", 2, false, &mut b).await; - assert!(!login_at(&mut b_peer).await.port_forward().multiplex); - }); - } - #[test] fn a_probing_login_asks_for_the_tunnel() { let rt = tokio::runtime::Builder::new_current_thread() @@ -800,56 +747,3 @@ mod login_tests { }); } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn peer_supports_mux_reads_the_features_bit() { - let mut pi = PeerInfo::new(); - assert!(!peer_supports_mux(&pi)); - pi.features = Some(Features { port_forward_mux: false, ..Default::default() }).into(); - assert!(!peer_supports_mux(&pi)); - pi.features = Some(Features { port_forward_mux: true, ..Default::default() }).into(); - assert!(peer_supports_mux(&pi)); - } - - #[test] - fn port_forward_mux_defaults_to_on() { - use hbb_common::config::option2bool; - use base::config::keys; - // option2bool's fallback branch is also "on unless N", so the value - // assertions below would pass for a prefixless key too. The `enable-` - // prefix is what actually guarantees the default, and renaming the key - // to an `allow-` one would silently flip it — pin the prefix itself. - assert!(keys::OPTION_ENABLE_PORT_FORWARD_MUX.starts_with("enable-")); - assert!(option2bool(keys::OPTION_ENABLE_PORT_FORWARD_MUX, "")); - assert!(option2bool(keys::OPTION_ENABLE_PORT_FORWARD_MUX, "Y")); - assert!(!option2bool(keys::OPTION_ENABLE_PORT_FORWARD_MUX, "N")); - } - - #[test] - fn take_socket_hands_back_a_working_socket_and_the_prebuf() { - use hbb_common::tokio::io::{AsyncReadExt, AsyncWriteExt}; - let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); - rt.block_on(async { - let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = l.local_addr().unwrap(); - let mut client = TcpStream::connect(addr).await.unwrap(); - let (server, _) = l.accept().await.unwrap(); - let mut framed = Framed::new(server, BytesCodec::new()); - client.write_all(b"abc").await.unwrap(); - // Read through the codec, as connect_and_login does during login. - let pulled = framed.next().await.unwrap().unwrap(); - assert_eq!(&pulled[..], b"abc"); - let (mut sock, prebuf) = take_socket(framed, pulled.to_vec()); - assert_eq!(prebuf, b"abc".to_vec()); - // Bytes written after the handoff arrive on the bare socket. - client.write_all(b"def").await.unwrap(); - let mut buf = [0u8; 3]; - sock.read_exact(&mut buf).await.unwrap(); - assert_eq!(&buf, b"def"); - }); - } -} diff --git a/src/port_forward_mux.rs b/src/port_forward_mux.rs index 4f0d31b80..32287952a 100644 --- a/src/port_forward_mux.rs +++ b/src/port_forward_mux.rs @@ -84,11 +84,6 @@ impl RecvWindow { self.remaining = self.remaining.saturating_add(n); } - #[cfg(test)] - pub fn remaining(&self) -> u32 { - self.remaining - } - /// Returns the amount to advertise in a `window_update` once enough has /// been drained; the same amount is credited back. pub fn drained(&mut self, n: usize) -> Option { @@ -438,7 +433,7 @@ mod tunnel { const REPORT_AGAIN_AFTER: Duration = Duration::from_secs(10); /// Distinct reasons remembered at once, so the dialog count stays bounded /// however the peer varies the text it sends. - pub(super) const MAX_REPORTED_OPEN_ERRORS: usize = 8; + const MAX_REPORTED_OPEN_ERRORS: usize = 8; // Internal state only; `Claim` is the API listeners see. enum TunnelState { @@ -661,7 +656,7 @@ mod tunnel { self.first_report_at(message, Instant::now()) } - pub(super) fn first_report_at(&self, message: String, now: Instant) -> Option { + fn first_report_at(&self, message: String, now: Instant) -> Option { if message.is_empty() { return None; } @@ -689,11 +684,6 @@ mod tunnel { pub fn live_channels(&self) -> usize { self.channels.lock().unwrap().len() } - - #[cfg(test)] - pub fn set_next_id(&self, id: i32) { - self.next_id.store(id, Ordering::Relaxed); - } } /// The only task that touches the stream. The three arms keep tokio's @@ -768,23 +758,6 @@ mod tests { .unwrap() } - #[test] - fn effective_window_clamps_to_initial() { - assert_eq!(effective_window(0), INITIAL_WINDOW); - assert_eq!(effective_window(INITIAL_WINDOW - 1), INITIAL_WINDOW); - assert_eq!(effective_window(INITIAL_WINDOW), INITIAL_WINDOW); - assert_eq!(effective_window(CHANNEL_WINDOW), CHANNEL_WINDOW); - } - - #[test] - fn recv_window_rejects_over_window_data() { - // `new` clamps its grant to INITIAL_WINDOW, so the test must fill that. - let mut w = RecvWindow::new(INITIAL_WINDOW); - assert!(w.accept(INITIAL_WINDOW as usize - MIN_FRAME_CHARGE as usize)); - assert!(w.accept(MIN_FRAME_CHARGE as usize)); - assert!(!w.accept(1)); - } - #[test] fn tiny_frames_are_charged_at_the_minimum() { let mut w = RecvWindow::new(INITIAL_WINDOW); @@ -795,20 +768,6 @@ mod tests { assert!(!w.accept(1)); } - #[test] - fn recv_window_updates_only_past_threshold() { - let mut w = RecvWindow::new(CHANNEL_WINDOW); - assert_eq!( - w.drained(UPDATE_THRESHOLD as usize - MIN_FRAME_CHARGE as usize), - None - ); - assert_eq!(w.drained(MIN_FRAME_CHARGE as usize), Some(UPDATE_THRESHOLD)); - // The update re-grants what was drained, so the same amount is accepted again. - assert!(w.accept(CHANNEL_WINDOW as usize)); - assert!(w.accept(UPDATE_THRESHOLD as usize)); - assert!(!w.accept(1)); - } - #[test] fn accounting_survives_a_transfer_far_larger_than_the_window() { // Cumulative counters used to overflow around 4 GiB on one channel and @@ -822,16 +781,6 @@ mod tests { } } - #[test] - fn grant_extends_a_window_that_has_been_used_up() { - let mut w = RecvWindow::new(INITIAL_WINDOW); - assert!(w.accept(INITIAL_WINDOW as usize)); - assert!(!w.accept(1)); - w.grant(CHANNEL_WINDOW - INITIAL_WINDOW); - assert!(w.accept((CHANNEL_WINDOW - INITIAL_WINDOW) as usize)); - assert!(!w.accept(1)); - } - #[test] fn send_credit_blocks_at_zero_and_resumes_on_add() { rt().block_on(async { @@ -890,45 +839,17 @@ mod tests { }); } - #[test] - fn frame_builders_set_the_expected_union_variant() { - use base::message_proto::{message, port_forward_channel}; - let m = data_msg(7, Bytes::from_static(b"abc")); - match m.union { - Some(message::Union::PortForwardChannel(ch)) => match ch.union { - Some(port_forward_channel::Union::Data(d)) => { - assert_eq!(d.channel_id, 7); - assert_eq!(d.data, b"abc".to_vec()); - } - other => panic!("unexpected {:?}", other), - }, - other => panic!("unexpected {:?}", other), - } - let m = opened_msg(3, false, "nope", CHANNEL_WINDOW); - match m.union { - Some(message::Union::PortForwardChannel(ch)) => match ch.union { - Some(port_forward_channel::Union::Opened(o)) => { - assert_eq!((o.channel_id, o.success, o.message.as_str(), o.window), - (3, false, "nope", CHANNEL_WINDOW)); - } - other => panic!("unexpected {:?}", other), - }, - other => panic!("unexpected {:?}", other), - } - } - use base::message_proto::{message, port_forward_channel}; use hbb_common::tokio::{self, io::AsyncReadExt, io::AsyncWriteExt, sync::mpsc}; use std::sync::{Arc, Mutex}; struct Harness { data_rx: mpsc::Receiver, - control_rx: mpsc::UnboundedReceiver, - inbound_tx: mpsc::UnboundedSender, + _control_rx: mpsc::UnboundedReceiver, + _inbound_tx: mpsc::UnboundedSender, credit: Arc, - window: Arc>, local: tokio::io::DuplexStream, - teardown: watch::Sender, + _teardown: watch::Sender, task: tokio::task::JoinHandle<()>, } @@ -938,8 +859,6 @@ mod tests { harness_on(id, prebuf, initial_out, watch::channel(false).0) } - /// The channel subscribes to `teardown` here, so a test can hand in one - /// that has already been raised. fn harness_on(id: i32, prebuf: Vec, initial_out: Vec, teardown: watch::Sender) -> Harness { let (data_tx, data_rx) = mpsc::channel(DATA_QUEUE_FRAMES); let (control_tx, control_rx) = mpsc::unbounded_channel(); @@ -951,9 +870,9 @@ mod tests { let sink = FrameSink::Queued { data: data_tx, control: control_tx }; let teardown_rx = teardown.subscribe(); let task = tokio::spawn(run_channel( - id, r, w, prebuf, initial_out, credit.clone(), window.clone(), inbound_rx, sink, teardown_rx, + id, r, w, prebuf, initial_out, credit.clone(), window, inbound_rx, sink, teardown_rx, )); - Harness { data_rx, control_rx, inbound_tx, credit, window, local, teardown, task } + Harness { data_rx, _control_rx: control_rx, _inbound_tx: inbound_tx, credit, local, _teardown: teardown, task } } // `PortForwardData.data` is generated as `bytes::Bytes` (hbb_common builds @@ -979,74 +898,6 @@ mod tests { } } - #[test] - fn teardown_ends_a_channel_parked_on_a_socket_nobody_reads() { - rt().block_on(async { - // Nothing reads the local side, so once the duplex buffer is full - // the socket relay parks in write_all; nothing writes it either, - // so the tunnel relay parks in read. Neither is on the inbound - // queue, which stays open here: teardown alone must end them. - let mut h = harness(1, vec![], vec![]); - for _ in 0..17 { - h.inbound_tx.send(Inbound::Data(Bytes::from(vec![0u8; MAX_FRAME]))).unwrap(); - } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - h.teardown.send_replace(true); - let ended = tokio::time::timeout(std::time::Duration::from_millis(500), &mut h.task).await; - assert!(ended.is_ok(), "channel task outlived the tunnel"); - }); - } - - #[test] - fn a_channel_subscribed_after_teardown_ends_at_once() { - rt().block_on(async { - // `open` can race `close_all`: this channel subscribes after the - // signal went out, and its entry sits in a map that was already - // cleared, so nothing will ever drop its inbound sender. No other - // channel was live when the tunnel closed, either. - let teardown = watch::channel(false).0; - teardown.send_replace(true); - let mut h = harness_on(1, vec![], vec![], teardown); - let ended = tokio::time::timeout(std::time::Duration::from_millis(500), &mut h.task).await; - assert!(ended.is_ok(), "late channel outlived the tunnel"); - }); - } - - #[test] - fn local_bytes_become_data_frames_capped_at_max_frame() { - rt().block_on(async { - let mut h = harness(1, vec![], vec![]); - let payload = vec![7u8; MAX_FRAME + 10]; - h.local.write_all(&payload).await.unwrap(); - // INITIAL_WINDOW equals MAX_FRAME, so the first frame exhausts - // it exactly; grant one minimum charge for the 10-byte tail. - h.credit.add(MIN_FRAME_CHARGE); - let mut got = Vec::new(); - while got.len() < payload.len() { - let m = h.data_rx.recv().await.unwrap(); - let (kind, id, bytes) = frame_kind(&m); - assert_eq!((kind, id), ("data", 1)); - assert!(bytes.len() <= MAX_FRAME); - got.extend(bytes); - } - assert_eq!(got, payload); - }); - } - - #[test] - fn prebuf_is_the_head_of_the_send_stream() { - rt().block_on(async { - let mut h = harness(2, b"head".to_vec(), vec![]); - h.local.write_all(b"tail").await.unwrap(); - let mut got = Vec::new(); - while got.len() < 8 { - let m = h.data_rx.recv().await.unwrap(); - got.extend(frame_kind(&m).2); - } - assert_eq!(got, b"headtail".to_vec()); - }); - } - #[test] fn send_side_stops_at_credit_and_resumes_on_add() { rt().block_on(async { @@ -1070,27 +921,6 @@ mod tests { }); } - #[test] - fn inbound_data_is_written_and_window_update_follows_threshold() { - rt().block_on(async { - let mut h = harness(4, vec![], vec![Bytes::from_static(b"first")]); - let mut buf = [0u8; 5]; - h.local.read_exact(&mut buf).await.unwrap(); - assert_eq!(&buf, b"first"); - let chunk = Bytes::from(vec![9u8; UPDATE_THRESHOLD as usize]); - assert!(h.window.lock().unwrap().accept(chunk.len())); - h.inbound_tx.send(Inbound::Data(chunk.clone())).unwrap(); - let mut sink = vec![0u8; chunk.len()]; - h.local.read_exact(&mut sink).await.unwrap(); - let m = h.control_rx.recv().await.unwrap(); - let (kind, id, add) = frame_kind(&m); - assert_eq!((kind, id), ("window_update", 4)); - let add = u32::from_le_bytes([add[0], add[1], add[2], add[3]]); - // The 5-byte `initial` chunk drained a whole minimum charge. - assert_eq!(add, UPDATE_THRESHOLD + MIN_FRAME_CHARGE); - }); - } - #[test] fn local_eof_sends_close_exactly_once_after_the_data() { rt().block_on(async { @@ -1104,41 +934,10 @@ mod tests { }); } - #[test] - fn peer_close_ends_the_channel_without_echoing_close() { - rt().block_on(async { - let mut h = harness(6, vec![], vec![]); - h.inbound_tx.send(Inbound::Close).unwrap(); - h.task.await.unwrap(); - assert!(h.data_rx.try_recv().is_err()); - assert!(h.control_rx.try_recv().is_err()); - }); - } - - #[test] - fn violation_signalled_by_the_demux_sends_close() { - rt().block_on(async { - let mut h = harness(7, vec![], vec![]); - h.inbound_tx.send(Inbound::Violation).unwrap(); - assert_eq!(frame_kind(&h.data_rx.recv().await.unwrap()), ("close", 7, vec![])); - h.task.await.unwrap(); - }); - } - - #[test] - fn dropped_tunnel_ends_the_channel_silently() { - rt().block_on(async { - let mut h = harness(8, vec![], vec![]); - drop(h.inbound_tx); - h.task.await.unwrap(); - assert!(h.data_rx.try_recv().is_err()); - }); - } - #[cfg(not(any(target_os = "android", target_os = "ios")))] mod tunnel { use super::*; - use crate::port_forward_mux::{tunnel::{TunnelHandle, MAX_REPORTED_OPEN_ERRORS}, Claim, Tunnel}; + use crate::port_forward_mux::{tunnel::TunnelHandle, Claim, Tunnel}; use hbb_common::{ protobuf::Message as _, tcp::FramedStream, @@ -1199,42 +998,6 @@ mod tests { }); } - #[test] - fn claim_follows_the_tunnel_state() { - let t = Tunnel::new(); - assert!(matches!(t.claim(), Claim::Claimed)); - t.set_failed(); - assert!(matches!(t.claim(), Claim::Claimed)); - t.set_legacy(); - assert!(matches!(t.claim(), Claim::Legacy)); - } - - #[test] - fn an_id_still_live_when_the_counter_comes_round_is_skipped() { - rt().block_on(async { - let (ours, mut peer) = stream_pair().await; - let t = Tunnel::new(); - t.claim(); - let h = t.set_muxed(ours, NoUi::default()); - let id_of = |ch: &PortForwardChannel| match &ch.union { - Some(port_forward_channel::Union::Open(o)) => o.channel_id, - other => panic!("expected open, got {:?}", other), - }; - let (_a, sock_a) = local_pair().await; - h.open("localhost", 80, sock_a, vec![]).unwrap(); - let a = id_of(&recv_frame(&mut peer).await); - // 2^32 opens later the counter is back at A's id, and A is - // still up. Handing that id out again would replace A's entry - // here while the peer keeps routing it to A's socket. - h.set_next_id(a); - let (_b, sock_b) = local_pair().await; - h.open("localhost", 80, sock_b, vec![]).unwrap(); - let b = id_of(&recv_frame(&mut peer).await); - assert_ne!(b, a, "channel B was handed A's live id"); - assert_eq!(h.live_channels(), 2); - }); - } - #[test] fn open_sends_open_then_pipelined_data_and_relays_replies() { rt().block_on(async { @@ -1295,65 +1058,6 @@ mod tests { }); } - #[test] - fn every_open_precedes_its_own_channels_first_data() { - rt().block_on(async { - let (ours, mut peer) = stream_pair().await; - let t = Tunnel::new(); - assert!(matches!(t.claim(), Claim::Claimed)); - let h = t.set_muxed(ours, NoUi::default()); - // Twenty channels, each with one byte of pipelined data behind - // its open. An open on the control queue can lose the loop's - // random tie-break to a data frame, so one channel would be a - // coin flip; twenty make a wrong implementation fail every run. - const N: usize = 20; - let mut apps = Vec::new(); - for i in 0..N { - let (app, sock) = local_pair().await; - h.open("localhost", 1, sock, vec![i as u8]).unwrap(); - apps.push(app); - } - let mut opened = std::collections::HashSet::new(); - let mut seen_data = 0; - while seen_data < N { - let ch = recv_frame(&mut peer).await; - match &ch.union { - Some(port_forward_channel::Union::Open(o)) => { - assert!(opened.insert(o.channel_id), "duplicate open"); - } - Some(port_forward_channel::Union::Data(d)) => { - assert!( - opened.contains(&d.channel_id), - "data for channel {} arrived before its open", - d.channel_id - ); - seen_data += 1; - } - other => panic!("unexpected {:?}", other), - } - } - }); - } - - #[test] - fn failed_open_closes_the_local_socket() { - rt().block_on(async { - let (ours, mut peer) = stream_pair().await; - let t = Tunnel::new(); - t.claim(); - let h = t.set_muxed(ours, NoUi::default()); - let (mut app, sock) = local_pair().await; - h.open("localhost", 1, sock, vec![]).unwrap(); - let id = match recv_frame(&mut peer).await.union { - Some(port_forward_channel::Union::Open(o)) => o.channel_id, - other => panic!("expected open, got {:?}", other), - }; - peer.send(&opened_msg(id, false, "refused", 0)).await.unwrap(); - let mut buf = [0u8; 1]; - assert_eq!(app.read(&mut buf).await.unwrap(), 0); - }); - } - #[test] fn a_refused_channel_is_reported_once_per_reason() { rt().block_on(async { @@ -1382,35 +1086,6 @@ mod tests { }); } - #[test] - fn a_refused_reason_is_reported_again_after_a_quiet_spell() { - rt().block_on(async { - let (ours, _peer) = stream_pair().await; - let t = Tunnel::new(); - t.claim(); - let h = t.set_muxed(ours, NoUi::default()); - let t0 = Instant::now(); - let at = |secs: u64| t0 + std::time::Duration::from_secs(secs); - let down = || "down".to_owned(); - assert_eq!(h.first_report_at(down(), at(0)), Some(down())); - // A burst that keeps going keeps the dialog quiet ... - assert_eq!(h.first_report_at(down(), at(8)), None); - assert_eq!(h.first_report_at(down(), at(16)), None); - // ... and one that stopped is reported afresh. - assert_eq!(h.first_report_at(down(), at(27)), Some(down())); - // The cap counts reasons still live, so it cannot silence the - // window for good. - for i in 0..MAX_REPORTED_OPEN_ERRORS { - h.first_report_at(format!("reason {}", i), at(27)); - } - assert_eq!(h.first_report_at("one more".to_owned(), at(27)), None); - assert_eq!( - h.first_report_at("one more".to_owned(), at(40)), - Some("one more".to_owned()) - ); - }); - } - #[test] fn an_over_window_frame_drops_the_channel_at_once() { rt().block_on(async { @@ -1619,84 +1294,6 @@ mod tests { }); } - #[test] - fn a_channel_opened_during_a_bulk_transfer_is_served_promptly() { - rt().block_on(async { - let (_t, h, port) = muxed_tunnel().await; - let (bulk_app, bulk_sock) = local_pair().await; - h.open("127.0.0.1", port as i32, bulk_sock, vec![]).unwrap(); - let bulk = vec![0xAB; 4 << 20]; - let (mut bulk_rd, mut bulk_wr) = bulk_app.into_split(); - let bulk_writer = { - let bulk = bulk.clone(); - tokio::spawn(async move { - bulk_wr.write_all(&bulk).await.unwrap(); - // Holding the write half open: dropping it half-closes - // the socket, which ends the channel by design. - bulk_wr - }) - }; - // Wait until a mebibyte is back, so the bulk channel is - // demonstrably mid-flight before anything else is opened. - let mut back = vec![0u8; 1 << 20]; - bulk_rd.read_exact(&mut back).await.unwrap(); - let (mut app, sock) = local_pair().await; - h.open("127.0.0.1", port as i32, sock, vec![42]).unwrap(); - let mut b = [0u8; 1]; - tokio::time::timeout(std::time::Duration::from_secs(2), app.read_exact(&mut b)) - .await - .expect("a channel opened during a bulk transfer starved") - .unwrap(); - assert_eq!(b[0], 42); - let mut rest = vec![0u8; bulk.len() - (1 << 20)]; - bulk_rd.read_exact(&mut rest).await.unwrap(); - let _bulk_wr = bulk_writer.await.unwrap(); - }); - } - - #[test] - fn a_local_half_close_ends_the_whole_channel() { - rt().block_on(async { - let (_t, h, port) = muxed_tunnel().await; - let (app, sock) = local_pair().await; - h.open("127.0.0.1", port as i32, sock, vec![]).unwrap(); - let (mut rd, wr) = app.into_split(); - // Dropping the write half is a shutdown(SHUT_WR). Supporting it - // needs a direction flag on the close frame; today's raw pipe - // drops both directions on either EOF too, and this matches it. - drop(wr); - let mut buf = [0u8; 1]; - assert_eq!(rd.read(&mut buf).await.unwrap(), 0); - }); - } - - #[test] - fn tail_before_close_is_delivered_in_both_directions() { - rt().block_on(async { - let (_t, h, port) = muxed_tunnel().await; - let (app, sock) = local_pair().await; - h.open("127.0.0.1", port as i32, sock, vec![]).unwrap(); - let payload = vec![7u8; 300 * 1024]; - let (mut rd, mut wr) = app.into_split(); - let reader = { - let payload = payload.clone(); - tokio::spawn(async move { - let mut back = vec![0u8; payload.len()]; - rd.read_exact(&mut back).await.unwrap(); - assert_eq!(back, payload); - rd - }) - }; - wr.write_all(&payload).await.unwrap(); - // The full echo proves every byte reached the target ahead of anything - // else; only then close, and the peer's `close` must follow cleanly. - let mut rd = reader.await.unwrap(); - drop(wr); - let mut one = [0u8; 1]; - assert_eq!(rd.read(&mut one).await.unwrap(), 0); - }); - } - #[test] fn one_byte_frames_never_trip_the_window() { rt().block_on(async { diff --git a/src/privacy_mode/win_input.rs b/src/privacy_mode/win_input.rs index 29c87dc18..09105bae5 100644 --- a/src/privacy_mode/win_input.rs +++ b/src/privacy_mode/win_input.rs @@ -263,14 +263,3 @@ pub extern "system" fn privacy_mode_hook_mouse( } unsafe { CallNextHookEx(NULL as _, code, w_param, l_param) } } - -mod test { - #[test] - fn privacy_hook() { - //use super::*; - - // privacy_hook::hook().unwrap(); - // std::thread::sleep(std::time::Duration::from_millis(50)); - // privacy_hook::unhook().unwrap(); - } -} diff --git a/src/rendezvous_mediator.rs b/src/rendezvous_mediator.rs index f80c1d5e7..042d62b6c 100644 --- a/src/rendezvous_mediator.rs +++ b/src/rendezvous_mediator.rs @@ -1616,7 +1616,7 @@ async fn punch_tcp_until_connected( } /// The repeats of `punch_tcp_until_connected`, over any punch rather than `connect_tcp_local` -/// alone, so that a test can run the schedule against a paused clock - which no socket can be. +/// alone. async fn punch_until( until: tokio::time::Instant, peer_addr: SocketAddr, @@ -1719,26 +1719,16 @@ impl Drop for CheckIfResendPk { #[cfg(test)] mod tests { use super::{ - connection_meta, mpsc, socket_client, tokio, udp_nat_listen, AnswererSlot, Arc, IceRoute, - IntoTargetAddr, Ordering, PunchHoleSent, RendezvousMediator, RendezvousMessage, - ICE_DEDUP_WINDOW, MAX_PENDING_REMOTE_ICE, MAX_WEBRTC_ANSWERERS, TCP_PUNCHES, UDP_PUNCHES, + connection_meta, mpsc, tokio, AnswererSlot, IceRoute, IntoTargetAddr, PunchHoleSent, + RendezvousMediator, RendezvousMessage, MAX_WEBRTC_ANSWERERS, TCP_PUNCHES, UDP_PUNCHES, }; - use hbb_common::{protobuf::Message as _, tcp::new_listener}; - use std::net::SocketAddr; - - // The pools are statics and the tests run in parallel: the ones that count them take turns. - static POOLS: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - fn pools() -> std::sync::MutexGuard<'static, ()> { - POOLS.lock().unwrap_or_else(|e| e.into_inner()) - } + use hbb_common::protobuf::Message as _; // The places are the bound and they come back on drop: a punch past the limit is declined // while the ones in flight are left alone, and the next one in is admitted only once a // place has actually been given back. #[test] fn test_punch_slots_cap_and_release() { - let _pools = pools(); for pool in [&UDP_PUNCHES, &TCP_PUNCHES] { let held: Vec<_> = (0..pool.max) .map(|_| pool.take().expect("a place up to the limit")) @@ -1749,34 +1739,6 @@ mod tests { } } - // Neither transport pays for the other's crowd: a full UDP pool leaves the TCP places alone. - #[test] - fn a_full_udp_pool_leaves_the_tcp_places_alone() { - let _pools = pools(); - let held: Vec<_> = (0..UDP_PUNCHES.max) - .map(|_| UDP_PUNCHES.take().expect("a place up to the limit")) - .collect(); - assert!(UDP_PUNCHES.take().is_none(), "the UDP pool is full"); - assert!( - TCP_PUNCHES.take().is_some(), - "a TCP punch must not wait on the UDP pool" - ); - drop(held); - } - - // At the last place it is the v6 punch that goes without, never the v4 one the peer counts on. - #[test] - fn the_last_place_goes_to_the_v4_punch() { - let _pools = pools(); - let held: Vec<_> = (0..UDP_PUNCHES.max - 1) - .map(|_| UDP_PUNCHES.take().expect("a place up to the last")) - .collect(); - let (v4, v6) = UDP_PUNCHES.take_pair(true, true); - assert!(v4.is_some(), "the last place goes to the v4 punch"); - assert!(v6.is_none(), "and the v6 punch goes without"); - drop(held); - } - // Declined is the listen, never the reply: with no place the PunchHoleSent still goes out, // carrying the answer and the v6 address the controller's other transports run on, and the // call returns at once - a listen would have waited for the peer's probe and failed without. @@ -1816,56 +1778,6 @@ mod tests { assert_eq!(&sent.socket_addr_v6[..], b"v6"); } - // The place is the wait: a listen that ends without a peer gives it back on its own. The end - // a test can reach is the peer's probe never coming, which `punch_udp` gives up on. - #[test] - fn a_listen_that_finds_no_peer_gives_its_place_back() { - let _pools = pools(); - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - rt.block_on(async { - let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); - let silent = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); - let peer = silent.local_addr().unwrap(); - let before = UDP_PUNCHES.places.load(Ordering::Acquire); - let slot = UDP_PUNCHES.take().expect("a place"); - assert_eq!(UDP_PUNCHES.places.load(Ordering::Acquire), before + 1); - let listened = udp_nat_listen( - Arc::new(socket), - peer, - peer, - crate::server::new_for_test(), - connection_meta(None, None), - slot, - ) - .await; - assert!(listened.is_err(), "no peer, no session"); - assert_eq!( - UDP_PUNCHES.places.load(Ordering::Acquire), - before, - "the place must come back with the wait" - ); - }); - } - - // A SOCKS proxy makes `connect_tcp_local` dial the proxy and ignore the local address, so - // nothing these two assert can hold. Read once, from the same global config production reads. - fn proxied() -> bool { - hbb_common::config::Config::get_socks().is_some() - } - - /// Both held while their addresses are read, so the pair cannot be the same port - which - /// `SO_REUSEPORT` would let bind twice rather than refuse, leaving the tests degenerate. - async fn free_loopback_pair() -> (SocketAddr, SocketAddr) { - let (a, b) = ( - tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(), - tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(), - ); - (a.local_addr().unwrap(), b.local_addr().unwrap()) - } - fn queue(route: &mut IceRoute, candidate: &str) -> bool { route.queue(candidate.to_owned()) } @@ -1887,214 +1799,6 @@ mod tests { assert_eq!(queued, vec!["host".to_owned(), "relay".to_owned()]); } - #[test] - fn a_candidate_the_full_queue_refused_is_not_remembered() { - let (tx, mut rx) = mpsc::channel::(1); - let mut route = IceRoute::new(tx); - assert!(queue(&mut route, "host")); - assert!(!queue(&mut route, "relay")); - // The re-send is the only repair for a refused candidate; remembering it would swallow it. - assert_eq!(rx.try_recv().ok(), Some("host".to_owned())); - assert!(queue(&mut route, "relay")); - assert_eq!(rx.try_recv().ok(), Some("relay".to_owned())); - } - - #[test] - fn a_re_send_is_skipped_while_the_original_is_still_queued() { - let (tx, mut rx) = mpsc::channel::(MAX_PENDING_REMOTE_ICE); - let mut route = IceRoute::new(tx); - for i in 0..MAX_PENDING_REMOTE_ICE { - assert!(queue(&mut route, &format!("candidate-{}", i))); - } - assert!(queue(&mut route, "candidate-0")); - let mut queued = 0; - while rx.try_recv().is_ok() { - queued += 1; - } - assert_eq!(queued, MAX_PENDING_REMOTE_ICE); - } - - #[test] - fn the_window_forgets_in_arrival_order() { - let (tx, mut rx) = mpsc::channel::(MAX_PENDING_REMOTE_ICE); - let mut route = IceRoute::new(tx); - for i in 0..=ICE_DEDUP_WINDOW { - assert!(queue(&mut route, &format!("candidate-{}", i))); - assert!(rx.try_recv().is_ok()); - } - // The oldest digest made room for the newest, so its re-send is admitted again. - assert!(queue(&mut route, "candidate-0")); - assert!(rx.try_recv().is_ok()); - // A recent one is still skipped. - let recent = format!("candidate-{}", ICE_DEDUP_WINDOW); - assert!(queue(&mut route, &recent)); - assert!(rx.try_recv().is_err()); - } - - // The second way in that the repeat punch opens: a punch reaching a peer already in SYN_SENT - // is answered by that socket rather than reset, and the two ends come up on one connection. - // A punch that misses the crossing is reset outright here, loopback having no NAT to absorb - // it and no round trip to hide behind - so a single punch lands only by luck, and repeating - // is what makes it land at all. That is the premise of the repeat, asserted directly. A round - // that misses costs one loopback RST, so rounds are cheap and there are many. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn a_punch_that_meets_the_peers_syn_connects_both_ends() { - // The crossing needs both connects genuinely in flight at once. Loopback answers a SYN to - // a port nobody is listening on with an instant RST, so on one CPU the first connect runs - // to completion before the second is scheduled and no round can ever cross - a property of - // the box, which this test cannot tell apart from a broken punch. - if proxied() || std::thread::available_parallelism().map_or(true, |cpus| cpus.get() < 2) { - return; - } - for _ in 0..256 { - let (a, b) = free_loopback_pair().await; - // Held for the whole crossing, because production always has one here and the design - // rests on which of the two the kernel hands the connection to: the punch and the - // peer's SYN share a four-tuple exactly, the listener only matches the address, and - // the punch has to win that or every crossing would be swallowed as a plain accept. - let listener = new_listener(a, true).await.unwrap(); - let to_b = tokio::spawn(socket_client::connect_tcp_local(b, Some(a), 3000)); - let to_a = tokio::spawn(socket_client::connect_tcp_local(a, Some(b), 3000)); - let (at_a, at_b) = tokio::join!(to_b, to_a); - let (Ok(Ok(mut at_a)), Ok(Ok(mut at_b))) = (at_a, at_b) else { - continue; - }; - at_a.send_bytes(bytes::Bytes::from_static(b"punch")) - .await - .unwrap(); - let got = at_b.next_timeout(3000).await.unwrap().unwrap(); - assert_eq!(&got[..], b"punch", "both ends must share one connection"); - assert!( - hbb_common::timeout(200, listener.accept()).await.is_err(), - "the crossing must reach the punch, not be accepted as an inbound connection" - ); - return; - } - panic!("no punch met the peer's SYN in 256 rounds on a machine that can cross them"); - } - - // The punch binds the address the listener already holds, so it has to go through the same - // `connect_tcp_local` production uses - a punch built by hand here would still pass if - // `new_socket` ever stopped setting the reuse flags, while every real punch failed to bind. - // The peer's view of the source port is what proves the bind took: a fallback to an ephemeral - // one would connect just as happily. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn a_punch_binds_the_address_the_listener_holds() { - if proxied() { - return; - } - // `free_loopback_pair` hands back ports it no longer holds, so another process can take - // one in between; retry rather than fail for something the punch had no part in. - for _ in 0..8 { - let (local, peer_addr) = free_loopback_pair().await; - let (Ok(listener), Ok(peer)) = ( - new_listener(local, true).await, - new_listener(peer_addr, true).await, - ) else { - continue; - }; - let punch = tokio::spawn(socket_client::connect_tcp_local( - peer_addr, - Some(local), - 1500, - )); - let (_peer_side, seen_as) = hbb_common::timeout(3000, peer.accept()) - .await - .expect("the punch must reach the peer") - .unwrap(); - assert_eq!( - seen_as.port(), - local.port(), - "the punch must leave from the address the listener holds, not an ephemeral one" - ); - // Held, not asserted and dropped: the coexistence below is only exercised while this - // socket is still on the address, which is the state production spends its window in. - let _punched = punch.await.unwrap().expect("the punch must connect"); - - let dialed = tokio::spawn(tokio::net::TcpStream::connect(local)); - let accepted = hbb_common::timeout(3000, listener.accept()).await; - assert!( - matches!(accepted, Ok(Ok(_))), - "the listener must still take connections while a punch shares its address: {accepted:?}" - ); - assert!(dialed.await.unwrap().is_ok()); - return; - } - panic!("could not hold two free loopback addresses in 8 tries"); - } - - // The schedule on its own, against a paused clock: the window is CONNECT_TIMEOUT long, and - // what these pin is where inside it the punches fall, which no socket could show. - #[tokio::test(start_paused = true)] - async fn the_punches_end_on_one_at_the_deadline() { - use super::{punch_until, PUNCH_GRACE, PUNCH_INTERVAL, PUNCH_MAX_INTERVAL}; - use hbb_common::{anyhow::anyhow, config::CONNECT_TIMEOUT}; - use std::time::Duration; - use tokio::time::Instant; - - let peer: SocketAddr = "127.0.0.1:1".parse().unwrap(); - let start = Instant::now(); - let until = start + Duration::from_millis(CONNECT_TIMEOUT); - let mut punches = Vec::new(); - // A gateway that answers with RST: every punch fails the moment it is made. - let met = punch_until::<(), _, _>(until, peer, |ms| { - punches.push((Instant::now(), ms)); - async { Err(anyhow!("RST")) } - }) - .await; - assert!(met.is_none()); - assert_eq!( - Instant::now(), - until, - "must return the moment the window closes, not a backoff later" - ); - // Tokio rounds every sleep up to the next millisecond. - let slack = Duration::from_millis(1); - assert!(punches[0].0 - start <= Duration::from_secs_f32(PUNCH_INTERVAL) + slack); - for pair in punches.windows(2) { - assert!( - pair[1].0 - pair[0].0 <= Duration::from_secs_f32(PUNCH_MAX_INTERVAL) + slack, - "no gap in the window may exceed the backoff ceiling: {pair:?}" - ); - } - assert_eq!( - *punches.last().unwrap(), - (until, PUNCH_GRACE), - "the window must end on a punch, given the whole grace" - ); - } - - #[tokio::test(start_paused = true)] - async fn a_punch_in_flight_runs_the_grace_past_the_deadline_and_no_further() { - use super::{punch_until, PUNCH_GRACE}; - use hbb_common::{anyhow::anyhow, config::CONNECT_TIMEOUT}; - use std::time::Duration; - use tokio::time::Instant; - - let peer: SocketAddr = "127.0.0.1:1".parse().unwrap(); - let until = Instant::now() + Duration::from_millis(CONNECT_TIMEOUT); - let mut punches = 0; - // A gateway that drops the SYN in silence: the punch sits in SYN_SENT for all it is given. - let met = punch_until::<(), _, _>(until, peer, |ms| { - punches += 1; - async move { - tokio::time::sleep(Duration::from_millis(ms)).await; - Err(anyhow!("timed out")) - } - }) - .await; - assert!(met.is_none()); - assert_eq!( - punches, 1, - "a punch held in SYN_SENT is the only one the window needs" - ); - assert_eq!( - Instant::now(), - until + Duration::from_millis(PUNCH_GRACE), - "must return when the grace runs out, not a backoff later" - ); - } - #[test] fn test_answerer_slots_cap_and_release() { let held: Vec<_> = (0..MAX_WEBRTC_ANSWERERS) diff --git a/src/server/audio_service.rs b/src/server/audio_service.rs index b81de7f68..4bb234f39 100644 --- a/src/server/audio_service.rs +++ b/src/server/audio_service.rs @@ -609,154 +609,6 @@ mod cpal_impl { )?; Ok((stream, errors)) } - - #[cfg(test)] - mod tests { - use super::super::audio_capture_queue::{ - new_pcm_handoff, start_capture_encoder, CaptureEncoderConfig, - }; - use super::{ - capture_packet_layout, convert_input_samples, CaptureFrameProcessor, - CaptureFrameProcessorConfig, - }; - use crate::audio_resampler::allocation_tests::assert_no_allocations; - use crate::server::EmptyExtraFieldService; - use magnum_opus::Channels::{Mono, Stereo}; - - const INVALID_CAPTURE_RATE: u32 = 99; - const RATE_24_KHZ: u32 = 24_000; - const RATE_44_1_KHZ: u32 = 44_100; - const RATE_48_KHZ: u32 = 48_000; - const MONO_CHANNELS: u16 = 1; - const NEGATIVE_FULL_SCALE_LIMIT: f32 = -0.99; - const POSITIVE_FULL_SCALE_LIMIT: f32 = 0.99; - const STEREO_CHANNELS: u16 = 2; - const SURROUND_CHANNELS: u16 = 6; - const ZERO_CHANNELS: u16 = 0; - - #[test] - fn capture_sample_conversion_uses_cpal_traits() { - let input = [i16::MIN, 0, i16::MAX]; - let output: Vec<_> = convert_input_samples(&input).collect(); - - assert_eq!(output.len(), input.len()); - assert!(output[0] <= NEGATIVE_FULL_SCALE_LIMIT); - assert_eq!(output[1], 0.0); - assert!(output[2] >= POSITIVE_FULL_SCALE_LIMIT); - } - - #[test] - fn capture_packet_layout_validates_rate_and_channels() { - let expected_frames = RATE_48_KHZ as usize / super::AUDIO_PACKETS_PER_SECOND; - assert_eq!( - capture_packet_layout(RATE_48_KHZ, STEREO_CHANNELS).unwrap(), - (expected_frames, expected_frames * STEREO_CHANNELS as usize) - ); - assert!(capture_packet_layout(INVALID_CAPTURE_RATE, MONO_CHANNELS).is_err()); - assert!(capture_packet_layout(RATE_48_KHZ, ZERO_CHANNELS).is_err()); - } - - #[test] - fn capture_callback_pipeline_does_not_allocate_after_warmup() { - for (input_rate, output_rate, device_channel, encode_channel) in [ - (RATE_48_KHZ, RATE_48_KHZ, MONO_CHANNELS, MONO_CHANNELS), - (RATE_48_KHZ, RATE_48_KHZ, STEREO_CHANNELS, STEREO_CHANNELS), - (RATE_44_1_KHZ, RATE_24_KHZ, STEREO_CHANNELS, STEREO_CHANNELS), - (RATE_48_KHZ, RATE_48_KHZ, SURROUND_CHANNELS, STEREO_CHANNELS), - ] { - assert_capture_processor_does_not_allocate(CaptureFrameProcessorConfig { - input_rate, - output_rate, - device_channel, - encode_channel, - }); - } - } - - #[test] - fn capture_pcm_handoff_reuses_buffers_and_accounts_for_loss() { - const QUEUE_CAPACITY: usize = 2; - const PACKET_SAMPLES: usize = 4; - const FIRST: [f32; PACKET_SAMPLES] = [1.0; PACKET_SAMPLES]; - const SECOND: [f32; PACKET_SAMPLES] = [2.0; PACKET_SAMPLES]; - const THIRD: [f32; PACKET_SAMPLES] = [3.0; PACKET_SAMPLES]; - const OVERSIZED_SAMPLES: usize = PACKET_SAMPLES + 1; - const OVERSIZED: [f32; OVERSIZED_SAMPLES] = [1.0; OVERSIZED_SAMPLES]; - - let (mut sender, receiver) = new_pcm_handoff(QUEUE_CAPACITY, PACKET_SAMPLES).unwrap(); - sender.set_wake_thread(std::thread::current()).unwrap(); - assert_no_allocations(|| { - sender.submit(&FIRST); - sender.submit(&SECOND); - sender.submit(&THIRD); - }); - - let loss = receiver.take_loss(); - assert_eq!(loss.dropped, 1); - assert_eq!(loss.oversized, 0); - assert_eq!(loss.recycle_failures, 0); - let second = receiver.pop().unwrap(); - let third = receiver.pop().unwrap(); - assert_eq!(second, SECOND); - assert_eq!(third, THIRD); - receiver.recycle(second); - receiver.recycle(third); - assert!(receiver.is_empty()); - - assert_no_allocations(|| sender.submit(&OVERSIZED)); - let loss = receiver.take_loss(); - assert_eq!(loss.dropped, 0); - assert_eq!(loss.oversized, 1); - assert_eq!(loss.recycle_failures, 0); - assert!(receiver.is_empty()); - } - - #[test] - fn capture_pcm_handoff_rejects_invalid_layouts() { - assert!(new_pcm_handoff(0, 1).is_err()); - assert!(new_pcm_handoff(1, 0).is_err()); - } - - fn assert_capture_processor_does_not_allocate(config: CaptureFrameProcessorConfig) { - const INPUT_LEVEL: f32 = 0.25; - const TEST_SERVICE_NAME: &str = "audio-allocation-test"; - - let service = EmptyExtraFieldService::new(TEST_SERVICE_NAME.to_owned(), true).sp; - let encode_channel = if config.encode_channel == MONO_CHANNELS { - Mono - } else { - Stereo - }; - let encoder_config = CaptureEncoderConfig { - sample_rate: config.output_rate, - encode_channel, - max_packet_samples: config.output_rate as usize / super::AUDIO_PACKETS_PER_SECOND - * config.device_channel.max(config.encode_channel) as usize, - }; - let (sender, worker) = start_capture_encoder(encoder_config, service).unwrap(); - let mut processor = CaptureFrameProcessor::new(config, sender).unwrap(); - let errors = super::CaptureErrorHandler::default(); - let input = vec![ - INPUT_LEVEL; - config.input_rate as usize / super::AUDIO_PACKETS_PER_SECOND - * config.device_channel as usize - ]; - let mut frame_buffer = - super::audio_capture::CaptureFrameBuffer::new(input.len()).unwrap(); - - frame_buffer.process(convert_input_samples(&input), |frame| { - errors.process_frame(|| processor.process(frame)); - }); - assert_no_allocations(|| { - frame_buffer.process(convert_input_samples(&input), |frame| { - errors.process_frame(|| processor.process(frame)); - }); - }); - assert!(!errors.needs_restart()); - drop(processor); - drop(worker); - } - } } fn create_format_msg(sample_rate: u32, channels: u16) -> Message { diff --git a/src/server/audio_service/audio_capture_encoder_tests.rs b/src/server/audio_service/audio_capture_encoder_tests.rs index 3c52c318f..fba1ffb3b 100644 --- a/src/server/audio_service/audio_capture_encoder_tests.rs +++ b/src/server/audio_service/audio_capture_encoder_tests.rs @@ -1,6 +1,5 @@ use super::super::{new_pcm_handoff, CAPTURE_PCM_QUEUE_PACKETS}; use super::*; -use crate::audio_resampler::allocation_tests::assert_no_allocations; use magnum_opus::{Application::LowDelay, Decoder, Encoder}; const SAMPLE_RATE: u32 = 48_000; @@ -10,8 +9,6 @@ const PAUSE_PACKET: usize = 50; const DROPPED_PACKETS: usize = 6; const SIGNAL_FREQUENCY: f64 = 97.0; const SIGNAL_AMPLITUDE: f64 = 0.5; -const ACTIVE_LEVEL: f32 = 0.8; -const SAMPLE_TOLERANCE: f32 = 0.000001; const MAX_ENCODE_BYTES_PER_SAMPLE: usize = 6; fn signal_packet(index: usize, channels: usize) -> Vec { @@ -85,102 +82,3 @@ fn capture_overflow_is_smoothed_before_encoding() { ); } } - -#[test] -fn rejected_packets_mark_the_gap_after_already_queued_audio() { - const CAPACITY: usize = 3; - let samples = SAMPLE_RATE as usize / PACKETS_PER_SECOND; - let (mut sender, receiver) = new_pcm_handoff(CAPACITY, samples).unwrap(); - let mut state = CaptureEncoderState::new(SAMPLE_RATE, Channels::Mono); - let active = vec![ACTIVE_LEVEL; samples]; - let opposite = vec![-ACTIVE_LEVEL; samples]; - sender.submit(&active); - let packet = state.next_packet(&receiver).unwrap(); - assert_eq!(packet, active); - receiver.recycle(packet); - sender.submit(&active); - sender.submit(&active); - sender.submit(&vec![ACTIVE_LEVEL; samples + 1]); - sender.submit(&opposite); - assert_eq!(receiver.take_loss().oversized, 1); - assert_no_allocations(|| { - for _ in 0..CAPACITY - 1 { - let packet = state.next_packet(&receiver).unwrap(); - assert_eq!(packet, active); - receiver.recycle(packet); - } - let packet = state.next_packet(&receiver).unwrap(); - let expected = ACTIVE_LEVEL * (1.0 - 2.0 / state.fade_frames as f32); - assert!((packet[0] - expected).abs() < SAMPLE_TOLERANCE); - assert_eq!(&packet[samples / 2..], &opposite[samples / 2..]); - receiver.recycle(packet); - }); -} - -#[test] -fn gaps_and_sequence_wrap_preserve_channel_history() { - const RATE_8_KHZ: u32 = 8_000; - for (rate, channels) in [ - (RATE_8_KHZ, Channels::Mono), - (RATE_8_KHZ, Channels::Stereo), - (SAMPLE_RATE, Channels::Mono), - (SAMPLE_RATE, Channels::Stereo), - ] { - let channels_count = channels as usize; - let mut state = CaptureEncoderState::new(rate, channels); - let samples = rate as usize / PACKETS_PER_SECOND * channels_count; - let active: Vec<_> = [ACTIVE_LEVEL, -ACTIVE_LEVEL][..channels_count] - .iter() - .copied() - .cycle() - .take(samples) - .collect(); - let mut first = active.clone(); - state.smooth_packet(0, &mut first); - assert_eq!(first, active); - let mut opposite: Vec<_> = active.iter().map(|sample| -sample).collect(); - let mut resumed = active.clone(); - assert_no_allocations(|| { - state.smooth_packet(2, &mut opposite); - state.smooth_packet(4, &mut resumed); - }); - for channel in 0..channels_count { - let expected = active[channel] * (1.0 - 2.0 / state.fade_frames as f32); - assert!((opposite[channel] - expected).abs() < SAMPLE_TOLERANCE); - let previous = opposite[samples - channels_count + channel]; - let expected = previous + (active[channel] - previous) / state.fade_frames as f32; - assert!((resumed[channel] - expected).abs() < SAMPLE_TOLERANCE); - } - assert_eq!(&resumed[samples / 2..], &active[samples / 2..]); - state.expected_sequence = usize::MAX; - let mut last = active.clone(); - let mut wrapped: Vec<_> = active.iter().map(|sample| -sample).collect(); - let expected = wrapped.clone(); - assert_no_allocations(|| { - state.smooth_packet(usize::MAX, &mut last); - state.smooth_packet(0, &mut wrapped); - }); - assert_eq!(last, active); - assert_eq!(wrapped, expected); - } -} - -#[test] -fn capture_loss_is_reported_while_packets_remain_queued() { - const CAPACITY: usize = 2; - let samples = SAMPLE_RATE as usize / PACKETS_PER_SECOND; - let (mut sender, receiver) = new_pcm_handoff(CAPACITY, samples).unwrap(); - let mut state = CaptureEncoderState::new(SAMPLE_RATE, Channels::Mono); - let before_report = Instant::now() - CAPTURE_STATS_LOG_INTERVAL; - state.reporter.last_report = before_report; - let packet = vec![ACTIVE_LEVEL; samples]; - for _ in 0..=CAPACITY { - sender.submit(&packet); - } - let packet = state.next_packet(&receiver).unwrap(); - assert!(!receiver.is_empty()); - assert!(state.reporter.last_report > before_report); - assert!(state.reporter.pending.is_empty()); - assert!(receiver.take_loss().is_empty()); - receiver.recycle(packet); -} diff --git a/src/server/audio_service/audio_capture_error.rs b/src/server/audio_service/audio_capture_error.rs index a1106f737..c5f944b89 100644 --- a/src/server/audio_service/audio_capture_error.rs +++ b/src/server/audio_service/audio_capture_error.rs @@ -67,15 +67,6 @@ mod tests { assert!(errors.needs_restart()); } - #[test] - fn late_error_from_an_old_stream_does_not_restart_its_replacement() { - let old_callback = CaptureErrorHandler::default(); - let replacement = CaptureErrorHandler::default(); - old_callback.handle(system_interruption()); - assert!(old_callback.needs_restart()); - assert!(!replacement.needs_restart()); - } - #[test] fn other_backend_errors_do_not_request_recreation() { let errors = CaptureErrorHandler::default(); @@ -86,44 +77,4 @@ mod tests { }); assert!(!errors.needs_restart()); } - - #[test] - fn processing_failure_skips_remaining_frames_until_stream_replacement() { - const FRAME_SAMPLES: usize = 2; - const FRAMES_PER_CALLBACK: usize = 3; - const CALLBACK_COUNT: usize = 2; - const FAILURE_CALL: usize = 2; - let errors = CaptureErrorHandler::default(); - let callback_errors = errors.clone(); - let mut framer = - super::super::audio_capture::CaptureFrameBuffer::new(FRAME_SAMPLES).unwrap(); - let input = [0.0; FRAME_SAMPLES * FRAMES_PER_CALLBACK]; - let mut processed = 0; - let mut failures = 0; - - // Inject an error to test recovery; this is not a valid-input backend failure reproduction. - for _ in 0..CALLBACK_COUNT { - framer.process(input.iter().copied(), |_| { - callback_errors.process_frame(|| { - processed += 1; - if processed >= FAILURE_CALL { - failures += 1; - hbb_common::anyhow::bail!("Injected capture processing failure"); - } - Ok(()) - }); - }); - } - - assert_eq!(processed, FAILURE_CALL); - assert_eq!(failures, 1); - assert!(errors.needs_restart()); - let replacement = CaptureErrorHandler::default(); - replacement.process_frame(|| { - processed += 1; - Ok(()) - }); - assert_eq!(processed, FAILURE_CALL + 1); - assert!(!replacement.needs_restart()); - } } diff --git a/src/server/audio_service/audio_capture_queue.rs b/src/server/audio_service/audio_capture_queue.rs index aa64f9f00..c8033d08e 100644 --- a/src/server/audio_service/audio_capture_queue.rs +++ b/src/server/audio_service/audio_capture_queue.rs @@ -226,11 +226,6 @@ impl CapturePcmSender { } impl CapturePcmReceiver { - #[cfg(test)] - pub(super) fn pop(&self) -> Option> { - self.pop_packet().map(|(_, buffer)| buffer) - } - fn pop_packet(&self) -> Option<(usize, Vec)> { self.handoff.buffers.lock().unwrap().ready.pop_front() } diff --git a/src/server/audio_service/audio_capture_queue_tests.rs b/src/server/audio_service/audio_capture_queue_tests.rs index 90caf2ed5..f49c7c4d0 100644 --- a/src/server/audio_service/audio_capture_queue_tests.rs +++ b/src/server/audio_service/audio_capture_queue_tests.rs @@ -4,49 +4,25 @@ use std::{sync::mpsc, time::Duration}; const PACKET_SAMPLES: usize = 4; const TEST_TIMEOUT: Duration = Duration::from_secs(2); -const CONCURRENT_PACKETS: usize = 10_000; - -#[derive(Clone, Copy)] -enum PausePoint { - Recycle, - Consume, -} struct WorkerPause { entered: mpsc::Sender<()>, resume: mpsc::Receiver<()>, } -fn paused_worker( - receiver: CapturePcmReceiver, - point: PausePoint, - pause: WorkerPause, -) -> CapturePcmReceiver { - let recycled = match point { - PausePoint::Recycle => Some(receiver.pop_packet().unwrap().1), - PausePoint::Consume => None, - }; +fn paused_worker(receiver: CapturePcmReceiver, pause: WorkerPause) -> CapturePcmReceiver { let consumed = { let mut buffers = receiver.handoff.buffers.lock().unwrap(); - let consumed = match recycled { - Some(mut packet) => { - packet.clear(); - buffers.available.push(packet); - None - } - None => Some(buffers.ready.pop_front().unwrap().1), - }; + let consumed = buffers.ready.pop_front().unwrap().1; pause.entered.send(()).unwrap(); pause.resume.recv().unwrap(); consumed }; - if let Some(packet) = consumed { - receiver.recycle(packet); - } + receiver.recycle(consumed); receiver } -fn assert_callback_progress(point: PausePoint) { +fn assert_callback_progress() { let (mut sender, receiver) = new_pcm_handoff(CAPTURE_PCM_QUEUE_PACKETS, PACKET_SAMPLES).unwrap(); for sequence in 0..CAPTURE_PCM_QUEUE_PACKETS { @@ -58,7 +34,7 @@ fn assert_callback_progress(point: PausePoint) { entered: entered_tx, resume: resume_rx, }; - let worker = std::thread::spawn(move || paused_worker(receiver, point, pause)); + let worker = std::thread::spawn(move || paused_worker(receiver, pause)); sender.set_wake_thread(worker.thread().clone()).unwrap(); entered_rx.recv_timeout(TEST_TIMEOUT).unwrap(); let (completed_tx, completed_rx) = mpsc::channel(); @@ -108,62 +84,7 @@ fn assert_pool_restored(receiver: &CapturePcmReceiver, capacity: usize) { .all(|buffer| buffer.capacity() >= PACKET_SAMPLES)); } -#[test] -fn callback_finishes_while_worker_recycles_a_buffer() { - assert_callback_progress(PausePoint::Recycle); -} - #[test] fn callback_finishes_while_worker_releases_a_ready_packet() { - assert_callback_progress(PausePoint::Consume); -} - -fn consume_concurrently( - receiver: CapturePcmReceiver, - finished: Arc, - first_sequence: usize, -) -> (CapturePcmReceiver, usize) { - let mut received = 0; - let mut next_ordinal = 0; - loop { - if let Some((sequence, packet)) = receiver.pop_packet() { - let ordinal = sequence.wrapping_sub(first_sequence); - assert!(ordinal >= next_ordinal && ordinal < CONCURRENT_PACKETS); - assert_eq!(packet, [ordinal as f32; PACKET_SAMPLES]); - next_ordinal = ordinal + 1; - received += 1; - receiver.recycle(packet); - } else if finished.load(Ordering::Acquire) && receiver.is_empty() { - return (receiver, received); - } else { - std::thread::yield_now(); - } - } -} - -#[test] -fn concurrent_handoff_preserves_order_buffers_and_loss_counts_across_sequence_wrap() { - const FIRST_SEQUENCE: usize = usize::MAX - CONCURRENT_PACKETS / 2; - for capacity in [1, 2, CAPTURE_PCM_QUEUE_PACKETS] { - let (mut sender, receiver) = new_pcm_handoff(capacity, PACKET_SAMPLES).unwrap(); - sender.sequence = FIRST_SEQUENCE; - let finished = Arc::new(AtomicBool::new(false)); - let worker_finished = finished.clone(); - let worker = std::thread::spawn(move || { - consume_concurrently(receiver, worker_finished, FIRST_SEQUENCE) - }); - assert_no_allocations(|| { - for ordinal in 0..CONCURRENT_PACKETS { - sender.submit(&[ordinal as f32; PACKET_SAMPLES]); - } - }); - finished.store(true, Ordering::Release); - let (receiver, received) = worker.join().unwrap(); - let stats = receiver.take_stats(); - assert_eq!(received + stats.loss.dropped, CONCURRENT_PACKETS); - assert_eq!(stats.loss.oversized, 0); - assert_eq!(stats.loss.recycle_failures, 0); - assert!(stats.max_queued_packets <= capacity); - assert_pool_restored(&receiver, capacity); - } + assert_callback_progress(); } diff --git a/src/server/clipboard_service.rs b/src/server/clipboard_service.rs index 54ae9e640..c331cd52f 100644 --- a/src/server/clipboard_service.rs +++ b/src/server/clipboard_service.rs @@ -349,31 +349,6 @@ fn run(sp: EmptyExtraFieldService) -> ResultType<()> { mod tests { use super::{decode_utf8_prefix, WAYLAND_CLIPBOARD_SKIP_CHECK_MAX_UTF8_BYTES}; - #[test] - fn decode_utf8_prefix_returns_text_for_valid_utf8() { - let text = "hello-Ł…Ų±Ų­ŲØŲ§"; - assert_eq!(decode_utf8_prefix(text.as_bytes()), Some(text.to_owned())); - } - - #[test] - fn decode_utf8_prefix_returns_none_for_invalid_utf8_sequence() { - let bytes = b"ab\xffcd"; - assert_eq!(decode_utf8_prefix(bytes), None); - } - - #[test] - fn decode_utf8_prefix_trims_incomplete_utf8_suffix() { - let bytes = vec![b'a', 0xE4, 0xB8]; - assert_eq!(decode_utf8_prefix(&bytes), Some("a".to_owned())); - } - - #[test] - fn decode_utf8_prefix_applies_max_bytes_limit() { - let bytes = vec![b'a'; WAYLAND_CLIPBOARD_SKIP_CHECK_MAX_UTF8_BYTES + 8]; - let result = decode_utf8_prefix(&bytes).expect("expected decoded prefix"); - assert_eq!(result.len(), WAYLAND_CLIPBOARD_SKIP_CHECK_MAX_UTF8_BYTES); - } - #[test] fn decode_utf8_prefix_keeps_utf8_boundary_when_limited() { let mut bytes = vec![b'a'; WAYLAND_CLIPBOARD_SKIP_CHECK_MAX_UTF8_BYTES - 1]; diff --git a/src/server/connection.rs b/src/server/connection.rs index 193f62686..730da3222 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -7200,64 +7200,6 @@ mod test { assert!(admit_unauthorized(1_000_102, a).is_some()); } - #[tokio::test] - async fn test_unauthorized_full_tells_the_oldest_to_go_and_frees_its_place_only_when_it_has() { - let _serial = UNAUTHORIZED_TESTS.lock().unwrap_or_else(|e| e.into_inner()); - let mut held: Vec<_> = (0..MAX_UNAUTHORIZED_CONNS as i32) - .map(|i| { - let ip: IpAddr = format!("198.51.100.{}", i + 1).parse().unwrap(); - admit_unauthorized(2_000_000 + i, ip).unwrap() - }) - .collect(); - // At the limit the newcomer is refused, the oldest is told to go, and the count does - // not move: the place is still occupied. - assert!(admit_unauthorized(2_000_999, "198.51.100.250".parse().unwrap()).is_none()); - assert!(held[0].is_evicted()); - assert!(held[1..].iter().all(|u| !u.is_evicted())); - assert_eq!(unauthorized_count(), MAX_UNAUTHORIZED_CONNS); - hbb_common::timeout(1000, held[0].evicted()).await.unwrap(); - // A further arrival while that one is still on its way out tells nobody else to go: a - // burst of refused arrivals clears no more room than a single one. - assert!(admit_unauthorized(2_001_000, "198.51.100.251".parse().unwrap()).is_none()); - assert!(held[1..].iter().all(|u| !u.is_evicted())); - // Only once an evicted connection has gone is there a place for a newcomer. - drop(held.remove(0)); - assert_eq!(unauthorized_count(), MAX_UNAUTHORIZED_CONNS - 1); - let newcomer = admit_unauthorized(2_001_001, "198.51.100.252".parse().unwrap()).unwrap(); - assert!(!newcomer.is_evicted()); - assert_eq!(unauthorized_count(), MAX_UNAUTHORIZED_CONNS); - // Full again, the next arrival tells the connection now oldest to go. - assert!(admit_unauthorized(2_001_002, "198.51.100.253".parse().unwrap()).is_none()); - assert!(held[0].is_evicted()); - assert!(held[1..].iter().all(|u| !u.is_evicted())); - assert!(!newcomer.is_evicted()); - } - - // The per-address share holds at the limit too: an address can turn out at most that many - // connections, one per place it then takes, and is refused before any eviction from then on. - #[test] - fn test_unauthorized_full_one_address_turns_out_at_most_its_share() { - let _serial = UNAUTHORIZED_TESTS.lock().unwrap_or_else(|e| e.into_inner()); - let mut held: Vec<_> = (0..MAX_UNAUTHORIZED_CONNS as i32) - .map(|i| { - let ip: IpAddr = format!("198.51.100.{}", i + 1).parse().unwrap(); - admit_unauthorized(3_000_000 + i, ip).unwrap() - }) - .collect(); - let flooder: IpAddr = "203.0.113.9".parse().unwrap(); - let mut taken = Vec::new(); - for i in 0..MAX_UNAUTHORIZED_CONNS_PER_ADDR as i32 { - assert!(admit_unauthorized(3_001_000 + i, flooder).is_none()); - assert!(held[0].is_evicted()); - drop(held.remove(0)); - taken.push(admit_unauthorized(3_002_000 + i, flooder).unwrap()); - } - assert_eq!(unauthorized_count(), MAX_UNAUTHORIZED_CONNS); - assert!(admit_unauthorized(3_003_000, flooder).is_none()); - assert!(held.iter().all(|u| !u.is_evicted())); - assert!(taken.iter().all(|u| !u.is_evicted())); - } - /// A loopback TCP connection as create_tcp_connection sees it, and the controller's end, /// which never speaks: the connection stalls in the identity handshake. async fn stalled_incoming() -> (Stream, Stream, SocketAddr) { @@ -7473,54 +7415,6 @@ mod test { assert!(wildcard_match(" 123456789 ", "123456789")); } - #[test] - fn test_decay_stale_failures() { - let entry = |minute: i32| (minute, 1, 40); - let keys = ["ip".to_string(), "p64".to_string(), "absent".to_string()]; - let mut m: HashMap = HashMap::new(); - m.insert("ip".to_string(), entry(100)); - m.insert("p64".to_string(), entry(160)); - m.insert("untouched".to_string(), entry(100)); - - // Exactly at the window: forgotten. Still inside it: kept. - decay_stale_failures(&mut m, &keys, 160, 60); - assert!(!m.contains_key("ip")); - assert!(m.contains_key("p64")); - // Keys that were not passed in are never visited, absent ones are a no-op. - assert!(m.contains_key("untouched")); - - // One minute short of the window keeps the entry. - decay_stale_failures(&mut m, &keys, 219, 60); - assert!(m.contains_key("p64")); - decay_stale_failures(&mut m, &keys, 220, 60); - assert!(!m.contains_key("p64")); - - // A clock that jumped backwards must not drop anything. - m.insert("ip".to_string(), entry(500)); - decay_stale_failures(&mut m, &keys, 0, 60); - assert!(m.contains_key("ip")); - } - - #[test] - fn test_clear_failures_drops_shared_prefixes() { - // On IPv6 a whitelisted peer usually has no entry of its own, while the shared - // prefixes that block it do. Clearing must not depend on the per-address entry. - let mut m: HashMap = HashMap::new(); - m.insert("p64".to_string(), (100, 1, 55)); - m.insert("p56".to_string(), (100, 1, 75)); - m.insert("p48".to_string(), (100, 1, 95)); - m.insert("someone-else".to_string(), (100, 1, 95)); - let keys = ["ip", "p64", "p56", "p48"].map(|k| k.to_string()); - - clear_failures(&mut m, &keys); - - for key in ["p64", "p56", "p48"] { - assert!(!m.contains_key(key), "{key} should have been cleared"); - } - // Keys belonging to other peers are left alone. - assert!(m.contains_key("someone-else")); - } - #[test] fn test_id_whitelist_allows() { let list = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); @@ -7613,13 +7507,6 @@ mod test { assert_eq!(pos.y, 510); } - #[test] - fn ipv6() { - assert!(Ipv6Addr::from_str("::1").is_ok()); - assert!(Ipv6Addr::from_str("127.0.0.1").is_err()); - assert!(Ipv6Addr::from_str("0").is_err()); - } - fn msg(set: impl FnOnce(&mut Message)) -> Message { let mut msg = Message::new(); set(&mut msg); @@ -7642,10 +7529,6 @@ mod test { }) } - fn set_supported_decoding(option: &mut OptionMessage) { - option.supported_decoding = hbb_common::protobuf::MessageField::some(Default::default()); - } - fn assert_scopes( conn_type: AuthConnType, cases: impl IntoIterator)>, @@ -7909,69 +7792,6 @@ mod test { assert_eq!(violation, Some("login.option")); } - #[test] - fn session_scope_limited_render_noop_options_reject_mixed_fields() { - for conn_type in [ - AuthConnType::FileTransfer, - AuthConnType::Terminal, - AuthConnType::PortForward, - ] { - let supported_decoding_only = option_msg(set_supported_decoding); - assert_eq!( - Connection::authorized_message_scope_violation(conn_type, &supported_decoding_only), - None - ); - - let mixed_option = option_msg(|o| { - set_supported_decoding(o); - o.disable_audio = BoolOption::Yes.into(); - }); - assert_eq!( - Connection::authorized_message_scope_violation(conn_type, &mixed_option), - Some("misc.option") - ); - } - } - - #[test] - fn session_scope_view_camera_options_keep_only_camera_fields() { - let mut option = OptionMessage::new(); - option.image_quality = ImageQuality::Balanced.into(); - option.custom_image_quality = 80; - option.custom_fps = 24; - set_supported_decoding(&mut option); - option.disable_audio = BoolOption::Yes.into(); - option.block_input = BoolOption::Yes.into(); - option.disable_clipboard = BoolOption::Yes.into(); - option.enable_file_transfer = BoolOption::Yes.into(); - option.terminal_persistent = BoolOption::Yes.into(); - - let (scoped, violation) = - Connection::scoped_login_option(AuthConnType::ViewCamera, &option); - let scoped = scoped.unwrap(); - assert_eq!(violation, Some("login.option")); - assert_eq!( - scoped.image_quality.enum_value(), - Ok(ImageQuality::Balanced) - ); - assert_eq!(scoped.custom_image_quality, 80); - assert_eq!(scoped.custom_fps, 24); - assert!(scoped.supported_decoding.is_some()); - assert_eq!(scoped.disable_audio.enum_value(), Ok(BoolOption::Yes)); - assert_eq!(scoped.block_input.enum_value(), Ok(BoolOption::NotSet)); - assert_eq!( - scoped.disable_clipboard.enum_value(), - Ok(BoolOption::NotSet) - ); - assert_eq!( - scoped.enable_file_transfer.enum_value(), - Ok(BoolOption::NotSet) - ); - assert_eq!( - scoped.terminal_persistent.enum_value(), - Ok(BoolOption::NotSet) - ); - } #[test] fn only_a_newer_remote_control_of_the_same_session_keeps_the_screen_unlocked() { let replaced_by = super::raii::AuthedConnID::is_newer_session_remote; diff --git a/src/server/display_service.rs b/src/server/display_service.rs index d3fbea143..111c746e2 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -1025,68 +1025,12 @@ pub fn try_get_displays_(add_amyuni_headless: bool) -> ResultType> Ok(displays) } -#[cfg(test)] -mod tests { - use super::normalize_primary_display_idx; - - #[test] - fn normalize_primary_display_idx_bounds() { - assert_eq!(normalize_primary_display_idx(0, 0), 0); - assert_eq!(normalize_primary_display_idx(0, 2), 0); - assert_eq!(normalize_primary_display_idx(1, 2), 1); - assert_eq!(normalize_primary_display_idx(2, 2), 0); - } -} - #[cfg(all(test, target_os = "linux", feature = "drm"))] mod input_map_tests { - use super::{ - input_map_epoch, input_map_gen, input_map_label, note_input_map_adopted, - note_input_map_ready, note_input_map_unknown, run_uinput_apply, set_wayland_uinput_rect, - wayland_uinput_rect, - }; - - // The input-map statics are process-wide and libtest runs tests concurrently. - static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - #[test] - fn an_apply_leaves_no_range_ready_until_it_is_acknowledged() { - let _serial = SERIAL.lock().unwrap_or_else(|p| p.into_inner()); - set_wayland_uinput_rect((0, 1920, 0, 1080)); - note_input_map_adopted(41); - let before = input_map_epoch(); - note_input_map_unknown(); - assert_eq!(input_map_gen(), u64::MAX, "no generation is ready"); - assert_eq!(input_map_epoch(), before + 1, "samples from before do not count"); - assert_eq!(wayland_uinput_rect(), None, "the poll applies again"); - } - - #[test] - fn the_raw_drm_union_labels_a_range_with_no_generation() { - assert_eq!(input_map_label(5, true), 5); - assert_eq!(input_map_label(5, false), u64::MAX); - } - - #[test] - fn an_acknowledged_range_is_an_adoption_and_a_fitting_one_is_not() { - let _serial = SERIAL.lock().unwrap_or_else(|p| p.into_inner()); - let before = input_map_epoch(); - note_input_map_adopted(41); - assert_eq!((input_map_gen(), input_map_epoch()), (41, before + 1)); - note_input_map_ready(42); - assert_eq!( - (input_map_gen(), input_map_epoch()), - (42, before + 1), - "a range that already fits is not an adoption" - ); - } - - // The apply thread is process-wide, and one test kills it on purpose. - static WORKER: std::sync::Mutex<()> = std::sync::Mutex::new(()); + use super::run_uinput_apply; #[test] fn uinput_applies_run_one_at_a_time_in_the_order_asked() { - let _worker = WORKER.lock().unwrap_or_else(|p| p.into_inner()); let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let answers: Vec<_> = (0..4) .map(|i| { @@ -1107,35 +1051,6 @@ mod input_map_tests { let want: Vec<_> = (0..4).flat_map(|i| [("start", i), ("end", i)]).collect(); assert_eq!(*log.lock().unwrap(), want, "one job at a time, in order"); } - - #[test] - fn a_uinput_apply_job_can_bound_its_ipc_with_a_timeout() { - let _worker = WORKER.lock().unwrap_or_else(|p| p.into_inner()); - let timed_out = run_uinput_apply(|rt| { - rt.block_on(async { - hbb_common::timeout(10, std::future::pending::<()>()) - .await - .is_err() - }) - }); - assert!(timed_out.blocking_recv().unwrap()); - } - - #[test] - fn a_uinput_apply_thread_that_died_is_started_again() { - let _worker = WORKER.lock().unwrap_or_else(|p| p.into_inner()); - let died = run_uinput_apply(|_| panic!("the apply thread dies here")); - assert!(died.blocking_recv().is_err(), "the job that killed it has no answer"); - // A job asked while the dying thread still unwinds is lost with it; a later one runs. - let answered = (0..50).find_map(|_| { - let answer = run_uinput_apply(|_| 7).blocking_recv().ok(); - if answer.is_none() { - std::thread::sleep(std::time::Duration::from_millis(10)); - } - answer - }); - assert_eq!(answered, Some(7)); - } } #[cfg(all(test, target_os = "linux"))] @@ -1168,18 +1083,6 @@ mod wayland_layout_tests { assert!(l.edge(&rotated, false, 0)); } - // The same, with no poll ever having run: the outgoing baseline is the only record of what - // the first capturer was built against. - #[test] - fn a_rotation_between_two_inits_before_the_first_poll_is_still_an_edge() { - let upright = layout(1920, 1080, 0); - let rotated = layout(1080, 1920, 1); - let mut l = WaylandLayout::default(); - l.reset_baseline(upright.clone()); - l.reset_baseline(rotated.clone()); - assert!(l.edge(&rotated, false, 0)); - } - // Control: without it the asserts above would pass on a detector that always fires. #[test] fn repeated_baseline_resets_without_a_rotation_are_not_an_edge() { @@ -1220,86 +1123,6 @@ mod wayland_layout_tests { assert!(!l3.edge(&upright, false, 0)); } - // A capturer built while the poll already has a memory must not overwrite it. - #[test] - fn a_later_capturer_does_not_overwrite_the_polls_memory() { - let upright = layout(1920, 1080, 0); - let rotated = layout(1080, 1920, 1); - let mut l = WaylandLayout::default(); - l.observe(&upright); - l.note_capturer(&rotated, 0); - assert!(l.edge(&rotated, false, 0), "the poll's memory still says upright"); - } - - // The constructor's snapshot read and its `note_capturer` are two steps, and the poll can - // land between them. After a failed init (empty baseline) the constructor takes A and - // publishes it; the output rotates; the poll reads B live, finds nothing recorded and the - // snapshot present, so no edge, and observes B. The late `note_capturer(A)` then met a - // non-empty memory and was dropped: the capturer showed A while the detector held B, and B - // against B never bumped the generation. - #[test] - fn a_capturer_record_that_lost_the_race_with_the_first_poll_is_still_an_edge() { - let upright = layout(1920, 1080, 0); - let rotated = layout(1080, 1920, 1); - let mut l = WaylandLayout::default(); - l.reset_baseline(Vec::new()); - assert!(!l.edge(&rotated, false, 0), "nothing recorded and the snapshot is present"); - l.observe(&rotated); - l.note_capturer(&upright, 0); - assert!(l.edge(&rotated, false, 0), "the capturer is built on upright, live is rotated"); - - // The promotion consumes it: the next poll sees the same layout and stays quiet. - l.observe(&rotated); - l.reset_baseline(rotated.clone()); - assert!(!l.edge(&rotated, false, 0)); - - // The same with a session init between the late record and the poll. - let mut l2 = WaylandLayout::default(); - l2.reset_baseline(Vec::new()); - l2.observe(&rotated); - l2.note_capturer(&upright, 0); - l2.reset_baseline(rotated.clone()); - assert!(l2.edge(&rotated, false, 0)); - - // Control: a late record that agrees with the poll's memory is not an edge. - let mut l3 = WaylandLayout::default(); - l3.reset_baseline(Vec::new()); - l3.observe(&upright); - l3.note_capturer(&upright, 0); - assert!(!l3.edge(&upright, false, 0)); - } - - // The late record can also land after the poll consumed the edge but before the bump that - // edge promotes, or after the bump with a snapshot taken before it. That capturer is stale - // by generation and rebuilds on its own, so its record must not buy a second promotion - // that tears the freshly rebuilt capturers down again. - #[test] - fn a_late_record_from_a_generation_already_promoted_is_not_a_second_edge() { - let upright = layout(1920, 1080, 0); - let rotated = layout(1080, 1920, 1); - let mut l = WaylandLayout::default(); - l.reset_baseline(upright.clone()); - l.observe(&upright); - // The output rotates, the poll consumes the edge, the capturer built on upright at - // generation 7 records late, and the poll promotes to 8. - assert!(l.edge(&rotated, false, 7)); - l.observe(&rotated); - l.note_capturer(&upright, 7); - l.reset_baseline(rotated.clone()); - assert!(!l.edge(&rotated, false, 8), "the capturer built at 7 rebuilds on its own"); - - // Control: a disagreeing record AT the promoted generation is a real edge. - l.observe(&rotated); - l.note_capturer(&upright, 8); - assert!(l.edge(&rotated, false, 8)); - - // A stale record landing after a fresh one must not hide the fresh one. - l.observe(&rotated); - l.note_capturer(&upright, 8); - l.note_capturer(&upright, 7); - assert!(l.edge(&rotated, false, 8)); - } - // A promotion consumes the edge: the next poll sees the same layout and must stay quiet. #[test] fn a_promoted_layout_is_not_an_edge_again() { diff --git a/src/server/drm_capturer.rs b/src/server/drm_capturer.rs index a50123c25..2206d3455 100644 --- a/src/server/drm_capturer.rs +++ b/src/server/drm_capturer.rs @@ -1560,7 +1560,7 @@ fn deliver_drm_cursor( // A guessed one was guessed on the ROTATED sprite - top-left of an arrow's box - and // the tip of a turned arrow is some other corner, so it is guessed again on the upright // one. The old flow was off at every angle; what differs is the size of the error, which - // is why only one of them got reported (see the test below). + // is why only one of them got reported. let (hx, hy) = if hot_measured { unrotate_hotspot(t, width as i32, height as i32, hotx, hoty) } else { @@ -2541,46 +2541,6 @@ mod drm_capturer_tests { .collect() } - #[test] - fn a_lone_display_goes_offline_only_when_its_fallback_was_rejected() { - // Unique name = unique health key; DRM_DISPLAY_HEALTH is process-wide. - let list = vec![drm_display("TEST-lone-fallback", 1080, 1920)]; - let key = connector_key(&list[0]); - let demoted = DisplayHealth { - zero_frame_streak: DRM_GRAB_MAX_FAILURES, - demotes: 1, - ..DisplayHealth::new() - }; - // Demoted alone keeps the lone display online: the whole-desktop fallback is usable. - DRM_DISPLAY_HEALTH.lock().unwrap().insert(key.clone(), demoted); - let mut infos = vec![DisplayInfo { - online: true, - ..Default::default() - }]; - mark_demoted_displays(&list, &mut infos); - assert!(infos[0].online, "the lone-display carve-out must survive"); - // A rejected fallback ends the carve-out: advertising online would restart-loop. - DRM_DISPLAY_HEALTH - .lock() - .unwrap() - .get_mut(&key) - .expect("just inserted") - .fallback_rejected = true; - mark_demoted_displays(&list, &mut infos); - assert!(!infos[0].online, "a rejected fallback must take the lone display offline"); - // Once the demotion cooldown lapses the display is no longer demoted, and online returns - // even with the rejection still latched (the re-arm will clear it on the next build). - DRM_DISPLAY_HEALTH - .lock() - .unwrap() - .get_mut(&key) - .expect("still there") - .since = Instant::now() - demote_cooldown(1) - Duration::from_secs(1); - infos[0].online = true; - mark_demoted_displays(&list, &mut infos); - assert!(infos[0].online, "past the cooldown the verdict is DRM's to retry"); - } - #[test] fn the_cursor_id_names_the_orientation_too() { // Same wire cursor under two transforms must publish as two ids, or the client's by-id @@ -2605,24 +2565,6 @@ mod drm_capturer_tests { assert_eq!(unrotate_hotspot(0, 3, 2, 2, 0), (2, 0)); } - #[test] - fn a_stale_snapshot_generation_asks_for_a_rebuild_without_blaming_the_display() { - let mut c = capturer_named(Some((64, 32)), Some("test:gen-rebuild")); - c.snapshot_gen = c.snapshot_gen.wrapping_sub(1); - put_frame(&c, 64, 32); - let err = match c.frame(Duration::from_millis(50)) { - Err(e) => e, - Ok(_) => panic!("a stale generation must rebuild, not deliver"), - }; - assert!(err.to_string().contains("layout changed"), "{err}"); - assert!(!c.got_frame); - assert_eq!( - zero_frame_streak_of(&c), - 0, - "a layout rebuild must not count against display health" - ); - } - #[test] fn unrotate_90_maps_the_left_column_to_the_top_row() { // The measured anchor from rustdesk#15886: mutter transform=1 carries the panel bar down @@ -2634,16 +2576,6 @@ mod drm_capturer_tests { assert_eq!(labels_of(&dst, h, w), vec![vec![4, 1], vec![5, 2], vec![6, 3]]); } - #[test] - fn unrotate_270_is_the_inverse_of_90() { - let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 0); - let mut once = Vec::new(); - unrotate_bgra(&src, w, h, 90, &mut once); - let mut back = Vec::new(); - unrotate_bgra(&once, h, w, 270, &mut back); - assert_eq!(back, src); - } - /// An upright arrow, tip at (0,0): a 4x6 bitmap, every pixel with x <= y/2 opaque, so the /// opaque box is the left 3 columns. Its top-left corner IS the tip - which is exactly why /// the bare-metal guess works when the sprite is upright, and only then. @@ -2660,30 +2592,11 @@ mod drm_capturer_tests { (px, w, h) } - /// An I-beam lying along x, deliberately NOT centred in its bitmap: 16x8, opaque rows 2..4 and - /// columns 1..13, so the opaque box is (1,2)-(12,3), 12x2 - elongated, which is what Adwaita's - /// `vertical-text` shape is. Its click point is the centre of that BOX, (6,2), while the centre - /// of the bitmap is (7,3). The offset is the point: a real sprite sits somewhere inside a - /// 64x64 hardware buffer (the adwaita box above is (2,6)-(21,14) in a 24x24 one), and a fixture - /// centred in its bitmap would let an implementation that returns the bitmap centre pass a test - /// claiming to check the box centre. - fn ibeam_horizontal() -> (Vec, usize, usize, (i32, i32)) { - let (w, h) = (16usize, 8usize); - let mut px = vec![0u8; w * h * 4]; - for y in 2..4 { - for x in 1..13 { - px[(y * w + x) * 4 + 3] = 255; - } - } - (px, w, h, (6, 2)) - } - /// The shape at the aspect ratio that actually matters: a 20x9 opaque box, which is what the /// installed Adwaita `vertical-text` measures at 24 px. 2.22:1, only just past the rule's - /// factor of two, so it pins the threshold from ABOVE: a stricter rule (say `bw > bh * 3`) - /// leaves the 6:1 bars below centred and silently un-centres the real cursor. Placed - /// off-centre in a 24x24 buffer like the real one, box (2,6)-(21,14), centre (11,10) - the - /// same numbers the theme file gives, against its declared hotspot of (12,11). + /// factor of two. Placed off-centre in a 24x24 buffer like the real one, box (2,6)-(21,14), + /// centre (11,10) - the same numbers the theme file gives, against its declared hotspot of + /// (12,11). fn ibeam_theme_aspect() -> (Vec, usize, usize, (i32, i32)) { let (w, h) = (24usize, 24usize); let mut px = vec![0u8; w * h * 4]; @@ -2695,20 +2608,6 @@ mod drm_capturer_tests { (px, w, h, (11, 10)) } - /// The same shape standing up and equally off-centre: 8x16, opaque columns 2..4 and rows 1..13, - /// box (2,1)-(3,12), centre (2,6) against a bitmap centre of (3,7). The tall case the guess - /// already handled, kept so the symmetric rule cannot pass by breaking it. - fn ibeam_vertical() -> (Vec, usize, usize, (i32, i32)) { - let (w, h) = (8usize, 16usize); - let mut px = vec![0u8; w * h * 4]; - for y in 1..13 { - for x in 2..4 { - px[(y * w + x) * 4 + 3] = 255; - } - } - (px, w, h, (2, 6)) - } - /// What the compositor puts in the cursor plane on an output rotated by `t`: the upright sprite /// rotated forward by t, which is what turning it back by (360 - t) degrees produces. fn as_scanned_out(upright: &[u8], w: usize, h: usize, t: i32) -> (Vec, usize, usize) { @@ -2718,58 +2617,6 @@ mod drm_capturer_tests { (out, dw, dh) } - // rustdesk#15886, the maintainer's physical test: the cursor looked right at 0 and 90, drawn - // above the click point at 270, and upside down at 180. Two causes, not one. The sprite came - // out upside down at 180 because master turned it only at 90 and 270, so a 180 sprite was - // never turned back at all. And at every angle the hotspot was GUESSED on the sprite as - // scanned out and only afterwards mapped as if it were a point on it: the guess picks the - // top-left of the opaque box, and a turned arrow's tip is not in that corner - at 90 the - // error is the arrow's WIDTH along x, small enough to pass as fine; at 270 it is its HEIGHT - // along y, which is the one that got reported. - // - // The 180 entry below is about that mapping, not about what was seen: on master the clamp sent - // 180 down the untouched branch, so the guess and the sprite were turned together and the - // hotspot landed on the tip anyway - measured as (0,0) there, with only the sprite wrong. What - // the loop shows is that the guess is not a point that survives being mapped, at any angle. - // This test covers the mapping; the sprite is covered by the two below. - #[test] - fn a_guessed_hotspot_is_guessed_on_the_upright_sprite() { - use scrap::drm_reader::infer_hotspot; - let (up, w, h) = arrow(); - let tip = infer_hotspot(&up, w, h); - assert_eq!(tip, (0, 0)); - let mut old_wrong_at = Vec::new(); - for t in [90, 180, 270] { - let (scan, sw, sh) = as_scanned_out(&up, w, h, t); - // The old flow: guess on the scanned-out sprite, then map the point. - let (gx, gy) = infer_hotspot(&scan, sw, sh); - let old = unrotate_hotspot(t, sw as i32, sh as i32, gx, gy); - // The new flow: turn the sprite back first, then guess. - let mut turned = Vec::new(); - unrotate_bgra(&scan, sw, sh, t, &mut turned); - let (dw, dh) = rotated_dims(t, sw, sh); - assert_eq!(turned, up, "the sprite turns back to upright at {t}"); - assert_eq!(infer_hotspot(&turned, dw, dh), tip, "the tip is found again at {t}"); - if old != tip { - old_wrong_at.push(t); - } - } - // The old flow is wrong at every angle, not only the one that was noticed. - assert_eq!(old_wrong_at, vec![90, 180, 270]); - } - - // rustdesk#16242, the maintainer's pixel-level finding: the guess only centred TALL shapes, so - // a horizontal I-beam got the corner of its box. Before the rotation rework such a cursor - // reached a quarter-turned output as a tall bitmap and the same rule centred it by accident, so - // turning the sprite upright first - correct in itself - turned a nearly right hotspot into one - // about 11 px out. Measured on the installed Adwaita `vertical-text` at 24 px: theme hotspot - // (12, 11), opaque box 20x9, corner guess (2, 6) = 11.2 px away, centre guess (11, 10) = 1.4 px. - // - // The old flow is not the fix: it was right for this one shape and wrong for arrows at every - // angle, which is what the test above pins. The fix is for the guess to centre an elongated box - // whichever way it lies, and that is what this checks - for both orientations and at the real - // 2.22:1 aspect of the shape that was wrong - by driving `deliver_drm_cursor` and reading the - // published hotspot back, not by chaining the helpers itself. /// The upgrade window, end to end from the WIRE rather than from a struct literal. /// /// An old root service can be streaming to a freshly started `--server`, and its `DrmCursor` @@ -2782,45 +2629,6 @@ mod drm_capturer_tests { /// /// The transform must be non-zero: at 0 the delivery path never consults `hot_measured`, so a /// test at 0 could not tell the two branches apart. - /// Two streams with different but STABLE provenance must not read as a change on every - /// message. A single global slot did exactly that: display A measured and display B inferred, - /// alternating forever, is a normal multi-GPU host and would have logged per cursor message - - /// the per-frame logging this was written to avoid. And the hidden-cursor sentinel carries - /// `hot_measured: false` because it has no hotspot, so recording it would turn every hide and - /// show into a provenance transition. - #[test] - fn provenance_is_tracked_per_display_and_ignores_the_hidden_cursor() { - let (a, b) = (9_510, 9_511); - LAST_RECEIVED_PROVENANCE.lock().unwrap().remove(&a); - LAST_RECEIVED_PROVENANCE.lock().unwrap().remove(&b); - - // First sighting of each is news; the same value again is not. - assert!(provenance_is_news_for(a, true)); - assert!(!provenance_is_news_for(a, true)); - assert!(provenance_is_news_for(b, false)); - assert!(!provenance_is_news_for(b, false)); - - // The interleaving that a single global slot could not survive. - for _ in 0..4 { - assert!(!provenance_is_news_for(a, true), "display A is stable"); - assert!(!provenance_is_news_for(b, false), "display B is stable"); - } - - // A real change on one display is still reported, and does not disturb the other. - assert!(provenance_is_news_for(a, false)); - assert!(!provenance_is_news_for(b, false)); - - // The hidden sentinel is not recorded at all: it would otherwise look like B flipping. - note_received_provenance(b, scrap::drm_reader::HIDDEN_CURSOR_ID, true); - assert!( - !provenance_is_news_for(b, false), - "the hidden cursor must not count as a provenance change" - ); - - LAST_RECEIVED_PROVENANCE.lock().unwrap().remove(&a); - LAST_RECEIVED_PROVENANCE.lock().unwrap().remove(&b); - } - #[test] fn a_legacy_cursor_message_keeps_the_hotspot_the_old_protocol_meant() { use crate::ipc::Data; @@ -2894,90 +2702,6 @@ mod drm_capturer_tests { ); } - #[test] - fn an_ibeam_keeps_its_centre_whichever_way_it_lies() { - use scrap::drm_reader::infer_hotspot; - for (i, (name, (up, w, h, box_centre))) in [ - ("horizontal", ibeam_horizontal()), - ("vertical", ibeam_vertical()), - ("theme-aspect", ibeam_theme_aspect()), - ] - .into_iter() - .enumerate() - { - // The fixture must not be centred in its own bitmap, or this test cannot tell the - // centre of the opaque BOX - which is what the guess computes and what a cursor's - // click point is - from the centre of the buffer the sprite happens to sit in. - let bitmap_centre = (((w - 1) / 2) as i32, ((h - 1) / 2) as i32); - assert_ne!( - box_centre, bitmap_centre, - "the {name} fixture is centred in its bitmap, so it cannot discriminate" - ); - - // The upright guess is the centre of the opaque box, not its corner. - let guess = infer_hotspot(&up, w, h); - assert_eq!(guess, box_centre, "the {name} I-beam is centred, not cornered"); - - // And it is what the delivery path PUBLISHES, not just what the helper computes: - // `deliver_drm_cursor` is where the guessed branch is chosen and where the sprite is - // turned back, so the sprite is handed over pre-rotated exactly as the compositor - // scans it out, and the published hotspot is read back out of DRM_CURSOR. - for (j, t) in [0, 90, 180, 270].into_iter().enumerate() { - let display = 9_300 + (i * 8 + j) as i32; - let (scan, sw, sh) = as_scanned_out(&up, w, h, t); - // What the producer actually sends for a guessed hotspot: its own guess, made on - // the sprite AS SCANNED OUT (drm_reader does exactly that). At 0 the delivery path - // publishes it unchanged, which is why it has to be the real value and not a - // placeholder; at every other angle the path discards it and guesses again. - let (phx, phy) = scrap::drm_reader::infer_hotspot(&scan, sw, sh); - deliver_drm_cursor(display, 1, 7, sw as u32, sh as u32, phx, phy, false, scan, t); - let map = DRM_CURSOR.lock().unwrap(); - let (_, c) = map.get(&display).expect("the cursor path published nothing"); - assert_eq!( - (c.width as usize, c.height as usize), - (w, h), - "the published {name} sprite is upright-sized at {t}" - ); - assert_eq!(c.colors, up, "the published {name} sprite is upright at {t}"); - assert_eq!( - (c.hotx, c.hoty), - box_centre, - "the published {name} hotspot is the box centre at {t}" - ); - } - } - - // The arrow is untouched, and it sits exactly ON the boundary rather than safely inside - // it: its box is 3x6, which is the factor of two itself, so it keeps its tip only because - // the comparison is strict. That makes it the control for the other side of the threshold - // - loosen the rule to `* 1` and this line fails, tighten it past 2.22 and the - // theme-aspect case above fails. The two together pin where the boundary is. - let (arrow_px, aw, ah) = arrow(); - assert_eq!(infer_hotspot(&arrow_px, aw, ah), (0, 0)); - } - - /// The pre-0.5.8 rule, kept for `None` (a library that cannot say): it pins what was MEASURED - /// on i915 advertising rotate-180 with mutter, the primary plane scanning out upright while - /// the compositor pre-rotates the cursor sprite, so the frame is left alone and the sprite is - /// turned. With a plane answer the rule below decides the frame; the sprite rule stays. - #[test] - fn a_180_output_turns_the_cursor_but_not_the_frame_on_i915_plus_mutter() { - assert_eq!(frame_transform(180, None), 0); - assert_eq!(frame_transform(90, None), 90); - assert_eq!(frame_transform(270, None), 270); - // The plane says so: rotate-180 in hardware, the scanout is already upright. - assert_eq!(frame_transform(180, Some(0x4)), 0); - let (up, w, h) = arrow(); - let (scan, sw, sh) = as_scanned_out(&up, w, h, 180); - let mut turned = Vec::new(); - unrotate_bgra(&scan, sw, sh, 180, &mut turned); - assert_eq!(turned, up); - } - - // The two above exercise the helpers, which cannot see WHICH angle the cursor path is handed - // or whether it turns 180 at all - the line that decides both is `if t != 0` in - // `deliver_drm_cursor`, and reverting it to master's `t == 90 || t == 270` leaves every helper - // assertion above still passing. So this drives the real path and reads back what it published. #[test] fn the_cursor_path_publishes_an_upright_sprite_at_every_angle() { let (up, w, h) = arrow(); @@ -2998,27 +2722,6 @@ mod drm_capturer_tests { } } - /// Measured on virtio-gpu (no `rotation` property, mutter draws the scanout turned): the - /// dump at 180 is upside down and the dumps at 90/270 are the logical desktop turned inside - /// the native mode. A plane that reports rotate-0 did nothing, so the frame is turned by the - /// whole output transform, 180 included; a plane that reports a rotation did all of it. - #[test] - fn the_frame_is_turned_unless_the_plane_rotated_it() { - assert_eq!(frame_transform(180, Some(0x1)), 180); - assert_eq!(frame_transform(90, Some(0x1)), 90); - assert_eq!(frame_transform(270, Some(0x1)), 270); - assert_eq!(frame_transform(0, Some(0x1)), 0); - assert_eq!(frame_transform(90, Some(0x2)), 0); - assert_eq!(frame_transform(180, Some(0x4)), 0); - assert_eq!(frame_transform(270, Some(0x8)), 0); - // A reflection is the plane's work too: a reflected scanout is left alone. - assert_eq!(frame_transform(180, Some(0x1 | 0x10)), 0); - assert_eq!(frame_transform(180, Some(0x4 | 0x20)), 0); - // No rotate bit at all is not a kernel value and gets no special case. - assert_eq!(frame_transform(180, Some(0)), 0); - assert_eq!(frame_transform(90, Some(0)), 0); - } - /// The compositor programs the plane from the CRTC transform, which is the logical transform /// composed with the connector's panel orientation (mutter: meta_output_logical_to_crtc_transform); /// `wl_output` carries the logical part only. A panel mounted upside down at logical 0 has @@ -3101,51 +2804,6 @@ mod drm_capturer_tests { assert!(c.frame(Duration::from_millis(50)).is_ok()); } - #[test] - fn the_same_bytes_in_another_format_are_not_a_repeat() { - // Blue as BGRA, red as RGBA. - let px = [255u8, 0, 0, 255]; - let put = |c: &IpcDrmCapturer, fmt| { - c.shared - .slot - .lock() - .unwrap() - .publish(1, 1, fmt, None, px.to_vec()); - c.shared.cv.notify_one(); - }; - let mut c = capturer_with(Some((1, 1))); - put(&c, Pixfmt::BGRA); - assert!(c.frame(Duration::from_millis(50)).is_ok()); - put(&c, Pixfmt::RGBA); - assert!(c.frame(Duration::from_millis(50)).is_ok()); - put(&c, Pixfmt::RGBA); - assert!(matches!( - c.frame(Duration::from_millis(50)), - Err(e) if e.kind() == io::ErrorKind::WouldBlock - )); - } - - #[test] - fn a_plane_that_rotated_90_in_hardware_hands_over_a_portrait_scanout() { - use scrap::TraitPixelBuffer; - let mut c = capturer_with(Some((32, 64))); // rotated session of a 64x32 mode - c.transform = 90; - // The plane did the 90: the scanout is already 32x64 and upright, nothing to turn. - put_frame_rot(&c, 32, 64, Some(0x2)); - match c.frame(Duration::from_millis(50)) { - Ok(Frame::PixelBuffer(pb)) => assert_eq!((pb.width(), pb.height()), (32, 64)), - Ok(_) => panic!("expected a pixel-buffer frame"), - Err(err) => panic!("expected a delivered frame, got {err}"), - } - // The plane did nothing: the landscape mode arrives and is turned to 32x64. - put_frame_rot(&c, 64, 32, Some(0x1)); - match c.frame(Duration::from_millis(50)) { - Ok(Frame::PixelBuffer(pb)) => assert_eq!((pb.width(), pb.height()), (32, 64)), - Ok(_) => panic!("expected a pixel-buffer frame"), - Err(err) => panic!("expected a delivered frame, got {err}"), - } - } - #[test] fn a_portrait_scanout_from_a_plane_that_rotated_90_is_left_alone() { use scrap::TraitPixelBuffer; @@ -3178,14 +2836,6 @@ mod drm_capturer_tests { } } - #[test] - fn unrotate_180_reverses_both_axes() { - let (src, w, h) = px_frame(&[&[1, 2, 3], &[4, 5, 6]], 0); - let mut dst = Vec::new(); - unrotate_bgra(&src, w, h, 180, &mut dst); - assert_eq!(labels_of(&dst, w, h), vec![vec![6, 5, 4], vec![3, 2, 1]]); - } - #[test] fn unrotate_reads_padded_strides_and_writes_tight() { // Row stride is derived from len/h, so a padded source must not shear the result. @@ -3263,59 +2913,6 @@ mod drm_capturer_tests { c.shared.cv.notify_one(); } - - #[test] - fn a_delivered_frame_clears_the_streak_but_keeps_the_cadence_and_the_convert_verdict() { - let key = "test:frame-keeps-cadence"; - let mut c = capturer_named(Some((64, 32)), Some(key)); - { - let mut map = DRM_DISPLAY_HEALTH.lock().unwrap(); - let h = map.entry(key.to_owned()).or_insert_with(DisplayHealth::new); - h.zero_frame_streak = 2; - h.demotes = 1; - h.rapid_builds = 3; - h.last_build = Some(Instant::now()); - h.prefer_cpu = true; - h.fallback_rejected = true; - } - put_frame(&c, 64, 32); - assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); - - // Copy out and RELEASE the guard before asserting: a failing assertion while holding - // process-wide DRM_DISPLAY_HEALTH poisons the mutex for every sibling test. - let h = { - let map = DRM_DISPLAY_HEALTH.lock().unwrap(); - *map.get(key).expect("the entry must SURVIVE a delivered frame") - }; - assert_eq!(h.zero_frame_streak, 0, "a delivered frame refutes the zero-frame streak"); - assert_eq!(h.demotes, 0, "and the demotion count that streak drove"); - assert!( - !h.fallback_rejected, - "a delivered frame also refutes the rejected-fallback verdict" - ); - assert_eq!( - h.rapid_builds, 3, - "but it says NOTHING about the rebuild cadence: keeping it is what lets the flap guard \ - reach RAPID_REBUILD_MAX for a display that delivers a first frame and then fails" - ); - assert!(h.last_build.is_some(), "same for the timestamp the cadence is measured from"); - assert!( - h.prefer_cpu, - "and nothing about which GPU exports the scanout: only a topology change may clear it" - ); - } - - #[test] - fn frame_of_the_session_size_is_delivered() { - let mut c = capturer_with(Some((64, 32))); - put_frame(&c, 64, 32); - assert!( - matches!(c.frame(Duration::from_millis(50)), Ok(_)), - "a frame matching the session geometry must be delivered" - ); - assert!(c.got_frame); - } - #[test] fn a_smaller_frame_ends_the_session_instead_of_being_encoded() { let mut c = capturer_named(Some((1920, 1080)), Some("test:mid-session-shrink")); @@ -3338,41 +2935,10 @@ mod drm_capturer_tests { ); } - #[test] - fn a_first_frame_that_never_matched_counts_as_a_session_without_frames() { - let mut c = capturer_named(Some((1920, 1080)), Some("test:never-matched")); - put_frame(&c, 1280, 720); - let err = match c.frame(Duration::from_millis(50)) { - Err(e) => e, - Ok(_) => panic!("a first frame off the advertised geometry must be a hard error"), - }; - assert!(err.to_string().contains("never matched its advertised geometry")); - assert!(!c.got_frame, "no frame reached the encoder, so none was produced"); - assert_eq!( - zero_frame_streak_of(&c), - 1, - "the display must be on its way to a PipeWire demotion, not just rebuilding" - ); - } - - #[test] - fn a_larger_frame_ends_the_session_too() { - let mut c = capturer_with(Some((1280, 720))); - put_frame(&c, 1920, 1080); - assert!(matches!(c.frame(Duration::from_millis(50)), Err(_))); - } - - #[test] - fn unknown_session_size_delivers_whatever_arrives() { - let mut c = capturer_with(None); - put_frame(&c, 800, 600); - assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); - } - // ---- cursor hotspot calibration ---- // The cache and DRM_CURSOR are process-wide and libtest runs tests concurrently: every test // below has its own display id (9_700..9_799) and its own shape id, and the ones that read - // the cache back hold this lock, because one of them clears it. + // the cache back hold this lock. static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); const CAL_GEN: u64 = 7; @@ -3481,103 +3047,6 @@ mod drm_capturer_tests { forget_cursor_cal(shape, CAL_GEN); } - // The confirmed value is within tolerance of the wire: the producer's guess was right, the - // wire value is served under the plain id, and the stale cached correction goes. - #[test] - fn a_confirmed_value_near_the_wire_serves_the_wire_and_drops_the_cached_correction() { - let _serial = CACHE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); - let (d, e, shape) = (9_701, next_cursor_epoch(), 0x7a01); - store_cursor_cal(shape, (30, 4), CAL_GEN); - let mut cal = arrive(d, e, shape, (10, 10)); - assert_eq!(served(d), (corrected(shape, (30, 4)), 30, 4), "seeded from the cache"); - // (12,10) then (14,10): confirms (12,10), which is 2 px from the wire. - settle(&mut cal, (488, 290), Some(peer((500, 300), 1)), d, e); - settle(&mut cal, (586, 390), Some(peer((600, 400), 2)), d, e); - assert_eq!(cached_cursor_cal(shape, CAL_GEN), None, "the wire was right: the correction goes"); - assert_eq!(cal.as_ref().unwrap().current_hot, (10, 10)); - assert_eq!(served(d), (plain(shape), 10, 10)); - } - - // The confirmed value is within tolerance of what is already served: nothing visible - // changes, and the served value is cached again if a clear-on-cap took it out meanwhile. - #[test] - fn a_confirmed_value_in_band_with_the_served_one_changes_nothing_and_stays_cached() { - let _serial = CACHE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); - let (d, e, shape) = (9_702, next_cursor_epoch(), 0x7a02); - store_cursor_cal(shape, (12, 12), CAL_GEN); - let mut cal = arrive(d, e, shape, (4, 4)); - let before = served(d); - assert_eq!(before, (corrected(shape, (12, 12)), 12, 12)); - CURSOR_CAL_CACHE.lock().unwrap().clear(); - // (13,12) then (15,12): confirms (13,12), 1 px from what is served. - settle(&mut cal, (488, 288), Some(peer((501, 300), 1)), d, e); - settle(&mut cal, (587, 388), Some(peer((602, 400), 2)), d, e); - assert_eq!(served(d), before, "in band: nothing visible changes"); - assert_eq!(cal.as_ref().unwrap().current_hot, (12, 12)); - assert_eq!(cached_cursor_cal(shape, CAL_GEN), Some((12, 12)), "the served value is cached again"); - forget_cursor_cal(shape, CAL_GEN); - } - - // Review of rustdesk#16122 (pre-push, 23-sep): a served correction 3 px from the wire and a - // confirmed value in band with BOTH. The served value wins, or every confirmation in that - // band would flap the hotspot and the id. A value in band with the wire only still goes - // back to the wire. Mutation caught: comparing the wire before the served value. - #[test] - fn a_confirmed_value_in_band_with_both_keeps_the_served_correction() { - let _serial = CACHE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); - let (d, e, shape) = (9_706, next_cursor_epoch(), 0x7a06); - store_cursor_cal(shape, (13, 10), CAL_GEN); - let mut cal = arrive(d, e, shape, (10, 10)); - let before = served(d); - assert_eq!(before, (corrected(shape, (13, 10)), 13, 10)); - // (12,10) then (14,10): confirms (12,10), 2 px from the wire AND 1 px from the served. - settle(&mut cal, (488, 290), Some(peer((500, 300), 1)), d, e); - settle(&mut cal, (586, 390), Some(peer((600, 400), 2)), d, e); - assert_eq!(served(d), before, "in band with the served value: it stays"); - assert_eq!(cal.as_ref().unwrap().current_hot, (13, 10)); - assert_eq!(cached_cursor_cal(shape, CAL_GEN), Some((13, 10))); - // (10,10) twice: in band with the wire only, so the wire is served again. - settle(&mut cal, (490, 290), Some(peer((500, 300), 3)), d, e); - settle(&mut cal, (590, 390), Some(peer((600, 400), 4)), d, e); - assert_eq!(served(d), (plain(shape), 10, 10)); - assert_eq!(cached_cursor_cal(shape, CAL_GEN), None); - } - - #[test] - fn a_disagreeing_measurement_replaces_the_candidate_and_the_same_sample_never_confirms() { - let mut c = arrive(9_703, next_cursor_epoch(), 0x7a03, (4, 4)).unwrap(); - assert_eq!(observe_measurement(&mut c, (12, 12), 1, 0), None); - assert_eq!(c.candidate, Some(((12, 12), 1, 0))); - assert_eq!(observe_measurement(&mut c, (30, 4), 2, 0), None, "disagreement replaces"); - assert_eq!(c.candidate, Some(((30, 4), 2, 0))); - assert_eq!(observe_measurement(&mut c, (31, 4), 3, 0), Some((30, 4)), "the retained one"); - assert_eq!(c.candidate, None); - assert_eq!(observe_measurement(&mut c, (12, 12), 4, 0), None); - assert_eq!(observe_measurement(&mut c, (12, 12), 4, 0), None, "same sample: not independent"); - assert_eq!(c.candidate, Some(((12, 12), 4, 0))); - } - - // The local hand on the mouse (review of rustdesk#16122): the remote peer stops, a local user - // nudges the pointer, the plane settles somewhere new, and the only sample on hand is the - // stale remote one. However many times the plane settles, the same sample never confirms. - #[test] - fn a_plane_move_without_a_new_peer_sample_never_confirms() { - let (d, e, shape) = (9_705, next_cursor_epoch(), 0x7a05); - forget_cursor_cal(shape, CAL_GEN); - let mut cal = arrive(d, e, shape, (4, 4)); - let before = served(d); - let stale = Some(peer((500, 300), 1)); - settle(&mut cal, (493, 288), stale, d, e); // (7,12) - assert_eq!(cal.as_ref().unwrap().candidate, Some(((7, 12), 1, 0))); - settle(&mut cal, (494, 288), stale, d, e); // (6,12), same sample - assert_eq!(cal.as_ref().unwrap().candidate, Some(((7, 12), 1, 0))); - settle(&mut cal, (400, 400), stale, d, e); // away: out of the bitmap - settle(&mut cal, (493, 288), stale, d, e); // back: (7,12) again, same sample - assert_eq!(cal.as_ref().unwrap().candidate, Some(((7, 12), 1, 0))); - assert_eq!(served(d), before); - assert_eq!(cached_cursor_cal(shape, CAL_GEN), None); - } - #[test] fn a_calibration_context_exists_only_for_an_identity_matched_upright_output() { let one = |t: i32| { @@ -3657,254 +3126,6 @@ mod drm_capturer_tests { assert_eq!(measure_hotspot(&ctx, (1643, 577), (2357, 814), (128, 128)), Some((26, 23))); } - // The cache is read behind the context gate only: without a context a correction measured - // upright is never served, which is what a rotated output relies on. - #[test] - fn a_cached_correction_is_not_served_without_a_context() { - let _serial = CACHE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); - let (d, e, shape) = (9_706, next_cursor_epoch(), 0x7a06); - store_cursor_cal(shape, (20, 20), CAL_GEN); - let raw = vec![0; 64 * 64 * 4]; - let cal = on_cursor_shape(d, e, None, shape, 64, 64, 4, 4, false, raw.clone(), 0); - assert!(cal.is_none()); - assert_eq!(served(d), (plain(shape), 4, 4), "no context, no cache read"); - let cal = on_cursor_shape(d, e, Some(ctx1()), shape, 64, 64, 4, 4, false, raw, 0); - assert!(cal.is_some(), "control: with a context the same arrival is served corrected"); - assert_eq!(served(d), (corrected(shape, (20, 20)), 20, 20)); - forget_cursor_cal(shape, CAL_GEN); - } - - #[test] - fn a_kernel_measured_hotspot_is_never_calibrated_even_at_the_origin() { - let (d, e, shape) = (9_707, next_cursor_epoch(), 0x7a07); - let raw = vec![0; 64 * 64 * 4]; - let kernel = on_cursor_shape(d, e, Some(ctx1()), shape, 64, 64, 0, 0, true, raw.clone(), 0); - assert!(kernel.is_none()); - assert_eq!(served(d), (plain(shape), 0, 0)); - let guess = on_cursor_shape(d, e, Some(ctx1()), shape, 64, 64, 0, 0, false, raw, 0); - assert!(guess.is_some(), "control: a guess at the origin is measured"); - assert!(!calibratable(scrap::drm_reader::HIDDEN_CURSOR_ID, false)); - } - - // Each stable position yields at most one measurement: a fresh sample arriving while the - // plane is still does not get a second one, so it cannot confirm the first. - #[test] - fn a_still_plane_is_measured_once() { - let (d, e, shape) = (9_708, next_cursor_epoch(), 0x7a08); - forget_cursor_cal(shape, CAL_GEN); - let mut cal = arrive(d, e, shape, (4, 4)); - let before = served(d); - settle(&mut cal, (488, 288), Some(peer((500, 300), 1)), d, e); - assert_eq!(cal.as_ref().unwrap().candidate, Some(((12, 12), 1, 0))); - for _ in 0..CURSOR_CAL_WINDOW_TICKS { - let fresh = Some(peer((500, 300), 2)); - on_cursor_plane(&mut cal, Some((488, 288)), || fresh, false, CAL_GEN, CAL_GEN, 0, d, e); - } - assert_eq!(cal.as_ref().unwrap().candidate, Some(((12, 12), 1, 0))); - assert_eq!(served(d), before); - assert_eq!(cached_cursor_cal(shape, CAL_GEN), None); - } - - #[test] - fn the_settle_window_and_the_sample_gates_are_closed_intervals() { - let mut c = arrive(9_709, next_cursor_epoch(), 0x7a09, (4, 4)).unwrap(); - for (ticks, ok) in [ - (CURSOR_CAL_STABLE_TICKS - 1, false), - (CURSOR_CAL_STABLE_TICKS, true), - (CURSOR_CAL_WINDOW_TICKS, true), - (CURSOR_CAL_WINDOW_TICKS + 1, false), - ] { - c.stable_ticks = ticks; - c.measured_this_position = false; - assert_eq!(settled_unmeasured(&c), ok, "stable_ticks {ticks}"); - } - c.stable_ticks = CURSOR_CAL_STABLE_TICKS; - c.measured_this_position = true; - assert!(!settled_unmeasured(&c)); - let ctx = ctx1(); - let s = |age: u64, gen: u64| { - Some(crate::server::input_service::PeerAbsSample { - pos: (1, 1), - age_ms: age, - seq: 9, - gen, - map_epoch: 3, - }) - }; - let min = CURSOR_CAL_MIN_INPUT_AGE_MS; - let max = CURSOR_CAL_MAX_INPUT_AGE_MS; - let g = CAL_GEN; - assert_eq!(usable_sample(&ctx, s(min, g), false, g, g, 3), Some(((1, 1), 9, 3))); - assert_eq!(usable_sample(&ctx, s(max, g), false, g, g, 3), Some(((1, 1), 9, 3))); - assert_eq!(usable_sample(&ctx, s(min - 1, g), false, g, g, 3), None); - assert_eq!(usable_sample(&ctx, s(max + 1, g), false, g, g, 3), None); - assert_eq!(usable_sample(&ctx, None, false, g, g, 3), None); - assert_eq!(usable_sample(&ctx, s(400, g), true, g, g, 3), None, "drift"); - assert_eq!(usable_sample(&ctx, s(400, g), false, g + 1, g, 3), None, "layout moved"); - assert_eq!(usable_sample(&ctx, s(400, g - 1), false, g, g, 3), None, "old sample"); - } - - /// zhou, 25-sep review of #16122: while the device runs the range of another layout (an - /// update pending or failed after the generation moved), or before any range was adopted, an - /// injected point lands elsewhere than the peer meant, and two stops share that error; and a - /// sample injected before the last adoption was mapped by the old range even when the - /// generation matches. Neither is a calibration reference. - #[test] - fn a_sample_is_a_reference_only_under_the_adopted_input_mapping() { - let ctx = ctx1(); - let s = Some(crate::server::input_service::PeerAbsSample { - pos: (1, 1), - age_ms: 400, - seq: 9, - gen: CAL_GEN, - map_epoch: 3, - }); - assert_eq!(usable_sample(&ctx, s, false, CAL_GEN, CAL_GEN, 3), Some(((1, 1), 9, 3))); - assert_eq!( - usable_sample(&ctx, s, false, CAL_GEN, CAL_GEN - 1, 3), - None, - "the device still runs the range of the previous layout" - ); - assert_eq!(usable_sample(&ctx, s, false, CAL_GEN, u64::MAX, 3), None, "no range adopted"); - assert_eq!( - usable_sample(&ctx, s, false, CAL_GEN, CAL_GEN, 4), - None, - "injected before the device adopted the current range" - ); - } - - /// The candidate was measured under the previous input mapping: a measurement under the new - /// one starts over instead of confirming it, however well the two agree. - #[test] - fn a_candidate_does_not_confirm_across_an_input_mapping_change() { - let mut c = arrive(9_721, next_cursor_epoch(), 0x7a21, (4, 4)).unwrap(); - assert_eq!(observe_measurement(&mut c, (30, 4), 1, 3), None); - assert_eq!(observe_measurement(&mut c, (30, 4), 2, 4), None, "a new range in between"); - assert_eq!(c.candidate, Some(((30, 4), 2, 4)), "the newer one is the candidate"); - assert_eq!(observe_measurement(&mut c, (31, 4), 3, 4), Some((30, 4))); - } - - /// zhou, 25-sep review of #16122: a correction two stops confirmed under one layout - /// generation is not served to the stream rebuilt under the next one, which serves the wire - /// hotspot until a pair of its own confirms a value; and a late write from the old stream - /// neither replaces nor drops what the new one confirmed. - #[test] - fn a_correction_confirmed_under_an_older_generation_is_not_served() { - let _serial = CACHE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); - let (d, e, shape) = (9_722, next_cursor_epoch(), 0x7a22); - let next = CAL_GEN + 1; - forget_cursor_cal(shape, next); - let mut old = arrive(d, e, shape, (4, 4)); - settle(&mut old, (488, 288), Some(peer((500, 300), 1)), d, e); - settle(&mut old, (587, 388), Some(peer((600, 400), 2)), d, e); - assert_eq!(cached_cursor_cal(shape, CAL_GEN), Some((12, 12))); - let mut ctx = ctx1(); - ctx.built_gen = next; - let (d2, e2) = (9_724, next_cursor_epoch()); - let raw = vec![0; 64 * 64 * 4]; - let mut new = on_cursor_shape(d2, e2, Some(ctx), shape, 64, 64, 4, 4, false, raw, 0); - assert_eq!(served(d2), (plain(shape), 4, 4), "the rebuilt stream serves the wire value"); - // (500,300) - (480,280) and (600,400) - (580,380): (20,20), under the new generation. - settle_under( - &mut new, - (480, 280), - Some(peer_under((500, 300), 3, next, 0)), - d2, - e2, - (next, next, 0), - ); - settle_under( - &mut new, - (580, 380), - Some(peer_under((600, 400), 4, next, 0)), - d2, - e2, - (next, next, 0), - ); - assert_eq!(cached_cursor_cal(shape, next), Some((20, 20))); - assert_eq!(served(d2), (corrected(shape, (20, 20)), 20, 20)); - store_cursor_cal(shape, (12, 12), CAL_GEN); - forget_cursor_cal(shape, CAL_GEN); - assert_eq!(cached_cursor_cal(shape, next), Some((20, 20)), "the old stream writes late"); - forget_cursor_cal(shape, next); - } - - /// zhou, 25-sep review of #16122: generations equal and no drift, but the device still runs - /// the uinput range of the previous layout: two otherwise valid stops publish and cache - /// nothing. After the device acknowledges the range, a sample injected before that does not - /// count, and fresh ones confirm. - #[test] - fn a_stale_input_mapping_publishes_nothing_until_fresh_samples_follow_the_adoption() { - let _serial = CACHE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); - let (d, e, shape) = (9_725, next_cursor_epoch(), 0x7a25); - forget_cursor_cal(shape, CAL_GEN); - let mut cal = arrive(d, e, shape, (4, 4)); - let stale = (CAL_GEN, CAL_GEN - 1, 0); - settle_under( - &mut cal, - (488, 288), - Some(peer_under((500, 300), 1, CAL_GEN, 0)), - d, - e, - stale, - ); - settle_under( - &mut cal, - (587, 388), - Some(peer_under((600, 400), 2, CAL_GEN, 0)), - d, - e, - stale, - ); - assert_eq!(cal.as_ref().unwrap().candidate, None, "nothing measured"); - assert_eq!(cached_cursor_cal(shape, CAL_GEN), None); - assert_eq!(served(d), (plain(shape), 4, 4)); - let adopted = (CAL_GEN, CAL_GEN, 1); - settle_under( - &mut cal, - (478, 278), - Some(peer_under((500, 300), 3, CAL_GEN, 0)), - d, - e, - adopted, - ); - assert_eq!(cal.as_ref().unwrap().candidate, None, "injected before the adoption"); - settle_under( - &mut cal, - (488, 288), - Some(peer_under((500, 300), 4, CAL_GEN, 1)), - d, - e, - adopted, - ); - settle_under( - &mut cal, - (587, 388), - Some(peer_under((600, 400), 5, CAL_GEN, 1)), - d, - e, - adopted, - ); - assert_eq!(cached_cursor_cal(shape, CAL_GEN), Some((12, 12))); - assert_eq!(served(d), (corrected(shape, (12, 12)), 12, 12)); - forget_cursor_cal(shape, CAL_GEN); - } - - #[test] - fn a_settle_lands_one_sample_later_than_the_constant_reads() { - let mut c = arrive(9_710, next_cursor_epoch(), 0x7a10, (4, 4)).unwrap(); - for _ in 0..=CURSOR_CAL_STABLE_TICKS { - update_plane_stability(&mut c, (10, 20)); - } - assert_eq!(c.stable_ticks, CURSOR_CAL_STABLE_TICKS); - c.measured_this_position = true; - update_plane_stability(&mut c, (11, 20)); - assert_eq!( - (c.plane, c.stable_ticks, c.measured_this_position), - (Some((11, 20)), 0, false) - ); - } - #[test] fn the_measurement_is_the_injected_tip_in_scanout_space_minus_the_plane() { let ctx = ctx1(); @@ -3927,62 +3148,6 @@ mod drm_capturer_tests { assert_eq!(measure_hotspot(&ctx, (480, 300), (488, 288), (32, 32)), None); } - #[test] - fn a_corrected_id_differs_from_the_wire_id_and_is_deterministic() { - let wire = 0xabcdef0123456789u64; - let a = remix_cursor_id(wire, 12, 12); - assert_ne!(a, wire); - assert_eq!(a, remix_cursor_id(wire, 12, 12)); - assert_ne!(a, remix_cursor_id(wire, 13, 12)); - } - - #[test] - fn the_calibration_cache_is_bounded() { - let _serial = CACHE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); - for i in 0..(CURSOR_CAL_CACHE_CAP as u64 * 2) { - store_cursor_cal(0x1_0000 + i, (1, 1), CAL_GEN); - } - assert!(CURSOR_CAL_CACHE.lock().unwrap().len() <= CURSOR_CAL_CACHE_CAP); - CURSOR_CAL_CACHE.lock().unwrap().clear(); - } - - /// The bound clears what the writer's generation or an older one confirmed, never a newer - /// entry: a stream still on the old layout filling the cache does not drop what the rebuilt - /// one confirmed. With only newer entries left, the older writer's value is not cached. - #[test] - fn the_bound_never_drops_an_entry_from_a_newer_generation() { - let _serial = CACHE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); - CURSOR_CAL_CACHE.lock().unwrap().clear(); - let newer = CAL_GEN + 1; - for i in 0..CURSOR_CAL_CACHE_CAP as u64 { - store_cursor_cal(0x2_0000 + i, (1, 1), newer); - } - store_cursor_cal(0x3_0000, (2, 2), CAL_GEN); - assert_eq!(cached_cursor_cal(0x3_0000, CAL_GEN), None, "no room without a newer entry"); - assert_eq!(cached_cursor_cal(0x2_0000, newer), Some((1, 1))); - store_cursor_cal(0x3_0001, (2, 2), newer); - assert_eq!(cached_cursor_cal(0x3_0001, newer), Some((2, 2)), "its own generation clears"); - assert!(CURSOR_CAL_CACHE.lock().unwrap().len() <= CURSOR_CAL_CACHE_CAP); - CURSOR_CAL_CACHE.lock().unwrap().clear(); - } - - // A held frame can carry the previous shape's plane position after a new shape arrived: - // a fresh CursorCal starts with no plane, so its first position always counts as a move. - #[test] - fn the_first_position_after_a_shape_never_counts_as_settled() { - let (d, e, shape) = (9_711, next_cursor_epoch(), 0x7a11); - let mut cal = arrive(d, e, shape, (4, 4)); - on_cursor_plane(&mut cal, None, || None, false, CAL_GEN, CAL_GEN, 0, d, e); - assert_eq!(cal.as_ref().unwrap().plane, None, "no position, no state change"); - for _ in 0..CURSOR_CAL_STABLE_TICKS { - let s = Some(peer((500, 300), 1)); - on_cursor_plane(&mut cal, Some((488, 288)), || s, false, CAL_GEN, CAL_GEN, 0, d, e); - } - let c = cal.as_ref().unwrap(); - assert_eq!(c.stable_ticks, CURSOR_CAL_STABLE_TICKS - 1); - assert_eq!(c.candidate, None); - } - fn drm_display(name: &str, w: u32, h: u32) -> DrmDisplayInfo { DrmDisplayInfo { name: name.to_owned(), @@ -4038,15 +3203,6 @@ mod drm_capturer_tests { assert_eq!((infos[0].width, infos[0].height), (1280, 800)); } - #[test] - fn a_plane_transposed_scanout_is_delivered_at_the_advertised_size() { - let d = plane_transposed_display("eDP-1", 800, 1280); - let session = rotated_dims(delivered_transform(&d, 0), d.width as usize, d.height as usize); - let mut c = capturer_named(Some(session), Some("test:plane-transposed-session")); - put_frame_rot(&c, 1280, 800, Some(0x8)); - assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); - } - #[test] fn a_rotated_output_over_a_rotated_plane_is_swapped_once() { // mutter rotates the output and programs the plane from it: one quarter turn, as before. @@ -4125,66 +3281,6 @@ mod drm_capturer_tests { assert_eq!(primary_index_from_assignment(&assignment, wl.primary), 1); } - #[test] - fn frame_buffers_circulate_instead_of_being_reallocated() { - let mut c = capturer_with(Some((64, 32))); - put_frame(&c, 64, 32); - put_frame(&c, 64, 32); - let recycled = c - .shared - .slot - .lock() - .unwrap() - .free - .iter() - .find_map(|b| b.as_ref()) - .map(|b| b.as_ptr()); - assert!( - recycled.is_some(), - "a superseded frame must be handed back, not dropped" - ); - put_frame(&c, 64, 32); - assert_eq!( - c.shared - .slot - .lock() - .unwrap() - .latest - .as_ref() - .map(|(.., b)| b.as_ptr()), - recycled, - "the receive path must refill the recycled buffer rather than allocate" - ); - assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); - assert!( - c.shared.slot.lock().unwrap().free.iter().any(|b| b.is_some()), - "the buffer the encoder finished with must be handed back to the receive path" - ); - } - - // Against a single free slot this asserts red: counting the offers is the point. - #[test] - fn two_idle_buffers_are_both_kept_rather_than_one_being_dropped() { - let mut c = capturer_with(Some((64, 32))); - put_frame(&c, 64, 32); - assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); - while c.shared.slot.lock().unwrap().take_free().is_some() {} - - put_frame(&c, 64, 32); // fills a fresh buffer (nothing on offer) and publishes it - put_frame(&c, 64, 32); // supersedes it -> deposit #1 - assert_eq!( - c.shared.slot.lock().unwrap().free.iter().flatten().count(), - 1, - "the superseded frame is the first idle buffer" - ); - assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); - assert_eq!( - c.shared.slot.lock().unwrap().free.iter().flatten().count(), - 2, - "both idle buffers must be kept; a single slot dropped the older one" - ); - } - #[test] fn a_resolution_guess_never_steals_an_exact_name_match() { // The review's scenario: an earlier connector with an unmatchable name shares the @@ -4211,13 +3307,6 @@ mod drm_capturer_tests { assert!(m2[0].is_none() && m2[1].is_none()); } - #[test] - fn outputs_are_matched_by_name_across_the_drm_naming_difference() { - let drm = [drm_display("HDMI-A-1", 1920, 1080), drm_display("DP-1", 2560, 1440)]; - let wl = [wl_display("DP-1", 1920, 0, 2560, 1440), wl_display("HDMI-1", 0, 0, 1920, 1080)]; - assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(1), Some(0)]); - } - // The M10 case: same model and resolution, names that do not normalize to the compositor's. #[test] fn identical_monitors_that_match_no_name_take_layout_order() { @@ -4229,75 +3318,6 @@ mod drm_capturer_tests { assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(0), Some(1)]); } - #[test] - fn one_output_is_never_claimed_by_two_connectors() { - let drm = [drm_display("DP-1", 1920, 1080), drm_display("DP-2", 1920, 1080)]; - let wl = [ - wl_display("Unknown-1", 0, 0, 1920, 1080), - wl_display("Unknown-2", 1920, 0, 3840, 2160), - ]; - let got = assign_wayland_outputs(&drm, &wl); - assert_eq!(got[0], Some(0)); - assert_ne!(got[0], got[1], "two connectors must not share one output"); - } - - #[test] - fn a_name_match_beats_the_positional_fallback() { - let drm = [drm_display("DP-1", 1920, 1080), drm_display("HDMI-A-1", 1920, 1080)]; - let wl = [ - wl_display("Unknown-1", 0, 0, 1920, 1080), - wl_display("HDMI-1", 1920, 0, 1920, 1080), - ]; - assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(0), Some(1)]); - } - - #[test] - fn extra_connectors_stay_unmatched() { - let drm = [ - drm_display("DP-1", 1920, 1080), - drm_display("DP-2", 1920, 1080), - drm_display("DP-3", 1920, 1080), - ]; - let wl = [ - wl_display("Unknown-1", 0, 0, 1920, 1080), - wl_display("Unknown-2", 1920, 0, 1920, 1080), - ]; - assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(0), Some(1), None]); - } - - #[test] - fn refresh_keeps_a_verdict_through_one_failure_and_gives_it_up_after_a_run() { - assert_eq!(refresh_outcome(Some(3), 0), RefreshOutcome::Publish); - assert_eq!(refresh_outcome(Some(1), 0), RefreshOutcome::Publish); - assert_eq!(refresh_outcome(Some(0), 0), RefreshOutcome::Unavailable); - assert_eq!(refresh_outcome(None, 1), RefreshOutcome::Restamp); - assert_eq!( - refresh_outcome(None, DRM_REFRESH_MAX_FAILURES - 1), - RefreshOutcome::Restamp - ); - assert_eq!( - refresh_outcome(None, DRM_REFRESH_MAX_FAILURES), - RefreshOutcome::GiveUp - ); - assert_eq!( - refresh_outcome(None, DRM_REFRESH_MAX_FAILURES + 5), - RefreshOutcome::GiveUp - ); - } - - #[test] - fn a_dead_producer_stops_being_advertised() { - let mut outcome = RefreshOutcome::Restamp; - for failures in 1..=DRM_REFRESH_MAX_FAILURES { - outcome = refresh_outcome(None, failures); - } - assert_eq!(outcome, RefreshOutcome::GiveUp); - assert!( - DRM_REFRESH_MAX_FAILURES >= 2, - "a single transient failure must never be enough to drop the verdict" - ); - } - #[test] fn health_reports_demoted_only_while_the_cooldown_runs() { let mut h = DisplayHealth::new(); @@ -4313,25 +3333,6 @@ mod drm_capturer_tests { assert!(h.demoted(), "the backoff must still be holding it at demotion 4"); } - #[test] - fn demote_cooldown_doubles_per_cycle_and_caps() { - assert_eq!(demote_cooldown(1), DEMOTE_COOLDOWN); - assert_eq!(demote_cooldown(2), DEMOTE_COOLDOWN * 2); - assert_eq!(demote_cooldown(3), DEMOTE_COOLDOWN * 4); - let cap = DEMOTE_COOLDOWN * (1 << DEMOTE_BACKOFF_MAX_SHIFT); - assert_eq!(demote_cooldown(1 + DEMOTE_BACKOFF_MAX_SHIFT), cap); - assert_eq!(demote_cooldown(50), cap); - assert_eq!(demote_cooldown(u32::MAX), cap); - assert_eq!(demote_cooldown(0), DEMOTE_COOLDOWN); - } - - #[test] - fn a_permanently_ungrabbable_display_stops_churning() { - let burn = Duration::from_secs(5); // four failed sessions - assert!(demote_cooldown(1) + burn < Duration::from_secs(40)); - assert!(demote_cooldown(5) + burn > Duration::from_secs(8 * 60)); - } - // --------------------------------------------------------------------------------------- // rustdesk#16242 round 2: the measurement behind the inference model. // @@ -4459,7 +3460,7 @@ mod drm_capturer_tests { // rustdesk#16242 round 2: the guess measured against the hotspots a THEME declares. // // Every test above builds its own sprite, so all of them compare the guess with the guess. - // That can pin a mapping but it cannot say whether the guess is any good. These two read the + // That can pin a mapping but it cannot say whether the guess is any good. This one reads the // expected value from the cursor theme instead - the click point its author wrote into the // file, which is exactly what a compositor programs into HOTSPOT_X/Y when it has the // property to program. @@ -4524,71 +3525,6 @@ mod drm_capturer_tests { (((a.0 - b.0) as f64).powi(2) + ((a.1 - b.1) as f64).powi(2)).sqrt() } - /// A hotspot names a point on the SHAPE, so the same sprite has to get the same click point - /// whatever angle the output happens to sit at. This drives the real delivery path for every - /// shape in the theme at all four angles and demands exactly that. - /// - /// The maintainer's objection on this PR was a table of four shapes where the old flow landed - /// closer than the new one. It did, at some angles - and 16 to 19 px out at the others, which - /// is what the second half of this test measures: with the old flow not one of the 35 shapes - /// keeps its hotspot across the four angles, and the point moves by 15 px on average. That - /// number is also the control. Without it the first assertion would still pass on a revert, - /// since a flow that is wrong the same way at every angle is consistent too. - #[test] - fn a_published_hotspot_does_not_move_when_the_output_turns() { - let mut display = 21_000; - let (mut spread_sum, mut spread_max, mut spread_zero) = (0f64, 0f64, 0usize); - for &(name, w, h, _, _, mask) in THEME_SHAPES { - let (w, h) = (w as usize, h as usize); - let up = theme_sprite(mask, w, h); - let (mut published, mut mapped) = (Vec::new(), Vec::new()); - for t in [0, 90, 180, 270] { - let (scan, sw, sh) = as_scanned_out(&up, w, h, t); - // What the producer puts on the wire: the guess taken on the sprite as the plane - // holds it, with no provenance behind it. - let sent = scrap::drm_reader::infer_hotspot(&scan, sw, sh); - mapped.push(unrotate_hotspot(t, sw as i32, sh as i32, sent.0, sent.1)); - display += 1; - deliver_drm_cursor( - display, 1, 1, sw as u32, sh as u32, sent.0, sent.1, false, scan, t, - ); - let map = DRM_CURSOR.lock().unwrap(); - let (_, c) = map.get(&display).expect("the delivery path published nothing"); - published.push((c.hotx, c.hoty)); - } - assert!( - published.windows(2).all(|p| p[0] == p[1]), - "{name}: the published hotspot follows the output angle: {published:?}" - ); - let spread = mapped - .iter() - .flat_map(|a| mapped.iter().map(move |b| away(*a, *b))) - .fold(0f64, f64::max); - spread_sum += spread; - spread_max = spread_max.max(spread); - if spread == 0.0 { - spread_zero += 1; - } - } - // The control, and it has to be read with the guess this PR now makes. A box centre and a - // heavier-half extreme both map like a pixel, so for the 16 shapes that land on one the - // old flow happens to be angle-independent too. The other 19 move, by up to 26.87 px on a - // 24 px sprite. Without this the assertion above would also pass on a revert: a flow that - // is wrong the same way at every angle is consistent too. - let n = THEME_SHAPES.len(); - assert!( - n - spread_zero >= 15, - "the control has gone vacuous: the old flow now keeps {spread_zero} of {n} shapes \ - stable, so the assertion above no longer separates the two flows" - ); - assert!( - spread_sum / n as f64 > 5.0 && spread_max > 20.0, - "the control is weak: old-flow spread mean {:.2} max {:.2} over {n} shapes", - spread_sum / n as f64, - spread_max - ); - } - /// How far the delivered hotspot lands from the click point the theme declares, per shape, /// measured against MASTER and not against an earlier revision of this PR. /// @@ -4613,8 +3549,7 @@ mod drm_capturer_tests { let mut e_now = 0f64; for t in angles { e_master += away(master_delivered(&up, w, h, t), truth); - // The upright inference answers the same at every angle by construction; the - // angle-independence test above is what pins that. + // The upright inference answers the same at every angle by construction. e_now += away(scrap::drm_reader::infer_hotspot(&up, w, h), truth); } let (e_master, e_now) = (e_master / angles.len() as f64, e_now / angles.len() as f64); @@ -4636,44 +3571,4 @@ mod drm_capturer_tests { sum_now / n ); } - - /// The two shapes the test above excuses are excused for a measured reason, not because they - /// were in the way, and the excuse is bounded in BOTH directions it could grow: the average - /// over the angles master handles, and the worst single angle. The worst angle is the bigger - /// number and the one that would be felt - `help` at 270 is 8.00 px under master and 21.54 - /// here - so leaving it unbounded would let the exemption widen quietly. - #[test] - fn the_two_excused_shapes_are_excused_by_how_much() { - // (shape, mean excess allowed, worst single-angle excess allowed). Measured: alias 0.51 - // mean and 8.94 at 90; help 4.73 mean and 13.54 at 270. - for (name, mean_bound, angle_bound) in [("alias", 0.75f64, 9.5f64), ("help", 5.5, 14.5)] { - let &(_, w, h, hx, hy, mask) = THEME_SHAPES - .iter() - .find(|s| s.0 == name) - .expect("the corpus must carry the excused shapes"); - let (w, h) = (w as usize, h as usize); - let up = theme_sprite(mask, w, h); - let truth = (hx, hy); - let now = away(scrap::drm_reader::infer_hotspot(&up, w, h), truth); - let angles = [0, 90, 270]; - let mut worst = f64::MIN; - let mut total = 0f64; - for t in angles { - let m = away(master_delivered(&up, w, h, t), truth); - total += m; - worst = worst.max(now - m); - } - let mean_excess = now - total / angles.len() as f64; - assert!( - mean_excess <= mean_bound, - "{name} averages {mean_excess:.2} px worse than master, over the {mean_bound} \ - this excuses" - ); - assert!( - worst <= angle_bound, - "{name} is {worst:.2} px worse than master at its worst angle, over the \ - {angle_bound} this excuses" - ); - } - } } diff --git a/src/server/input_service.rs b/src/server/input_service.rs index fa7d145fe..5a392bb2f 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -2826,12 +2826,8 @@ mod cursor_shape_tests { mod peer_abs_sample_tests { use super::*; - // The two writers share one static and libtest runs tests concurrently. - static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); - #[test] fn a_move_without_an_absolute_sample_forgets_the_last_one() { - let _serial = SERIAL.lock().unwrap_or_else(|p| p.into_inner()); note_peer_absolute_move(20, 30); let s = last_peer_abs_sample().expect("an absolute move leaves a sample"); assert_eq!(s.pos, (20, 30)); @@ -2839,39 +2835,4 @@ mod peer_abs_sample_tests { note_pointer_moved_without_absolute_sample(); assert_eq!(last_peer_abs_sample(), None); } - - #[test] - fn every_absolute_move_is_a_new_sample() { - let _serial = SERIAL.lock().unwrap_or_else(|p| p.into_inner()); - note_peer_absolute_move(1, 1); - let a = last_peer_abs_sample().unwrap().seq; - note_peer_absolute_move(1, 1); - let b = last_peer_abs_sample().unwrap().seq; - assert!(b > a, "same point, distinct sample"); - note_pointer_moved_without_absolute_sample(); - } - - #[test] - fn a_move_of_this_process_forgets_the_sample_of_a_peer_move_it_waited_for() { - let _serial = SERIAL.lock().unwrap_or_else(|p| p.into_inner()); - let mouse = std::sync::Arc::new(std::sync::Mutex::new(())); - note_peer_absolute_move(5, 5); - // The peer holds the mouse lock when the move of this process starts. - let peer = mouse.lock().unwrap(); - let (asked, asking) = std::sync::mpsc::channel(); - let m = mouse.clone(); - let mover = std::thread::spawn(move || { - let lock = || { - asked.send(()).unwrap(); - m.lock().unwrap() - }; - forget_sample_and_move(lock, |_| {}) - }); - asking.recv().unwrap(); - assert!(last_peer_abs_sample().is_some(), "nothing forgotten before the lock"); - note_peer_absolute_move(50, 50); - drop(peer); - mover.join().unwrap(); - assert_eq!(last_peer_abs_sample(), None, "the move of this process came last"); - } } diff --git a/src/server/login_failure_check.rs b/src/server/login_failure_check.rs index 4394213ec..504aed50f 100644 --- a/src/server/login_failure_check.rs +++ b/src/server/login_failure_check.rs @@ -171,8 +171,6 @@ pub(crate) fn try_acquire_os_credential_login_gate() -> Result = Mutex::new(()); - fn clear_os_credential_failure_state(scope: FailureScope) { if let Some(state_mutex) = state_for_os_credential_scope(scope) { *state_mutex.lock().unwrap() = OsCredentialFailureState::default(); @@ -181,7 +179,6 @@ mod tests { #[test] fn os_credential_policy_prioritizes_backoff() { - let _guard = TEST_MUTEX.lock().unwrap(); clear_os_credential_failure_state(FailureScope::TerminalOsLogin); let now_ms = get_time(); for _ in 0..3 { @@ -192,40 +189,4 @@ mod tests { assert!(decision.login_error.is_some()); clear_os_credential_failure_state(FailureScope::TerminalOsLogin); } - - #[test] - fn os_credential_policy_idle_window_resets_total_counter() { - let _guard = TEST_MUTEX.lock().unwrap(); - clear_os_credential_failure_state(FailureScope::TerminalOsLogin); - for _ in 0..13 { - record_os_credential_failure(FailureScope::TerminalOsLogin); - } - let blocked = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, get_time()); - assert!(!blocked.allowed); - - let after_failures_ms = get_time(); - let after_idle_ms = after_failures_ms + OS_CREDENTIAL_LOGIN_TOTAL_IDLE_RESET_MS + 1_000; - let allowed = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, after_idle_ms); - assert!(allowed.allowed); - clear_os_credential_failure_state(FailureScope::TerminalOsLogin); - } - - #[test] - fn os_credential_policy_audits_every_backoff_block() { - let _guard = TEST_MUTEX.lock().unwrap(); - clear_os_credential_failure_state(FailureScope::TerminalOsLogin); - - for _ in 0..3 { - record_os_credential_failure(FailureScope::TerminalOsLogin); - } - let now_ms = get_time(); - let first = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms); - let second = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms + 1_000); - assert!(!first.allowed); - assert!(!second.allowed); - assert!(first.audit.is_some()); - assert!(second.audit.is_some()); - - clear_os_credential_failure_state(FailureScope::TerminalOsLogin); - } } diff --git a/src/server/port_forward_mux.rs b/src/server/port_forward_mux.rs index 69bded25f..6923fe153 100644 --- a/src/server/port_forward_mux.rs +++ b/src/server/port_forward_mux.rs @@ -169,11 +169,6 @@ impl PortForwardMux { self.channels.len() } - #[cfg(test)] - pub fn recv_window_remaining(&self, id: i32) -> Option { - self.channels.get(&id).map(|e| e.window.lock().unwrap().remaining()) - } - /// Every task ends and drops its target socket: the queue's senders go for /// a task on the queue, `teardown` reaches one parked on the socket. pub fn close_all(&mut self) { @@ -260,7 +255,7 @@ fn unreachable_message(addr: &str, is_rdp: bool) -> String { #[cfg(test)] mod tests { use super::*; - use crate::port_forward_mux::{CHANNEL_WINDOW, INITIAL_WINDOW, MAX_CHANNELS, MIN_FRAME_CHARGE}; + use crate::port_forward_mux::{CHANNEL_WINDOW, MAX_CHANNELS}; use hbb_common::{ tokio::{ self, @@ -379,18 +374,6 @@ mod tests { }); } - #[test] - fn permission_denied_refuses_without_spawning() { - rt().block_on(async { - let port = echo_target().await; - let (tx, mut rx) = mpsc::unbounded_channel(); - let mut mux = PortForwardMux::new(tx, format!("127.0.0.1:{}", port)); - mux.handle(open(1, port), || false); - assert_eq!(opened(&next_frame(&mut rx).await), (1, false)); - assert_eq!(mux.live_channels(), 0); - }); - } - #[test] fn a_revoked_permission_refuses_new_channels_and_keeps_live_ones() { rt().block_on(async { @@ -409,22 +392,6 @@ mod tests { }); } - #[test] - fn close_while_connecting_sends_no_opened() { - rt().block_on(async { - // `close` is queued before the task is first polled. Its `select!` is - // biased towards the command arm, so even a connect that completes on - // that same poll loses: no `opened` may ever be sent. - let port = echo_target().await; - let (tx, mut rx) = mpsc::unbounded_channel(); - let mut mux = PortForwardMux::new(tx, format!("127.0.0.1:{}", port)); - mux.handle(open(1, port), || true); - mux.handle(close(1), || true); - assert!(tokio::time::timeout(std::time::Duration::from_millis(200), rx.recv()).await.is_err()); - assert_eq!(mux.live_channels(), 0); - }); - } - #[test] fn over_window_data_closes_only_that_channel() { rt().block_on(async { @@ -496,38 +463,6 @@ mod tests { }); } - #[test] - fn demux_admits_only_the_initial_window_before_opened() { - rt().block_on(async { - let port = echo_target().await; - let (tx, mut rx) = mpsc::unbounded_channel(); - let mut mux = PortForwardMux::new(tx, format!("127.0.0.1:{}", port)); - mux.handle(open(1, port), || true); - // The channel task has not run yet: the demultiplexer alone - // decides what may sit in the queue before `opened`. - assert_eq!(mux.recv_window_remaining(1), Some(INITIAL_WINDOW)); - assert_eq!(opened(&next_frame(&mut rx).await), (1, true)); - assert_eq!(mux.recv_window_remaining(1), Some(CHANNEL_WINDOW)); - }); - } - - #[test] - fn pending_bytes_are_bounded_by_initial_window_before_opened() { - // A loopback connect completes before a task can observe "connecting", - // so the bound is pinned on the pure predicate the task uses. - assert!(pending_fits(0, INITIAL_WINDOW as usize)); - assert!(pending_fits( - INITIAL_WINDOW as usize - MIN_FRAME_CHARGE as usize, - 1 - )); - // A 1-byte frame costs a whole minimum charge here too. - assert!(!pending_fits( - INITIAL_WINDOW as usize - MIN_FRAME_CHARGE as usize + 1, - 1 - )); - assert!(!pending_fits(usize::MAX, 1)); - } - /// A target that accepts and hangs up at once, so every channel ends on /// the target's EOF — the case where only the next `open` frees the entry. async fn drop_target() -> u16 { diff --git a/src/server/portable_service.rs b/src/server/portable_service.rs index 0b2601e71..5940c6abb 100644 --- a/src/server/portable_service.rs +++ b/src/server/portable_service.rs @@ -1596,11 +1596,6 @@ pub struct FrameInfo { mod tests { use super::{is_valid_capture_frame_length, ADDR_CAPTURE_FRAME}; - #[test] - fn test_is_valid_capture_frame_length_rejects_zero_length() { - assert!(!is_valid_capture_frame_length(ADDR_CAPTURE_FRAME + 1024, 0)); - } - #[test] fn test_is_valid_capture_frame_length_rejects_out_of_bounds_length() { assert!(!is_valid_capture_frame_length(ADDR_CAPTURE_FRAME + 16, 17)); diff --git a/src/server/terminal_service.rs b/src/server/terminal_service.rs index 20e659a31..761b63e71 100644 --- a/src/server/terminal_service.rs +++ b/src/server/terminal_service.rs @@ -2087,51 +2087,7 @@ impl TerminalServiceProxy { #[cfg(test)] mod tests { - use super::{find_utf8_split_point, OutputBuffer, Utf8ChunkAccumulator, MAX_BUFFER_LINES}; - - #[test] - fn utf8_split_point_returns_full_len_for_complete_input() { - assert_eq!(find_utf8_split_point(b"hello"), 5); - assert_eq!(find_utf8_split_point("äø­ę–‡".as_bytes()), "äø­ę–‡".len()); - assert_eq!(find_utf8_split_point("šŸ˜€".as_bytes()), "šŸ˜€".len()); - } - - #[test] - fn utf8_split_point_detects_incomplete_trailing_sequence() { - let data = [b'a', 0xE4, 0xB8]; - assert_eq!(find_utf8_split_point(&data), 1); - } - - #[test] - fn utf8_split_point_keeps_malformed_prefix_but_buffers_trailing_lead_byte() { - let data = [0xFF, 0xE4]; - assert_eq!(find_utf8_split_point(&data), 1); - } - - #[test] - fn utf8_split_point_treats_orphan_continuations_as_complete() { - let data = [0x80, 0x81, 0x82]; - assert_eq!(find_utf8_split_point(&data), data.len()); - } - - #[test] - fn utf8_chunk_accumulator_reassembles_split_multibyte_output() { - let full = "ä½ å„½äø–ē•Œ".as_bytes(); - let mut chunker = Utf8ChunkAccumulator::default(); - let mut output = Vec::new(); - - for chunk in full.chunks(5) { - if let Some(data) = chunker.push_chunk(chunk.to_vec()) { - output.extend_from_slice(&data); - } - } - - if let Some(data) = chunker.finish() { - output.extend_from_slice(&data); - } - - assert_eq!(output, full); - } + use super::{OutputBuffer, Utf8ChunkAccumulator, MAX_BUFFER_LINES}; #[test] fn utf8_chunk_accumulator_buffers_leading_split_multibyte_output() { @@ -2154,27 +2110,6 @@ mod tests { assert!(chunker.finish().is_none()); } - #[test] - fn utf8_chunk_accumulator_does_not_stall_on_malformed_bytes() { - let mut chunker = Utf8ChunkAccumulator::default(); - assert_eq!(chunker.push_chunk(vec![0xFF]), Some(vec![0xFF])); - assert!(chunker.finish().is_none()); - } - - #[test] - fn utf8_chunk_accumulator_buffers_lone_utf8_lead_bytes() { - let mut chunker = Utf8ChunkAccumulator::default(); - assert!(chunker.push_chunk(vec![0xE4]).is_none()); - assert_eq!(chunker.finish(), Some(vec![0xE4])); - } - - #[test] - fn utf8_chunk_accumulator_does_not_hold_back_non_utf8_prefixes() { - let mut chunker = Utf8ChunkAccumulator::default(); - assert_eq!(chunker.push_chunk(vec![0xFF, 0xE4]), Some(vec![0xFF, 0xE4])); - assert!(chunker.finish().is_none()); - } - #[test] fn output_buffer_trim_after_incomplete_merge_does_not_underflow() { let mut buffer = OutputBuffer::new(); diff --git a/src/server/video_qos.rs b/src/server/video_qos.rs index cfb874f66..96ffa6315 100644 --- a/src/server/video_qos.rs +++ b/src/server/video_qos.rs @@ -897,15 +897,6 @@ mod tests { qos } - #[test] - fn isolated_delay_spike_does_not_lower_fps() { - let mut qos = stable_qos(); - for delay in [800, 10, 10, 10] { - qos.user_network_delay(1, delay); - assert_eq!(qos.fps(), FPS); - } - } - #[test] fn occasional_spikes_do_not_accumulate_congestion() { let mut qos = stable_qos(); @@ -915,15 +906,6 @@ mod tests { } } - #[test] - fn sustained_delay_reduces_fps_gradually() { - let mut qos = stable_qos(); - for expected_fps in [30, 30, 24, 24, 24, 20] { - qos.user_network_delay(1, 800); - assert_eq!(qos.fps(), expected_fps); - } - } - #[test] fn delay_history_keeps_two_samples() { let mut delay = UserDelay::default(); @@ -933,22 +915,6 @@ mod tests { assert_eq!(delay.delay_history.len(), HISTORY_DELAY_LEN); } - #[test] - fn response_timeout_halves_fps_for_each_second_outstanding() { - let mut qos = stable_qos(); - for (elapsed, expected) in [(2001, 15), (3001, 7), (4001, 5), (5001, 5), (6001, 5)] { - qos.user_delay_response_elapsed(1, elapsed); - assert_eq!(qos.fps(), expected, "{elapsed} ms outstanding"); - } - } - - #[test] - fn severe_delay_does_not_wait_for_another_reply() { - let mut qos = stable_qos(); - qos.user_network_delay(1, 1200); - assert_eq!(qos.fps(), 15); - } - #[test] fn response_timeout_recovers_in_two_good_replies() { let mut qos = stable_qos(); @@ -1003,20 +969,10 @@ mod tests { assert_eq!(qos.fps(), FPS, "ordinary recovery can still reach the cap"); } - #[test] - fn custom_fps_limit_applies_during_delay_spike() { - let mut qos = stable_qos(); - qos.user_custom_fps(1, 12); - qos.user_network_delay(1, 800); - assert_eq!(qos.fps(), 12); - } - mod adaptation; mod baseline; - mod invariants; mod jitter; mod recovery; - mod robustness; mod sim; mod smoke; mod startup; diff --git a/src/server/video_qos/tests/adaptation.rs b/src/server/video_qos/tests/adaptation.rs index 0ecbaea3d..7a49a2d93 100644 --- a/src/server/video_qos/tests/adaptation.rs +++ b/src/server/video_qos/tests/adaptation.rs @@ -1,208 +1,5 @@ -//! Regression tests for sustained capacity drops and path-delay/content changes. -//! Capacity drops use the closed-loop model; delay and activity fixtures are open-loop. use super::*; -fn percentile(values: &[f64], p: f64) -> f64 { - assert!(!values.is_empty()); - let mut sorted = values.to_vec(); - sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); - sorted[((sorted.len() - 1) as f64 * p).round() as usize] -} - -// Count a fall and subsequent rise of at least `amplitude`, ignoring smaller -// reversals. A one-way reduction or recovery is not an oscillation. -fn round_trips(values: &[f64], amplitude: f64) -> usize { - let mut peak = values[0]; - let mut trough = peak; - let mut falling = false; - let mut count = 0; - for &value in &values[1..] { - if falling { - trough = trough.min(value); - if value - trough >= amplitude { - count += 1; - peak = value; - falling = false; - } - } else { - peak = peak.max(value); - if peak - value >= amplitude { - trough = value; - falling = true; - } - } - } - count -} - -#[test] -fn oscillation_metric_distinguishes_recovery_and_small_jitter() { - assert_eq!(round_trips(&[30.0, 29.0, 30.0, 28.0, 30.0], 7.5), 0); - assert_eq!(round_trips(&[30.0, 20.0, 10.0], 7.5), 0); - assert_eq!(round_trips(&[10.0, 20.0, 30.0], 7.5), 0); - assert_eq!(round_trips(&[30.0, 10.0, 30.0, 20.0, 30.0], 7.5), 2); - assert_eq!(round_trips(&[0.49, 0.17, 0.49], 0.67 * 0.25), 1); -} - -struct Oscillation { - mean_fps: f64, - mean_ratio: f64, - fps_span: f64, - ratio_span: f64, - fps_cycles_per_min: f64, - ratio_cycles_per_min: f64, - queue_p95_ms: f64, -} - -fn permanent_drop(seeds: std::ops::RangeInclusive) { - use super::sim::{self, Summary}; - - const STEADY_START_MS: u32 = 120_000; - const END_MS: u32 = 600_000; - let cases = [ - ("bandwidth_halved_30", 3000.0), - ("bandwidth_halved_fixed_rate_30", 2000.0), - ("bandwidth_halved_fixed_rate_no_abr_30", 3000.0), - ]; - println!("Permanent drop: 8 -> 2.5 Mbps at 60 s; duration 600 s; seeds {seeds:?}."); - println!("Oscillation/queue window: 120-600 s. Spans are p95-p5; round trips require 25% of the requested FPS/ratio in each direction. Delivered FPS/frame age cover 15-600 s."); - println!("| scenario | capacity wobble | steady mean FPS (median) | mean ratio (median) | FPS span (p90) | ratio span (p90) | FPS cycles/min (p90) | ratio cycles/min (p90) | queue p95 (p90) | delivered FPS (median) | frame age p95 (p90) |"); - println!("|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|"); - let mut cases_run = 0; - for mut sc in sim::scenarios() { - let Some((_, queue_bound_ms)) = cases.iter().find(|(name, _)| *name == sc.name) else { - continue; - }; - cases_run += 1; - sc.seconds = END_MS / 1000; - sc.link.capacity_kbps = vec![(0, 8000.0), (60_000, 2500.0)]; - let original_wobble = sc.link.wobble; - for wobble in [0.0, original_wobble] { - sc.link.wobble = wobble; - let mut reports = Vec::new(); - let mut oscillations = Vec::new(); - for seed in seeds.clone() { - sc.seed = seed; - let report = sim::run(&sc); - assert!( - report - .trace - .iter() - .filter(|(t, ..)| (30_000..60_000).contains(t)) - .all(|(_, fps, _, _)| *fps == sc.limit), - "healthy pre-drop phase: {} seed {seed}", - sc.name - ); - let steady: Vec<_> = report - .trace - .iter() - .filter(|(t, ..)| *t >= STEADY_START_MS) - .collect(); - let fps: Vec<_> = steady.iter().map(|(_, fps, _, _)| *fps as f64).collect(); - let ratios: Vec<_> = steady - .iter() - .map(|(_, _, _, ratio)| *ratio as f64) - .collect(); - let queues: Vec<_> = steady - .iter() - .map(|(_, _, queue, _)| *queue as f64) - .collect(); - let minutes = (END_MS - STEADY_START_MS) as f64 / 60_000.0; - oscillations.push(Oscillation { - mean_fps: fps.iter().sum::() / fps.len() as f64, - mean_ratio: ratios.iter().sum::() / ratios.len() as f64, - fps_span: percentile(&fps, 0.95) - percentile(&fps, 0.05), - ratio_span: percentile(&ratios, 0.95) - percentile(&ratios, 0.05), - fps_cycles_per_min: round_trips(&fps, sc.limit as f64 * 0.25) as f64 / minutes, - ratio_cycles_per_min: round_trips(&ratios, sc.quality.ratio() as f64 * 0.25) - as f64 - / minutes, - queue_p95_ms: percentile(&queues, 0.95), - }); - reports.push(report); - } - let metric = |field: fn(&Oscillation) -> f64, p| { - percentile(&oscillations.iter().map(field).collect::>(), p) - }; - let summary = Summary::of(&reports); - let queue_p95 = metric(|o| o.queue_p95_ms, 0.9); - println!("| {} | {:.0}% | {:.1} | {:.3} | {:.1} | {:.3} | {:.2} | {:.2} | {:.0} ms | {:.1} | {} ms |", - sc.name, wobble * 100.0, metric(|o| o.mean_fps, 0.5), - metric(|o| o.mean_ratio, 0.5), - metric(|o| o.fps_span, 0.9), metric(|o| o.ratio_span, 0.9), - metric(|o| o.fps_cycles_per_min, 0.9), metric(|o| o.ratio_cycles_per_min, 0.9), - queue_p95, summary.delivered_median, summary.frame_age_p95_p90); - // Queue and frame-age limits reuse the transient-drop budgets; - // oscillation statistics are diagnostic. - assert!( - queue_p95 < *queue_bound_ms, - "{}: steady queue {queue_p95} ms", - sc.name - ); - assert!( - (summary.frame_age_p95_p90 as f64) < *queue_bound_ms, - "{summary:?}" - ); - assert!( - summary.delivered_median >= sc.limit as f64 * 0.4, - "{summary:?}" - ); - } - } - assert_eq!(cases_run, cases.len()); -} - -#[test] -fn permanent_capacity_drop_600s() { - permanent_drop(super::sim::SEEDS); -} - -#[test] -#[ignore = "extended permanent-drop coverage over 100 held-out seeds"] -fn permanent_capacity_drop_held_out_seeds() { - permanent_drop(21..=120); -} - -#[test] -fn low_capacity_preserves_auto_floor_and_recovers() { - let mut sc = super::sim::scenarios() - .into_iter() - .find(|s| s.name == "bandwidth_halved_fixed_rate_no_abr_30") - .unwrap(); - sc.link.capacity_kbps = vec![(0, 8000.0), (60_000, 700.0), (120_000, 8000.0)]; - for seed in super::sim::SEEDS { - sc.seed = seed; - let report = super::sim::run(&sc); - assert!( - report.trace.iter().all(|(_, fps, ..)| *fps >= 5), - "seed {seed}: automatic reductions went below 5 FPS" - ); - let congested: Vec<_> = report - .trace - .iter() - .filter(|(t, ..)| (80_000..120_000).contains(t)) - .collect(); - let min_fps = congested.iter().map(|(_, fps, ..)| *fps).min().unwrap(); - let min_queue = congested - .iter() - .map(|(_, _, queue, _)| *queue) - .min() - .unwrap(); - assert_eq!( - min_fps, 5, - "seed {seed}: severe congestion must reach the floor" - ); - // At 5 FPS this model sends about 670 kbps, leaving little room to drain - // existing backlog at 700 kbps. Require drainage after capacity returns. - assert!( - report.recovery_ms.is_some_and(|ms| ms <= 20_000), - "seed {seed}: recovery took {:?}", - report.recovery_ms - ); - println!("700 kbps fixed-rate, seed {seed}: min FPS={min_fps}, min queue={min_queue} ms, queue p95={} ms, frame age p95={} ms, recovery={:?}", report.queue_p95_ms, report.frame_age_p95_ms, report.recovery_ms); - } -} - const DISPLAY: &str = "adaptation"; fn session(abr: bool) -> VideoQoS { @@ -229,65 +26,6 @@ fn second(qos: &mut VideoQoS, delay: u32, dynamic: bool) { sync_bitrate(qos); } -fn baseline_steps() -> Vec { - let mut unmet = Vec::new(); - println!("Baseline step: 90 s at 10 ms, 180 s at new RTT, 90 s at 10 ms; one fresh reply per second. Relearning requires FPS=30 and excess<150 ms throughout the final 60 s at the new RTT."); - println!("| ABR | new RTT | cold-start final FPS | learned baseline | final excess | final FPS | final ratio | relearned | returned-path FPS |"); - println!("|---|---:|---:|---:|---:|---:|---:|---|---:|"); - for abr in [false, true] { - for rtt in [310, 410] { - let mut cold = session(abr); - for _ in 0..90 { - second(&mut cold, rtt, true); - assert!(cold.fps() >= INIT_FPS, "stable cold-start RTT {rtt}"); - } - assert_eq!(cold.fps(), FPS); - let mut qos = session(abr); - for _ in 0..90 { - second(&mut qos, 10, true); - } - assert_eq!(qos.fps(), FPS); - let mut relearned = true; - for s in 0..180 { - second(&mut qos, rtt, true); - if s >= 120 { - let base = qos.users[&1].delay.rtt_calculator.get_rtt().unwrap(); - relearned &= qos.fps() == FPS && rtt.saturating_sub(base) < 150; - } - } - let base = qos.users[&1].delay.rtt_calculator.get_rtt().unwrap(); - let high_fps = qos.fps(); - let high_ratio = qos.ratio(); - for _ in 0..90 { - second(&mut qos, 10, true); - } - assert_eq!( - qos.fps(), - FPS, - "return to the original path: ABR={abr} RTT={rtt}" - ); - assert!(qos.ratio() >= BR_BALANCED * 0.95); - println!("| {abr} | {rtt} ms | {} | {base} ms | {} ms | {high_fps} | {high_ratio:.3} | {relearned} | {} |", - cold.fps(), rtt.saturating_sub(base), qos.fps()); - if !relearned { - unmet.push(format!( - "ABR={abr}, RTT 10 -> {rtt} ms: base={base}, FPS={high_fps}" - )); - } - } - } - unmet -} - -#[test] -fn baseline_step_relearns_higher_rtt() { - let unmet = baseline_steps(); - assert!( - unmet.is_empty(), - "higher baseline was not relearned: {unmet:?}" - ); -} - #[test] fn static_to_dynamic_ratio_recovery() { println!("Static recovery: 90 s healthy video, 12 s confirmed 800 ms delay, 60 s healthy static screen, then 90 s video. Bitrate is modeled as ratio * 6000 kbps."); diff --git a/src/server/video_qos/tests/baseline.rs b/src/server/video_qos/tests/baseline.rs index 26e8837a1..61873a140 100644 --- a/src/server/video_qos/tests/baseline.rs +++ b/src/server/video_qos/tests/baseline.rs @@ -8,25 +8,6 @@ fn learned_baseline(delay: u32) -> RttCalculator { rtt } -#[test] -fn old_minimum_expires_after_a_stable_path_change() { - for (old, new) in [(10, 310), (10, 500), (159, 500)] { - let mut rtt = learned_baseline(old); - for i in 0..40 { - let before = rtt.get_rtt().unwrap(); - rtt.update(new + i % 5 * 10); - let after = rtt.get_rtt().unwrap(); - assert!(after <= before + 50, "limit the cost of relearning"); - if i < 10 { - assert_eq!(after, old, "a short burst must not replace the baseline"); - } - } - assert_eq!(rtt.get_rtt(), Some(new), "{old} -> {new}"); - rtt.update(old); - assert_eq!(rtt.get_rtt(), Some(old), "a lower delay is direct evidence"); - } -} - #[test] fn rising_delay_is_not_learned_as_a_new_baseline() { for step in [2, 10, 50] { @@ -38,32 +19,6 @@ fn rising_delay_is_not_learned_as_a_new_baseline() { } } -#[test] -fn intermittent_spikes_do_not_raise_the_baseline() { - let mut rtt = learned_baseline(159); - for delay in [159, 900, 500, 159, 350].repeat(30) { - rtt.update(delay); - assert_eq!(rtt.get_rtt(), Some(159)); - } -} - -#[test] -fn pending_probes_and_late_replies_do_not_age_the_baseline() { - let mut qos = stable_qos(); - for elapsed in (2001..122_001).step_by(1000) { - qos.user_delay_response_elapsed(1, elapsed); - assert_eq!(qos.users[&1].delay.rtt_calculator.get_rtt(), Some(10)); - } - qos.user_network_delay(1, 122_000); - assert_eq!(qos.users[&1].delay.rtt_calculator.get_rtt(), Some(10)); - for _ in 0..30 { - qos.user_delay_response_elapsed(1, 2500); - qos.user_network_delay(1, 2600); - } - assert_eq!(qos.users[&1].delay.rtt_calculator.get_rtt(), Some(10)); - assert_eq!(qos.fps(), 5); -} - #[test] fn stable_path_change_recovers_without_reconnecting() { println!("| ABR | path delay | seconds to 30 FPS | final baseline | final FPS |"); diff --git a/src/server/video_qos/tests/invariants.rs b/src/server/video_qos/tests/invariants.rs deleted file mode 100644 index d5da5acb2..000000000 --- a/src/server/video_qos/tests/invariants.rs +++ /dev/null @@ -1,498 +0,0 @@ -//! The controller's invariants as properties over random sessions. A scenario -//! test pins one trajectory; these hold whatever the trajectory: -//! -//! 1. viewer isolation: a viewer's private target is a function of its own -//! replies, timeouts and limit, never of another viewer's (with ABR on, the -//! shared bitrate state is the one designed input: the frame rate keeps a -//! floor while the bitrate can still come down); -//! 2. bad evidence never raises anything: a bad reply or a timeout tick keeps or -//! lowers that viewer's target and the bitrate ratio; -//! 3. lifecycle: a join adds a constraint and a leave removes it, and neither -//! touches any other viewer's state; -//! 4. evidence ownership: a bitrate cut is asked for by a viewer's own evidence, -//! by the step that viewer's own evidence calls for, and a newcomer's first -//! reply does not spend that evidence again; -//! 5. caps: a reply leaves the target within `[MIN_FPS, cap]`, and the stream is -//! the aggregation of the targets, the caps and the start-up guards; -//! 6. pairing: the late reply of a braked probe does not brake again. -use super::*; - -/// xorshift64*, so the tests need no external crate and stay reproducible. -struct Rng(u64); - -impl Rng { - fn new(seed: u64) -> Self { - Rng((seed ^ 0x9E37_79B9_7F4A_7C15).max(1)) - } - - fn next(&mut self) -> u64 { - let mut x = self.0; - x ^= x >> 12; - x ^= x << 25; - x ^= x >> 27; - self.0 = x; - x.wrapping_mul(0x2545_F491_4F6C_DD1D) - } - - fn below(&mut self, n: u64) -> u64 { - self.next() % n - } - - fn chance(&mut self, pct: u64) -> bool { - self.below(100) < pct - } -} - -#[derive(Debug, Clone, Copy)] -enum Step { - Reply { id: i32, delay: u32 }, - Timeout { id: i32, elapsed: u128 }, - Wait(u64), - Tick(usize), - Cap { id: i32, fps: u32 }, -} - -const SEEDS: u64 = 150; -const STEPS: usize = 300; - -/// Random events for a set of viewers on one link. A probe that is out stays -/// out until a reply: the connection reports a growing elapsed time every second -/// and the reply that ends the stall carries at least that delay. -struct Driver { - rng: Rng, - ids: Vec, - base_rtt: u32, - outstanding: HashMap, -} - -impl Driver { - fn new(seed: u64, ids: Vec) -> Self { - let mut rng = Rng::new(seed); - let base_rtt = 10 + rng.below(300) as u32; - Driver { - rng, - ids, - base_rtt, - outstanding: HashMap::new(), - } - } - - fn step(&mut self) -> Step { - let id = self.ids[self.rng.below(self.ids.len() as u64) as usize]; - let roll = self.rng.below(100); - match roll { - 0..=64 => { - let mut delay = if roll < 45 { - self.base_rtt + self.rng.below(140) as u32 - } else { - self.base_rtt + DELAY_THRESHOLD_150MS + self.rng.below(1500) as u32 - }; - if let Some(elapsed) = self.outstanding.remove(&id) { - delay = delay.max(elapsed as u32 + self.rng.below(500) as u32); - } - Step::Reply { id, delay } - } - 65..=74 => { - let elapsed = match self.outstanding.get(&id) { - Some(elapsed) => elapsed + 1000, - None => 2001 + self.rng.below(1000) as u128, - }; - self.outstanding.insert(id, elapsed); - Step::Timeout { id, elapsed } - } - 75..=89 => Step::Wait(self.rng.below(1500)), - 90..=96 => Step::Tick(self.rng.below(31) as usize), - _ => Step::Cap { - id, - fps: 1 + self.rng.below(60) as u32, - }, - } - } -} - -/// What `on_connection_open` inserts, without touching the config store. -fn open(qos: &mut VideoQoS, id: i32) { - qos.users.insert( - id, - UserData { - joined_at: Some(qos.now()), - ..Default::default() - }, - ); -} - -fn session(abr: bool) -> VideoQoS { - let mut qos = VideoQoS::default(); - qos.advance_ms(2000); - qos.abr_config = abr; - qos.first_reply_adjusts_ratio = true; - qos.new_display("test".to_owned()); - qos.set_support_changing_quality("test", true); - qos.store_bitrate(4000); - qos -} - -/// The video loop reports the encoder's bitrate as soon as it applies a ratio. -fn sync_bitrate(qos: &mut VideoQoS) { - let target = qos.latest_quality().ratio(); - let ratio = qos.ratio(); - qos.store_bitrate((4000.0 * ratio / target) as u32); -} - -fn apply(qos: &mut VideoQoS, step: Step) { - match step { - Step::Reply { id, delay } => qos.user_network_delay(id, delay), - Step::Timeout { id, elapsed } => qos.user_delay_response_elapsed(id, elapsed), - Step::Wait(ms) => qos.advance_ms(ms), - Step::Tick(encoded) => qos.update_display_data("test", encoded), - Step::Cap { id, fps } => qos.user_custom_fps(id, fps), - } - sync_bitrate(qos); -} - -/// The viewer's private target, as the controller reads it before a reply. -fn target(qos: &VideoQoS, id: i32) -> u32 { - let user = &qos.users[&id]; - user.delay.fps.unwrap_or(INIT_FPS.min(user.fps_cap())) -} - -fn baseline(qos: &VideoQoS, id: i32) -> Option { - qos.users[&id].delay.rtt_calculator.get_rtt() -} - -/// Everything the controller keeps about a viewer, for change detection. -fn snapshot(qos: &VideoQoS, id: i32) -> String { - format!("{:?}", qos.users[&id]) -} - -/// The aggregation `adjust_fps` is meant to compute: the slowest viewer's target, -/// INIT_FPS for a viewer without a reply or inside its first second, within the -/// lowest cap. -fn expected_stream(qos: &VideoQoS) -> u32 { - let mut fps = qos - .users - .values() - .map(|u| u.delay.fps.unwrap_or(INIT_FPS)) - .min() - .unwrap_or(INIT_FPS); - if qos - .users - .values() - .any(|u| u.joined_at.is_some_and(|j| qos.since(j).as_secs() < 1)) - { - fps = fps.min(INIT_FPS); - } - let cap = qos - .users - .values() - .map(|u| u.fps_cap()) - .min() - .unwrap_or(FPS); - fps.clamp(MIN_FPS, cap) -} - -// Invariant 2: bad evidence never raises a target or the ratio. -#[test] -fn bad_evidence_never_raises_a_target_or_the_ratio() { - for seed in 0..SEEDS { - for abr in [false, true] { - let mut qos = session(abr); - let ids: Vec = (1..=1 + (seed % 3) as i32).collect(); - for id in &ids { - open(&mut qos, *id); - } - let mut driver = Driver::new(seed, ids); - for step_no in 0..STEPS { - let step = driver.step(); - let ratio_before = qos.ratio(); - let before = match step { - Step::Reply { id, .. } | Step::Timeout { id, .. } => Some((id, target(&qos, id))), - _ => None, - }; - apply(&mut qos, step); - // Bad by the baseline the controller used for this reply: the reply - // itself may have relearned it. - let bad = match step { - Step::Reply { id, delay } => baseline(&qos, id) - .is_some_and(|base| delay >= base + DELAY_THRESHOLD_150MS), - Step::Timeout { .. } => true, - _ => false, - }; - if let (true, Some((id, before))) = (bad, before) { - let after = target(&qos, id); - assert!( - after <= before, - "seed {seed} abr {abr} step {step_no} {step:?}: target {before} -> {after}" - ); - assert!( - qos.ratio() <= ratio_before, - "seed {seed} abr {abr} step {step_no} {step:?}: ratio {ratio_before} -> {}", - qos.ratio() - ); - } - } - } - } -} - -// Invariant 2 and 6: a timeout tick keeps or lowers the target, whatever it is. -#[test] -fn a_timeout_keeps_or_lowers_every_target() { - for reference in MIN_FPS..=MAX_FPS { - for elapsed in [2001, 2999, 3000, 3001, 4500, 6001, 9000, 30_000] { - let mut qos = session(false); - open(&mut qos, 1); - qos.user_custom_fps(1, MAX_FPS); - qos.users.get_mut(&1).unwrap().delay.fps = Some(reference); - qos.adjust_fps(); - let stream = qos.fps(); - qos.user_delay_response_elapsed(1, elapsed); - assert!( - target(&qos, 1) <= reference, - "{elapsed} ms outstanding at {reference} fps: target {}", - target(&qos, 1) - ); - assert!( - qos.fps() <= stream, - "{elapsed} ms outstanding at {reference} fps: stream {stream} -> {}", - qos.fps() - ); - } - } -} - -// Invariant 6: the late reply of a braked probe does not brake again. -#[test] -fn a_late_reply_after_a_brake_does_not_brake_again() { - for reference in (MIN_FPS + 1..=MAX_FPS).step_by(3) { - for elapsed in [2001u32, 3001, 4500, 6001, 9000] { - for abr in [false, true] { - let mut qos = session(abr); - open(&mut qos, 1); - qos.user_custom_fps(1, MAX_FPS); - for _ in 0..3 { - qos.user_network_delay(1, 20); - } - qos.users.get_mut(&1).unwrap().delay.fps = Some(reference); - qos.user_delay_response_elapsed(1, elapsed as u128); - let braked = target(&qos, 1); - qos.user_network_delay(1, elapsed + 100); - assert_eq!( - target(&qos, 1), - braked, - "abr {abr}, {elapsed} ms outstanding at {reference} fps" - ); - } - } - } -} - -/// Viewer 1's target after each of its own events, alone or with company whose -/// events are interleaved: a second viewer with its own replies, timeouts, waits -/// and limits, and a third that joins and leaves along the way. The display -/// timer is left out: on a dynamic screen it raises the ratio off its floor, -/// and the property holds the bitrate state fixed. -fn viewer_one_targets(seed: u64, company: bool, abr: bool, at_floor: bool) -> Vec { - let mut own = Driver::new(seed, vec![1]); - let mut others = Driver::new(seed ^ 0xC0FF_EE, vec![2]); - let mut qos = session(abr); - if at_floor { - qos.ratio = qos.min_ratio(); - sync_bitrate(&mut qos); - assert!(!qos.can_reduce_bitrate()); - } - open(&mut qos, 1); - if company { - open(&mut qos, 2); - } - let no_tick = |step: Step| match step { - Step::Tick(_) => Step::Wait(1000), - step => step, - }; - let mut targets = Vec::new(); - for step_no in 0..STEPS { - if company { - for _ in 0..others.rng.below(3) { - let step = no_tick(others.step()); - apply(&mut qos, step); - } - if step_no == STEPS / 3 { - open(&mut qos, 3); - others.ids.push(3); - } - if step_no == 2 * STEPS / 3 { - qos.on_connection_close(3); - others.ids.pop(); - } - } - let step = no_tick(own.step()); - apply(&mut qos, step); - targets.push(target(&qos, 1)); - } - targets -} - -// Invariant 1: another viewer's replies, timeouts, limits, joins and leaves do -// not change a viewer's private target. With ABR off the bitrate is fixed; with -// ABR on the shared bitrate state is a designed input, so the property is checked -// at the bitrate floor, where it can no longer change. -#[test] -fn a_viewers_target_is_independent_of_other_viewers() { - for seed in 0..SEEDS { - for (abr, at_floor) in [(false, false), (true, true)] { - let alone = viewer_one_targets(seed, false, abr, at_floor); - let with_company = viewer_one_targets(seed, true, abr, at_floor); - assert_eq!( - alone, with_company, - "seed {seed} abr {abr}: viewer 1's targets differ with company" - ); - } - } -} - -// Invariant 3: a join adds a constraint, a leave removes it, and neither touches -// another viewer's state. -#[test] -fn joins_and_leaves_only_change_the_aggregation() { - for seed in 0..SEEDS { - for abr in [false, true] { - let mut qos = session(abr); - open(&mut qos, 1); - open(&mut qos, 2); - let mut driver = Driver::new(seed, vec![1, 2]); - let mut next_id = 3; - let mut present: Vec = Vec::new(); - for step_no in 0..STEPS { - let step = driver.step(); - apply(&mut qos, step); - if driver.rng.chance(10) { - let others: Vec = qos.users.keys().copied().collect(); - let before: Vec = others.iter().map(|id| snapshot(&qos, *id)).collect(); - qos.adjust_fps(); - let stream = qos.fps(); - open(&mut qos, next_id); - present.push(next_id); - driver.ids.push(next_id); - next_id += 1; - qos.adjust_fps(); - assert!( - qos.fps() <= stream, - "seed {seed} abr {abr} step {step_no}: a join raised the stream {stream} -> {}", - qos.fps() - ); - assert_eq!(qos.fps(), expected_stream(&qos)); - let after: Vec = others.iter().map(|id| snapshot(&qos, *id)).collect(); - assert_eq!( - before, after, - "seed {seed} abr {abr} step {step_no}: a join changed a viewer" - ); - } else if !present.is_empty() && driver.rng.chance(10) { - let leaving = present.remove(driver.rng.below(present.len() as u64) as usize); - driver.ids.retain(|id| *id != leaving); - driver.outstanding.remove(&leaving); - let others: Vec = qos.users.keys().copied().filter(|id| *id != leaving).collect(); - let before: Vec = others.iter().map(|id| snapshot(&qos, *id)).collect(); - qos.on_connection_close(leaving); - let after: Vec = others.iter().map(|id| snapshot(&qos, *id)).collect(); - assert_eq!( - before, after, - "seed {seed} abr {abr} step {step_no}: a leave changed a viewer" - ); - assert_eq!( - qos.fps(), - expected_stream(&qos), - "seed {seed} abr {abr} step {step_no}: the stream after a leave" - ); - } - } - } - } -} - -// Invariant 4: the ratio comes down only when a viewer's own evidence asks for -// it, by that viewer's own step, and a newcomer's first reply does not spend the -// evidence again. -#[test] -fn a_bitrate_cut_is_owned_by_a_viewers_evidence() { - let mut cuts = 0; - for seed in 0..SEEDS { - let mut qos = session(true); - let ids: Vec = (1..=1 + (seed % 3) as i32).collect(); - for id in &ids { - open(&mut qos, *id); - } - let mut driver = Driver::new(seed, ids); - for step_no in 0..STEPS { - let step = driver.step(); - let before = qos.ratio(); - apply(&mut qos, step); - let after = qos.ratio(); - if after >= before { - continue; - } - cuts += 1; - let asked: Vec = qos - .users - .values() - .filter_map(|u| u.delay.ratio_reduction()) - .collect(); - assert!( - !asked.is_empty(), - "seed {seed} step {step_no} {step:?}: a cut nobody asked for" - ); - let deepest = asked.iter().copied().fold(1.0_f32, f32::min); - assert!( - after >= before * deepest * 0.999, - "seed {seed} step {step_no} {step:?}: cut {before} -> {after}, deepest step asked {deepest}" - ); - // A newcomer replying inside the cooldown finds the evidence spent. - open(&mut qos, 99); - qos.advance_ms(driver.rng.below(2900)); - qos.user_network_delay(99, driver.base_rtt); - assert_eq!( - qos.ratio(), - after, - "seed {seed} step {step_no}: a newcomer's first reply spent the evidence again" - ); - qos.on_connection_close(99); - } - } - assert!(cuts > SEEDS as usize, "only {cuts} cuts across {SEEDS} sessions"); -} - -// Invariant 5: a reply leaves the target within its cap, and the stream is the -// aggregation of targets, caps and start-up guards after every decision. -#[test] -fn targets_stay_within_caps_and_the_stream_is_their_aggregation() { - for seed in 0..SEEDS { - for abr in [false, true] { - let mut qos = session(abr); - let ids: Vec = (1..=1 + (seed % 3) as i32).collect(); - for id in &ids { - open(&mut qos, *id); - } - let mut driver = Driver::new(seed, ids); - for step_no in 0..STEPS { - let step = driver.step(); - apply(&mut qos, step); - match step { - Step::Reply { id, .. } => { - let cap = qos.users[&id].fps_cap(); - let t = target(&qos, id); - assert!( - (MIN_FPS..=cap).contains(&t), - "seed {seed} abr {abr} step {step_no} {step:?}: target {t} outside [{MIN_FPS}, {cap}]" - ); - } - Step::Wait(_) | Step::Cap { .. } => continue, - _ => {} - } - assert_eq!( - qos.fps(), - expected_stream(&qos), - "seed {seed} abr {abr} step {step_no} {step:?}: the stream is not the aggregation" - ); - } - } - } -} diff --git a/src/server/video_qos/tests/jitter.rs b/src/server/video_qos/tests/jitter.rs index 320d75c54..bd0913f72 100644 --- a/src/server/video_qos/tests/jitter.rs +++ b/src/server/video_qos/tests/jitter.rs @@ -27,26 +27,6 @@ fn bitrate_reduction_precedes_ordinary_fps_reduction() { assert!(qos.fps() < FPS); } -#[test] -fn bitrate_cooldown_defers_ordinary_fps_reduction() { - let mut qos = abr_session(); - qos.adjust_ratio_instant = qos.now(); - let ratio = qos.ratio(); - for _ in 0..3 { - qos.user_network_delay(1, 400); - assert_eq!(qos.fps(), FPS); - assert_eq!(qos.ratio(), ratio); - } - qos.advance_ms(4000); - qos.user_network_delay(1, 400); - assert_eq!(qos.fps(), FPS); - assert!(qos.ratio() < ratio); - qos.user_network_delay(1, 400); - assert_eq!(qos.fps(), FPS); - qos.user_network_delay(1, 400); - assert!(qos.fps() < FPS); -} - #[test] fn unavailable_abr_or_minimum_bitrate_does_not_prevent_fps_reduction() { for mode in ["disabled", "unsupported", "minimum"] { @@ -74,91 +54,6 @@ fn severe_delay_and_timeout_bypass_bitrate_cooldown() { assert_eq!(qos.fps(), 7); } -#[test] -fn minimum_bitrate_during_cooldown_does_not_block_fps_reduction() { - // ABR on, ratio at its floor, a good reply cleared the post-reduction counter, - // and the adjustment cooldown has just restarted: bitrate cannot help here. - let mut qos = abr_session(); - qos.ratio = BR_MIN_HIGH_RESOLUTION; - qos.user_network_delay(1, 10); - qos.adjust_ratio_instant = qos.now(); - for _ in 0..3 { - qos.user_network_delay(1, 400); - } - assert!(qos.fps() < FPS); -} - -fn abr_session_from_scratch() -> VideoQoS { - let mut qos = VideoQoS::default(); - qos.advance_ms(2000); - qos.users.insert(1, UserData::default()); - qos.new_display("test".to_owned()); - qos.set_support_changing_quality("test", true); - qos.store_bitrate(4000); - qos -} - -/// The video loop reports the encoder's bitrate as soon as it applies a new ratio. -fn sync_bitrate(qos: &mut VideoQoS) { - let target = qos.latest_quality().ratio(); - let ratio = qos.ratio(); - qos.store_bitrate((4000.0 * ratio / target) as u32); -} - -/// One second of wall clock, one probe reply, one display update: what a -/// connection does every second. -fn second(qos: &mut VideoQoS, delay: u32, encoded: usize) { - qos.advance_ms(1000); - sync_bitrate(qos); - qos.user_network_delay(1, delay); - sync_bitrate(qos); - qos.update_display_data("test", encoded); - sync_bitrate(qos); -} - -#[test] -fn stable_high_rtt_restores_bitrate() { - for rtt in [180, 300] { - let mut qos = abr_session_from_scratch(); - let target = qos.latest_quality().ratio(); - for _ in 0..120 { - second(&mut qos, rtt, 30); - } - assert_eq!(qos.fps(), FPS, "rtt {rtt}"); - assert!( - qos.ratio() >= target * 0.99, - "rtt {rtt}: ratio {}", - qos.ratio() - ); - } -} - -#[test] -fn congestion_bitrate_reduction_resets_dynamic_screen_window() { - // A static screen encodes about one frame per second. While the congestion path - // adjusts the ratio at every cooldown, the periodic branch never runs, so the - // encode counter must not keep accumulating across the whole episode. - let mut qos = abr_session(); - for _ in 0..10 { - qos.advance_ms(4000); - qos.user_network_delay(1, 10); - qos.user_network_delay(1, 400); - qos.user_network_delay(1, 400); - qos.update_display_data("test", 1); - } - for _ in 0..12 { - qos.advance_ms(1000); - qos.user_network_delay(1, 10); - } - let ratio = qos.ratio(); - qos.advance_ms(4000); - qos.update_display_data("test", 1); - assert!( - qos.ratio() <= ratio, - "a static screen must not look dynamic after congestion" - ); -} - #[test] fn a_single_stall_does_not_cut_bitrate() { // One probe out for 2.5 s, then its late reply: jitter, not congestion. @@ -187,88 +82,6 @@ fn a_stall_beyond_three_seconds_cuts_bitrate() { assert!(qos.ratio() < ratio, "still out at the next tick"); } -#[test] -fn stable_high_rtt_does_not_dip_at_start() { - for rtt in [180, 300] { - let mut qos = super::smoke::session(30, Quality::Balanced); - for _ in 0..20 { - qos.advance_ms(1000); - qos.user_network_delay(1, rtt); - assert!(qos.fps() >= INIT_FPS, "rtt {rtt}: {}", qos.fps()); - } - assert_eq!(qos.fps(), FPS, "rtt {rtt}"); - } -} - -#[test] -fn confirmed_severe_congestion_halves_bitrate() { - let mut qos = abr_session(); - let ratio = qos.ratio(); - qos.user_network_delay(1, 800); - qos.user_network_delay(1, 800); // two bad replies: an ordinary step - let after_first = qos.ratio(); - assert!( - after_first < ratio && after_first > ratio * 0.75, - "{after_first}" - ); - qos.user_network_delay(1, 800); // three: confirmed - qos.advance_ms(4000); - qos.update_display_data("test", 30); - assert!(qos.ratio() <= after_first * 0.55, "{}", qos.ratio()); -} - -#[test] -fn fps_holds_its_floor_while_bitrate_can_still_drop() { - // With a bitrate-targeted encoder fewer frames do not mean fewer bytes, so the - // bitrate comes down first and the frame rate keeps its floor meanwhile. - let mut qos = abr_session(); - let mut reached_floor = false; - // Keep the queue growing, rather than presenting a stable new path delay. - let mut delay = 400; - for _ in 0..60 { - qos.advance_ms(3000); - second(&mut qos, delay, 30); - delay += 10; - if qos.ratio() > 0.17 { - assert!( - qos.fps() >= 10, - "fps {} at ratio {}", - qos.fps(), - qos.ratio() - ); - } else { - reached_floor = true; - break; - } - } - assert!( - reached_floor, - "bitrate must reach its floor: {}", - qos.ratio() - ); - for _ in 0..24 { - qos.user_network_delay(1, delay); - delay += 10; - } - assert!( - qos.fps() < 10, - "an exhausted bitrate frees the frame rate: {}", - qos.fps() - ); -} - -#[test] -fn bitrate_timer_does_not_punish_unconfirmed_spikes_or_stale_averages() { - let mut qos = abr_session(); - let ratio = qos.ratio(); - for delay in [800, 10, 350, 10, 350, 10].repeat(10) { - qos.user_network_delay(1, delay); - qos.adjust_ratio(false); - assert_eq!(qos.fps(), FPS); - assert_eq!(qos.ratio(), ratio); - } -} - #[test] fn viewers_confirm_congestion_independently() { let mut qos = stable_qos(); @@ -312,182 +125,3 @@ fn a_congested_viewer_does_not_lower_another_viewers_target() { } assert_eq!(qos.fps(), FPS); } - -#[test] -fn pending_probe_checks_do_not_count_as_fresh_bad_replies() { - let mut qos = stable_qos(); - qos.user_network_delay(1, 400); - for elapsed in [1000, 1500, 1900] { - qos.user_delay_response_elapsed(1, elapsed); - assert_eq!(qos.fps(), FPS); - } - qos.user_network_delay(1, 400); - assert_eq!(qos.fps(), FPS); - qos.user_network_delay(1, 10); - qos.user_network_delay(1, 400); - qos.user_network_delay(1, 400); - assert_eq!(qos.fps(), FPS); -} - -#[test] -fn recovery_continues_with_intermittent_jitter() { - let mut qos = stable_qos(); - for _ in 0..3 { - qos.user_network_delay(1, 1200); - } - assert_eq!(qos.fps(), 5); - for delay in [10, 350].repeat(30) { - qos.user_network_delay(1, delay); - } - assert_eq!(qos.fps(), FPS); -} - -#[test] -fn custom_limit_of_one_viewer_does_not_lower_another_viewers_target() { - let mut qos = stable_qos(); - qos.users.insert(2, UserData::default()); - for _ in 0..30 { - qos.user_network_delay(2, 10); - qos.user_network_delay(1, 10); - } - qos.user_custom_fps(2, 12); - qos.user_network_delay(1, 10); - assert_eq!(qos.fps(), 12, "the stream follows the lowest limit"); - assert_eq!( - qos.users[&1].delay.fps, - Some(FPS), - "viewer 1's own target is not a function of viewer 2's limit" - ); - qos.on_connection_close(2); - assert_eq!( - qos.fps(), - FPS, - "the stream is back the moment the limit is gone" - ); -} - -#[test] -fn new_viewers_first_reply_does_not_bypass_bitrate_cooldown() { - let mut qos = abr_session(); - for _ in 0..3 { - qos.user_network_delay(1, 800); - } - // Viewer 1 is confirmed and its evidence was spent on a cut a moment ago. - let ratio = qos.ratio(); - assert!(ratio < Quality::Balanced.ratio()); - qos.users.insert(2, UserData::default()); - qos.user_network_delay(2, 10); - assert_eq!( - qos.ratio(), - ratio, - "viewer 2's first reply must not spend viewer 1's evidence again inside the cooldown" - ); -} - -#[test] -fn new_viewer_does_not_inherit_another_viewers_congested_fps() { - let mut qos = stable_qos(); - for _ in 0..2 { - qos.user_network_delay(1, 1200); - } - assert_eq!(qos.fps(), 8); - qos.users.insert(2, UserData::default()); - qos.user_network_delay(2, 10); - assert!( - qos.users[&2].delay.fps >= Some(INIT_FPS), - "a new viewer starts from INIT_FPS, not from the congested stream: {:?}", - qos.users[&2].delay.fps - ); -} - -#[test] -fn unconfirmed_severe_viewer_does_not_amplify_another_viewers_confirmed_mild_congestion() { - let mut qos = abr_session(); - qos.users.insert(2, UserData::default()); - for _ in 0..30 { - qos.user_network_delay(2, 10); - qos.user_network_delay(1, 10); - } - // The first reply of a new viewer adjusts the ratio and restarts the cooldown. - qos.advance_ms(4000); - let ratio = qos.ratio(); - // Viewer 2: mild congestion, confirmed over three replies. Viewer 1: one - // severe spike, never confirmed. Each viewer on its own calls for at most a - // five percent step; together they must not turn into a halving. - qos.user_network_delay(2, 200); - qos.user_network_delay(1, 1200); - qos.user_network_delay(2, 200); - let after_two = qos.ratio(); - assert!(after_two < ratio, "viewer 2's second bad reply cuts"); - assert!( - after_two >= ratio * 0.94, - "viewer 2's own mild excess is a five percent step, not {after_two}" - ); - qos.user_network_delay(2, 200); - qos.advance_ms(4000); - qos.update_display_data("test", 30); - assert!( - qos.ratio() >= after_two * 0.94, - "viewer 1's severity must not be paired with viewer 2's confirmation: {}", - qos.ratio() - ); -} - -/// What `on_connection_open` inserts, without touching the config store. -fn newcomer(qos: &VideoQoS) -> UserData { - UserData { - joined_at: Some(qos.now()), - ..Default::default() - } -} - -#[test] -fn closing_a_just_opened_viewer_does_not_throttle_existing_viewers() { - let mut qos = stable_qos(); - assert_eq!(qos.fps(), FPS); - qos.users.insert(2, newcomer(&qos)); - assert_eq!( - qos.fps(), - FPS, - "nothing changes until the stream is re-aggregated" - ); - qos.on_connection_close(2); - assert_eq!( - qos.fps(), - FPS, - "the guard leaves with the viewer that brought it" - ); -} - -#[test] -fn a_new_viewer_caps_the_stream_at_init_fps_for_a_second() { - let mut qos = stable_qos(); - qos.users.insert(2, newcomer(&qos)); - qos.user_network_delay(1, 10); - assert_eq!(qos.fps(), INIT_FPS); - qos.advance_ms(1000); - qos.user_network_delay(2, 10); - qos.user_network_delay(1, 10); - assert!( - qos.fps() > INIT_FPS, - "after a second the stream follows the viewers' own targets: {}", - qos.fps() - ); -} - -#[test] -fn closing_the_latest_newcomer_keeps_an_earlier_newcomers_guard() { - let mut qos = stable_qos(); - qos.users.insert(2, newcomer(&qos)); - qos.user_network_delay(2, 10); - assert_eq!(qos.fps(), INIT_FPS); - qos.advance_ms(100); - qos.users.insert(3, newcomer(&qos)); - qos.advance_ms(100); - qos.on_connection_close(3); - assert_eq!( - qos.fps(), - INIT_FPS, - "viewer 2 is still inside its own start-up window" - ); -} diff --git a/src/server/video_qos/tests/recovery.rs b/src/server/video_qos/tests/recovery.rs index 79dccf9e9..7dc611f0d 100644 --- a/src/server/video_qos/tests/recovery.rs +++ b/src/server/video_qos/tests/recovery.rs @@ -72,22 +72,6 @@ fn automatic_floor_preserves_lower_custom_limits() { } } -#[test] -fn two_good_replies_restore_after_a_severe_stall() { - let mut qos = stable_qos(); - qos.user_delay_response_elapsed(1, 5001); - assert_eq!(qos.fps(), 5); - qos.user_network_delay(1, 5100); - assert_eq!(qos.fps(), 5, "the late reply must not brake twice"); - qos.user_network_delay(1, 10); - assert!( - (5..FPS).contains(&qos.fps()), - "one good reply is not enough" - ); - qos.user_network_delay(1, 10); - assert_eq!(qos.fps(), FPS); -} - #[test] fn jitter_during_recovery_does_not_discard_the_restore_target() { let mut qos = stable_qos(); @@ -99,22 +83,3 @@ fn jitter_during_recovery_does_not_discard_the_restore_target() { } assert_eq!(qos.fps(), FPS); } - -#[test] -fn a_failed_fast_restore_rolls_back_before_the_queue_grows() { - let mut qos = stable_qos(); - qos.user_delay_response_elapsed(1, 5001); - for _ in 0..2 { - qos.user_network_delay(1, 10); - } - assert_eq!(qos.fps(), FPS); - qos.user_network_delay(1, 800); - assert_eq!(qos.fps(), FPS / 2); - for _ in 0..2 { - qos.user_network_delay(1, 10); - } - assert!( - qos.fps() < FPS, - "a failed restore must lower the next probe" - ); -} diff --git a/src/server/video_qos/tests/robustness.rs b/src/server/video_qos/tests/robustness.rs deleted file mode 100644 index c3237977e..000000000 --- a/src/server/video_qos/tests/robustness.rs +++ /dev/null @@ -1,180 +0,0 @@ -//! Guards against tuning the controller to the simulator: the CI bounds applied -//! to seeds that never took part in setting them, and a sweep of the scenario -//! parameters. Both are `#[ignore]`d: they take a few seconds and are meant for -//! anyone changing a controller constant or a bound. -//! -//! ```text -//! cargo test --lib video_qos::tests::robustness -- --ignored --nocapture -//! ``` -use super::sim::{bound_violations, run, scenarios, Report, Scenario, Summary}; -use super::*; - -fn summarise(sc: &Scenario, seeds: impl Iterator) -> Summary { - let reports: Vec = seeds - .map(|seed| run(&Scenario { seed, ..sc.clone() })) - .collect(); - Summary::of(&reports) -} - -/// Seeds 21 to 120 in five blocks of twenty, each block held to the CI bounds. -#[test] -#[ignore] -fn held_out_seeds() { - println!("| scenario | blocks violating | which | median target | worst p10 | below limit/2 (p90) | queue p95 (p90) |"); - println!("|---|---:|---|---:|---:|---:|---:|"); - let mut failures = Vec::new(); - for sc in scenarios() { - let mut failing_blocks = 0; - let mut which: Vec<&str> = Vec::new(); - let mut reports: Vec = Vec::new(); - for block in 0..5u64 { - let first = 21 + block * 20; - let block_reports: Vec = (first..first + 20) - .map(|seed| run(&Scenario { seed, ..sc.clone() })) - .collect(); - let s = Summary::of(&block_reports); - reports.extend(block_reports); - let violations = bound_violations(&s); - if !violations.is_empty() { - failing_blocks += 1; - for v in violations { - if !which.contains(&v) { - which.push(v); - } - } - } - } - let all = Summary::of(&reports); - println!( - "| {} | {}/5 | {} | {:.1} | {} | {:.1}% | {} ms |", - sc.name, - failing_blocks, - which.join("; "), - all.mean_target_median, - all.p10_target_worst, - all.below_half_p90, - all.queue_p95_p90 - ); - if failing_blocks > 0 { - failures.push(format!( - "{} ({} of 5 blocks: {})", - sc.name, - failing_blocks, - which.join("; ") - )); - } - } - assert!( - failures.is_empty(), - "bounds fail on held-out seeds, so they were fitted to the CI seeds: {failures:?}" - ); -} - -/// One scenario parameter at a time, halved and doubled, twenty seeds each. No -/// bounds: the point is to see that nothing falls off a cliff, and to compare with -/// master by running the same test there. -#[test] -#[ignore] -fn sensitivity() { - let base = scenarios(); - let pick = |name: &str| base.iter().find(|s| s.name == name).unwrap().clone(); - let home = pick("home_wifi_30"); - let halved = pick("bandwidth_halved_30"); - let with_link = |sc: &Scenario, edit: &dyn Fn(&mut super::sim::Link)| { - let mut link = sc.link.clone(); - edit(&mut link); - Scenario { link, ..sc.clone() } - }; - let variants: Vec<(&str, Scenario)> = vec![ - ("home base", home.clone()), - ( - "home stalls half as long", - with_link(&home, &|l| l.stall_ms = (150.0, 1250.0)), - ), - ( - "home stalls twice as long", - with_link(&home, &|l| l.stall_ms = (600.0, 5000.0)), - ), - ( - "home stalls twice as often", - with_link(&home, &|l| l.stall_mean_interval_s = 10.0), - ), - ( - "home stalls half as often", - with_link(&home, &|l| l.stall_mean_interval_s = 40.0), - ), - ( - "home loss doubled", - with_link(&home, &|l| l.loss_per_s = 0.4), - ), - ( - "home capacity halved", - with_link(&home, &|l| l.capacity_kbps = vec![(0, 10_000.0)]), - ), - ( - "home capacity 6 Mbps", - with_link(&home, &|l| l.capacity_kbps = vec![(0, 6_000.0)]), - ), - ( - "home jitter heavier", - with_link(&home, &|l| { - l.jitter_sigma = 1.5; - l.jitter_median_ms = 30.0; - }), - ), - ( - "home base rtt 80 ms", - with_link(&home, &|l| l.base_rtt_ms = 80.0), - ), - ( - "home 60 fps limit", - Scenario { - limit: 60, - ..home.clone() - }, - ), - ("bandwidth halved base", halved.clone()), - ( - "bandwidth to 4 Mbps", - with_link(&halved, &|l| { - l.capacity_kbps = vec![(0, 8_000.0), (60_000, 4_000.0), (120_000, 8_000.0)] - }), - ), - ( - "bandwidth to 1.5 Mbps", - with_link(&halved, &|l| { - l.capacity_kbps = vec![(0, 8_000.0), (60_000, 1_500.0), (120_000, 8_000.0)] - }), - ), - ( - "bandwidth halved, never restored, 300 s", - Scenario { - seconds: 300, - ..with_link(&halved, &|l| { - l.capacity_kbps = vec![(0, 8_000.0), (60_000, 2_500.0)] - }) - }, - ), - ]; - println!("| variant | median target | worst p10 | below limit/2 (p90) | queue p95 (p90) | delivered | sustained recovery (worst) |"); - println!("|---|---:|---:|---:|---:|---:|---:|"); - for (label, sc) in &variants { - let s = summarise(sc, super::sim::SEEDS); - println!( - "| {} | {:.1} | {} | {:.1}% | {} ms | {:.1} | {} |", - label, - s.mean_target_median, - s.p10_target_worst, - s.below_half_p90, - s.queue_p95_p90, - s.delivered_median, - if s.has_restore { - s.recovery_worst_ms - .map(|ms| format!("{:.1}s", ms as f64 / 1000.0)) - .unwrap_or_else(|| "never".to_owned()) - } else { - "-".to_owned() - } - ); - } -} diff --git a/src/server/video_qos/tests/sim.rs b/src/server/video_qos/tests/sim.rs index 1e8f9f81f..790f23b1c 100644 --- a/src/server/video_qos/tests/sim.rs +++ b/src/server/video_qos/tests/sim.rs @@ -24,8 +24,7 @@ //! reacts to the *kind* of behaviour a home Wi-Fi, a stable relay or a saturated //! uplink produce, deterministically and over many seeds. //! -//! Against overfitting: the CI run uses seeds 1 to 20; `robustness.rs` applies the -//! same bounds to seeds 21 to 120 and sweeps the scenario parameters. Scenario +//! The CI run uses seeds 1 to 20. Scenario //! parameters are educated guesses until a recorded `qos_trace` calibrates them. use super::*; @@ -296,8 +295,6 @@ pub struct Report { pub cold_start_min_fps: u32, /// First time the target reached 90% of the limit. pub time_to_90pct_ms: Option, - pub final_fps: u32, - pub final_ratio: f32, pub trace: Vec<(u32, u32, u32, f32)>, // (time_ms, target fps, queue_ms, ratio) } @@ -496,8 +493,6 @@ pub fn run(sc: &Scenario) -> Report { recovery_ms, cold_start_min_fps, time_to_90pct_ms, - final_fps: qos.fps(), - final_ratio: qos.ratio(), trace, } } @@ -758,8 +753,8 @@ fn sim_scenarios() { } } -/// The bounds every scenario summary has to meet, shared by the CI run over `SEEDS` -/// and by the held-out run in `robustness.rs`. They state what the product needs, +/// The bounds every scenario summary has to meet in the CI run over `SEEDS`. +/// They state what the product needs, /// not what one seed produced. If a new seed or a new scenario violates a bound, /// change the design or loosen the bound with a written reason; never tune a /// controller constant until the bound passes. @@ -913,76 +908,3 @@ fn replay_recorded_trace() { trace.len() ); } - -#[test] -fn replay_time_axis_is_shared_across_connections() { - // Two viewers each log once a second for twenty seconds: twenty seconds of - // wall clock, not forty. - let text: String = (0..20) - .flat_map(|i| { - [ - format!("qos_trace t={} id=1 delay=10 fps=30\n", 100_000 + i * 1000), - format!("qos_trace t={} id=2 delay=10 fps=30\n", 100_001 + i * 1000), - ] - }) - .collect(); - let trace = replay(&text); - let elapsed = trace.last().unwrap().0 - trace.first().unwrap().0; - assert!( - (19_000..=19_100).contains(&elapsed), - "replayed {elapsed} ms for 19 s of wall clock" - ); -} - -#[test] -fn replay_recorded_trace_is_independent_of_connection_id() { - let replay = |id: i32| { - let text: String = (0..20) - .map(|i| { - format!( - "qos_trace t={} id={id} delay=10 fps=30\n", - 100_000 + i * 1000 - ) - }) - .collect(); - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = std::env::temp_dir().join(format!( - "rustdesk-qos-replay-{}-{nonce}-{id}.log", - std::process::id() - )); - std::fs::write(&path, text).unwrap(); - // Exercise the real replay entry point without changing other tests' environment. - let test = format!( - "{}::replay_recorded_trace", - module_path!().split_once("::").unwrap().1 - ); - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", &test, "--nocapture", "--test-threads=1"]) - .env("RUSTDESK_QOS_TRACE", &path) - .output(); - std::fs::remove_file(&path).unwrap(); - let output = output.unwrap(); - assert!(output.status.success(), "replay failed: {output:?}"); - let stdout = String::from_utf8(output.stdout).unwrap(); - let id = id.to_string(); - let fps: Vec = stdout - .lines() - .filter_map(|line| { - let fields: Vec<_> = line.split(',').collect(); - if fields.len() == 4 && fields[1] == id { - Some(fields[3].parse().unwrap()) - } else { - None - } - }) - .collect(); - assert_eq!(fps.len(), 20, "missing replay samples: {stdout}"); - fps - }; - let expected = replay(1); - assert_eq!(expected.last(), Some(&30)); - assert_eq!(replay(1652), expected); -} diff --git a/src/server/video_qos/tests/smoke.rs b/src/server/video_qos/tests/smoke.rs index 86a93a7de..c9f2834d7 100644 --- a/src/server/video_qos/tests/smoke.rs +++ b/src/server/video_qos/tests/smoke.rs @@ -18,227 +18,6 @@ pub(super) fn session(fps: u32, quality: Quality) -> VideoQoS { qos } -fn profiles() -> Vec<(&'static str, Vec, bool)> { - vec![ - ("stable_10", vec![10; 120], false), - ("stable_80", vec![80; 120], false), - ("stable_180", vec![180; 120], false), - ("stable_300", vec![300; 120], false), - ( - "lan_jitter", - (0..120).map(|i| 10 + (i * 37 % 70)).collect(), - true, - ), - ( - "isolated_spikes", - (0..120) - .map(|i| if i % 15 == 0 { 800 } else { 10 }) - .collect(), - true, - ), - ("alternating_10_350", [10, 350].repeat(60), true), - ( - "two_sample_bursts", - (0..120) - .map(|i| if i % 12 < 2 { 700 } else { 10 }) - .collect(), - true, - ), - ( - "threshold_jitter", - [140, 180, 150, 190, 130, 170].repeat(20), - true, - ), - ( - "congestion_200_recovery", - [vec![200; 20], vec![10; 60]].concat(), - true, - ), - ( - "congestion_800_recovery", - [vec![800; 20], vec![10; 60]].concat(), - true, - ), - ( - "congestion_1500_recovery", - [vec![1500; 20], vec![10; 60]].concat(), - true, - ), - ( - "rising_then_falling", - (0..80).map(|i| 10 + i.min(79 - i) * 20).collect(), - true, - ), - ] -} - -#[test] -fn smoke_latency_profiles() { - use std::fmt::Write; - - let mut csv = String::from("profile,limit,quality,sample,delay_ms,fps\n"); - for (quality_name, quality) in [ - ("balanced", Quality::Balanced), - ("best", Quality::Best), - ("low", Quality::Low), - ] { - for limit in [1, 5, 15, 30, 60, 120] { - for (name, delays, warm_up) in profiles() { - let mut qos = session(limit, quality); - if warm_up { - for _ in 0..90 { - qos.user_network_delay(1, 10); - } - assert_eq!(qos.fps(), limit); - } - let mut trace = Vec::new(); - for (i, delay) in delays.into_iter().enumerate() { - qos.user_network_delay(1, delay); - let fps = qos.fps(); - assert!((MIN_FPS..=limit).contains(&fps), "{name}: {fps}"); - trace.push(fps); - writeln!(csv, "{name},{limit},{quality_name},{i},{delay},{fps}").unwrap(); - } - if limit == 30 && quality_name == "balanced" { - println!( - "{name}: first20={:?}, last={}", - &trace[..20], - trace.last().unwrap() - ); - } - if name.starts_with("stable_") { - assert_eq!(trace.last(), Some(&limit), "{name}, {quality_name}"); - } - if matches!( - name, - "lan_jitter" - | "isolated_spikes" - | "alternating_10_350" - | "two_sample_bursts" - | "threshold_jitter" - ) { - assert!( - trace.iter().all(|fps| *fps == limit), - "{name}, {quality_name}" - ); - } - if matches!(name, "congestion_800_recovery" | "congestion_1500_recovery") { - assert!( - trace.iter().all(|fps| *fps >= limit.min(5)), - "automatic reductions must preserve the floor: {trace:?}" - ); - if limit >= 15 { - assert!( - trace[20] < limit, - "a single good reply must not restore the full frame rate" - ); - } - assert_eq!( - trace[21], limit, - "two fresh good replies must restore the frame rate" - ); - if name == "congestion_1500_recovery" { - assert_eq!( - trace[5], - limit.min(5), - "severe congestion must brake promptly" - ); - } - } - if name == "congestion_200_recovery" && limit >= 15 { - assert!( - trace[..20].iter().min() < Some(&limit), - "moderate sustained congestion must reduce the frame rate: {trace:?}" - ); - } - if name.ends_with("_recovery") { - assert_eq!(trace.last(), Some(&limit), "{name}, {quality_name}"); - } - } - } - } - if let Ok(path) = std::env::var("RUSTDESK_QOS_SMOKE_CSV") { - std::fs::write(path, csv).unwrap(); - } -} - -#[test] -fn smoke_bandwidth_drop_and_recovery() { - use std::fmt::Write; - - let mut qos = session(30, Quality::Balanced); - for _ in 0..90 { - qos.user_network_delay(1, 10); - } - // Fixed-size frames, FIFO link and one outstanding TestDelay, with a 10 ms base RTT. - // Encoding and ABR are intentionally absent so that FPS alone controls offered load. - let mut queue = 0.0_f64; - let mut probe: Option<(u32, f64, Option)> = None; - let mut last_delay = 10; - let mut csv = String::from("time_ms,capacity_fps,queue_ms,delay_ms,fps\n"); - let mut max_queue_ms = 0; - let mut drained = false; - let mut recovered_at = None; - for now in (0..120_000).step_by(10) { - qos.advance_ms(10); - let capacity = if (10_000..70_000).contains(&now) { - 15.0 - } else { - 40.0 - }; - queue = (queue + (qos.fps() as f64 - capacity) * 0.01).max(0.0); - if let Some((_, remaining, reply_at)) = probe.as_mut() { - *remaining -= capacity * 0.01; - if *remaining <= 0.0 && reply_at.is_none() { - *reply_at = Some(now + 10); - } - } - if let Some((sent, _, Some(reply_at))) = probe { - if now >= reply_at { - last_delay = now - sent; - qos.user_network_delay(1, last_delay); - probe = None; - } - } - if now % 1000 == 0 { - if probe.is_none() { - probe = Some((now, queue, None)); - } - qos.user_delay_response_elapsed(1, (now - probe.unwrap().0) as u128); - let queue_ms = (queue / capacity * 1000.0) as u32; - max_queue_ms = max_queue_ms.max(queue_ms); - if (20_000..40_000).contains(&now) && queue_ms < 100 { - drained = true; - } - if now >= 70_000 && qos.fps() == 30 && recovered_at.is_none() { - recovered_at = Some(now - 70_000); - } - writeln!( - csv, - "{now},{capacity},{queue_ms},{last_delay},{}", - qos.fps() - ) - .unwrap(); - } - } - println!( - "bandwidth 40 -> 15 -> 40 fps: max_queue_ms={max_queue_ms}, recovery_ms={recovered_at:?}, final_fps={}", - qos.fps() - ); - if let Ok(path) = std::env::var("RUSTDESK_QOS_SMOKE_CSV") { - std::fs::write(std::path::Path::new(&path).with_extension("queue.csv"), csv).unwrap(); - } - assert!(drained, "congestion must drain after the capacity drop"); - assert!( - max_queue_ms < 2500, - "queue must not grow while awaiting confirmation" - ); - assert!( - recovered_at.is_some_and(|ms| ms <= 15_000) && qos.fps() == 30, - "FPS must recover when capacity returns" - ); -} - #[test] fn smoke_capacity_with_short_stalls() { for limit in [30, 60] { @@ -286,84 +65,3 @@ fn smoke_capacity_with_short_stalls() { } } } - -#[test] -fn smoke_abr_bandwidth_drop_and_recovery() { - for reduced_capacity in [27, 24, 15] { - let mut qos = session(30, Quality::Balanced); - for _ in 0..90 { - qos.user_network_delay(1, 10); - } - qos.abr_config = true; - qos.new_display("test".to_owned()); - qos.set_support_changing_quality("test", true); - qos.store_bitrate(4000); - let initial_ratio = qos.ratio(); - let mut queue = 0.0_f64; - let mut probe: Option<(u32, f64, Option)> = None; - let mut first_ratio_drop = None; - let mut first_fps_drop = None; - let mut first_fps_drop_delay = None; - let mut last_delay = 10; - let mut max_queue_ms = 0; - let mut drained = false; - let mut recovered_at = None; - for now in (0..120_000).step_by(10) { - qos.advance_ms(10); - let capacity = if (10_000..70_000).contains(&now) { - reduced_capacity as f64 - } else { - 40.0 - }; - // Frame size scales with the requested ratio; video and probes share one FIFO. - let frame_size = (qos.ratio() / initial_ratio) as f64; - queue = (queue + (qos.fps() as f64 * frame_size - capacity) * 0.01).max(0.0); - qos.store_bitrate((4000.0 * frame_size) as u32); - if let Some((_, remaining, reply_at)) = probe.as_mut() { - *remaining -= capacity * 0.01; - if *remaining <= 0.0 && reply_at.is_none() { - *reply_at = Some(now + 10); - } - } - if let Some((sent, _, Some(reply_at))) = probe { - if now >= reply_at { - last_delay = now - sent; - qos.user_network_delay(1, last_delay); - probe = None; - } - } - if now % 1000 == 0 { - if probe.is_none() { - probe = Some((now, queue, None)); - } - qos.user_delay_response_elapsed(1, (now - probe.unwrap().0) as u128); - qos.update_display_data("test", qos.fps() as usize); - let queue_ms = (queue / capacity * 1000.0) as u32; - max_queue_ms = max_queue_ms.max(queue_ms); - if (20_000..40_000).contains(&now) && queue_ms < 100 { - drained = true; - } - } - if qos.ratio() < initial_ratio && first_ratio_drop.is_none() { - first_ratio_drop = Some(now); - } - if qos.fps() < 30 && first_fps_drop.is_none() { - first_fps_drop = Some(now); - first_fps_drop_delay = Some(last_delay); - } - if now >= 70_000 && qos.fps() == 30 && recovered_at.is_none() { - recovered_at = Some(now - 70_000); - } - } - println!("ABR bandwidth 40 -> {reduced_capacity} -> 40: max_queue_ms={max_queue_ms}, first_ratio_drop_ms={first_ratio_drop:?}, first_fps_drop_ms={first_fps_drop:?}, first_fps_drop_delay_ms={first_fps_drop_delay:?}, recovery_ms={recovered_at:?}, final_fps={}, final_ratio={:.3}", qos.fps(), qos.ratio()); - assert!(drained && max_queue_ms < 2500); - assert!(recovered_at.is_some_and(|ms| ms <= 15_000)); - assert_eq!(qos.fps(), 30); - assert!(qos.ratio() >= initial_ratio * 0.8); - if reduced_capacity >= 24 { - assert!(first_ratio_drop.is_some_and(|ratio_time| { - first_fps_drop.map_or(true, |fps_time| ratio_time < fps_time) - })); - } - } -} diff --git a/src/server/video_qos/tests/startup.rs b/src/server/video_qos/tests/startup.rs index 6efea7d4d..03433b8c1 100644 --- a/src/server/video_qos/tests/startup.rs +++ b/src/server/video_qos/tests/startup.rs @@ -63,138 +63,6 @@ fn an_unclean_startup_reply_disables_faster_growth() { } } -#[test] -fn a_failed_startup_probe_rolls_back_and_does_not_restart() { - let mut qos = session(120, false); - for _ in 0..2 { - reply(&mut qos, 10); - } - let probe = qos.fps(); - assert!(probe >= 30, "fixture must reach the accelerated level"); - reply(&mut qos, 1200); - assert!(qos.fps() <= probe / 2, "rollback must remain prompt"); - for _ in 0..2 { - reply(&mut qos, 10); - } - assert_eq!(qos.fps(), probe, "keep the existing two-reply recovery"); - for _ in 0..8 { - let before = qos.fps(); - reply(&mut qos, 10); - assert!(qos.fps() <= before + (before / 5).max(6)); - } -} - -#[test] -fn a_startup_spike_still_requires_congestion_confirmation() { - let mut qos = session(120, false); - for _ in 0..2 { - reply(&mut qos, 10); - } - let probe = qos.fps(); - for _ in 0..2 { - reply(&mut qos, 800); - assert_eq!(qos.fps(), probe, "startup is not a failed fast restore"); - } - reply(&mut qos, 800); - assert!(qos.fps() < probe, "three fresh bad replies must reduce FPS"); - assert!(qos.fps() >= probe - probe / 5); -} - -#[test] -fn a_startup_timeout_keeps_legacy_recovery() { - let mut qos = session(120, false); - qos.advance_ms(3000); - qos.user_delay_response_elapsed(1, 3001); - assert_eq!(qos.fps(), 5); - reply(&mut qos, 3100); - assert_eq!(qos.fps(), 5, "late reply must not undo the brake"); - for _ in 0..2 { - reply(&mut qos, 10); - } - assert_eq!(qos.fps(), INIT_FPS); - for _ in 0..8 { - let before = qos.fps(); - reply(&mut qos, 10); - assert!(qos.fps() <= before + (before / 5).max(6)); - } -} - -#[test] -fn raising_the_cap_does_not_restart_startup_acceleration() { - let mut qos = session(30, false); - for _ in 0..6 { - reply(&mut qos, 10); - } - assert_eq!(qos.fps(), 30); - qos.user_custom_fps(1, 120); - for _ in 0..6 { - let before = qos.fps(); - reply(&mut qos, 10); - assert!(qos.fps() <= before + (before / 5).max(6)); - } -} - -#[test] -fn startup_remains_per_viewer_and_preserves_the_join_guard() { - let mut qos = session(120, false); - for _ in 0..20 { - reply(&mut qos, 10); - } - qos.users.insert( - 2, - UserData { - joined_at: Some(qos.now()), - custom_fps: Some(30), - ..Default::default() - }, - ); - qos.user_network_delay(2, 10); - assert_eq!(qos.fps(), INIT_FPS); - assert_eq!(qos.users[&1].delay.fps, Some(120)); - qos.advance_ms(1000); - qos.user_network_delay(2, 10); - assert_eq!(qos.users[&2].delay.fps, Some(30)); - assert_eq!(qos.fps(), 30); - qos.on_connection_close(2); - assert_eq!(qos.fps(), 120); -} - -#[test] -fn closed_loop_startup_preserves_quality_and_bounds_queueing() { - use super::sim::{self, EncoderModel}; - for cap in [30, 60, 120] { - for base in [10, 150, 300, 600] { - for encoder in [EncoderModel::Cbr, EncoderModel::FixedRate] { - for seed in 1..=5 { - let mut sc = sim::scenarios() - .into_iter() - .find(|sc| sc.name == "city_relay_30") - .unwrap(); - sc.seconds = 30; - sc.limit = cap; - sc.link.base_rtt_ms = base as f64; - sc.encoder = encoder; - sc.seed = seed; - let report = sim::run(&sc); - let budget = match cap { - 30 => 2000, - 60 => 4000, - _ => 6000, - } + base; - assert!( - report.time_to_90pct_ms.is_some_and(|t| t <= budget), - "cap={cap} base={base}: {report:?}" - ); - assert_eq!(report.final_fps, cap); - assert_eq!(report.final_ratio, sc.quality.ratio()); - assert!(report.trace.iter().all(|(_, _, queue, _)| *queue < 150)); - assert!(report.frame_age_p95_ms < 150); - } - } - } - } -} - #[test] fn constrained_startup_does_not_leave_a_large_queue() { use super::sim::{self, EncoderModel}; diff --git a/src/server/wayland.rs b/src/server/wayland.rs index c03799162..af9e7f5e5 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -215,43 +215,6 @@ mod tests { assert_eq!(m("streams:empty:"), WAYLAND_NO_USABLE_SCREEN); } - #[test] - fn only_a_portal_that_may_be_dead_is_told_to_restart() { - let m = |tag| staged_message(tag, false); - // Not reached the bus at all: the portal has not been asked anything yet. - assert_eq!( - m("session-bus:dbus:org.freedesktop.DBus.Error.NotSupported"), - WAYLAND_NO_SESSION - ); - // Answering, but without an implementation behind the interface that was called -- - // at any stage, not just the first one. - assert_eq!( - m("create-session:unsupported:org.freedesktop.DBus.Error.UnknownMethod"), - WAYLAND_UNSUPPORTED - ); - assert_eq!( - m("select-sources:unsupported:org.freedesktop.DBus.Error.UnknownMethod"), - WAYLAND_UNSUPPORTED - ); - // Absent or silent, which is what the existing key's remedy is for. - assert_eq!( - m("create-session:dbus:org.freedesktop.DBus.Error.ServiceUnknown"), - SCRAP_XDP_PORTAL_UNAVAILABLE - ); - // Not this one: `Start` had already been answered, so the portal was alive and the - // request granted. Saying it may have crashed would walk the diagnosis backwards. - assert_eq!( - m("open-pipewire-remote:dbus:org.freedesktop.DBus.Error.Failed"), - WAYLAND_PIPEWIRE_HANDOVER - ); - // A tag this build does not know must never fall back to a guess. - assert_eq!( - m("some-new-stage:some-new-kind:x"), - SCRAP_XDP_PORTAL_UNAVAILABLE - ); - assert_eq!(m(""), SCRAP_XDP_PORTAL_UNAVAILABLE); - } - // The peer resolves a message by replacing its first `{...}` with `{}` and looking that // up, so a detail-carrying message has to reduce back to its key exactly. #[test] @@ -268,28 +231,6 @@ mod tests { WAYLAND_GST_UNAVAILABLE ); } - - #[test] - fn legacy_ubuntu_keeps_its_message_for_machine_faults_only() { - let m = |tag| staged_message(tag, true); - assert_eq!( - m("create-session:dbus:org.freedesktop.DBus.Error.ServiceUnknown"), - SCRAP_UBUNTU_HIGHER_REQUIRED - ); - assert_eq!( - m("create-session:unsupported:org.freedesktop.DBus.Error.UnknownMethod"), - SCRAP_UBUNTU_HIGHER_REQUIRED - ); - assert_eq!( - m("gst-plugin:unavailable:pipewiresrc"), - SCRAP_UBUNTU_HIGHER_REQUIRED - ); - assert_eq!(m("streams:empty:"), SCRAP_UBUNTU_HIGHER_REQUIRED); - assert_eq!(m("session-bus:dbus:"), SCRAP_UBUNTU_HIGHER_REQUIRED); - assert_eq!(m("start:declined:"), WAYLAND_DECLINED); - assert_eq!(m("start:ended:"), WAYLAND_ENDED); - assert_eq!(m("start:no-response:"), WAYLAND_TIMED_OUT); - } } struct CapturerPtr(*mut Capturer); diff --git a/src/ui_cm_interface.rs b/src/ui_cm_interface.rs index ee64eaaf5..3242e1183 100644 --- a/src/ui_cm_interface.rs +++ b/src/ui_cm_interface.rs @@ -1848,46 +1848,4 @@ mod tests { } }); } - - /// Tests that symlink creation works on this platform. - /// This is a helper to verify the test environment supports symlinks. - #[test] - #[cfg(not(any(target_os = "ios")))] - fn test_symlink_creation_works() { - let base_dir = std::env::temp_dir().join("rustdesk_symlink_test"); - let _ = fs::remove_dir_all(&base_dir); - fs::create_dir_all(&base_dir).unwrap(); - - // Create target file in a subdirectory - let target_dir = base_dir.join("target_dir"); - fs::create_dir_all(&target_dir).unwrap(); - let target_file = target_dir.join("target.txt"); - fs::write(&target_file, b"content").unwrap(); - - // Create symlink in a different directory - let link_dir = base_dir.join("link_dir"); - fs::create_dir_all(&link_dir).unwrap(); - let link_path = link_dir.join("link.txt"); - - #[cfg(unix)] - { - use std::os::unix::fs::symlink; - if symlink(&target_file, &link_path).is_err() { - let _ = fs::remove_dir_all(&base_dir); - return; - } - } - - #[cfg(windows)] - { - use std::os::windows::fs::symlink_file; - if symlink_file(&target_file, &link_path).is_err() { - // Skip if no permission (needs admin or dev mode on Windows) - let _ = fs::remove_dir_all(&base_dir); - return; - } - } - - let _ = fs::remove_dir_all(&base_dir); - } } diff --git a/src/ui_interface.rs b/src/ui_interface.rs index 734cb5d79..d67e3941b 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -1742,16 +1742,7 @@ pub fn is_remote_modify_enabled_by_control_permissions() -> Option { #[cfg(test)] mod tests { - use super::{trim_video_save_directory, validate_windows_service_video_save_directory}; - - #[test] - fn trim_configured_video_save_directory() { - assert_eq!( - trim_video_save_directory(" relative/recordings "), - Some("relative/recordings") - ); - assert_eq!(trim_video_save_directory(" "), None); - } + use super::validate_windows_service_video_save_directory; #[test] fn validate_service_video_save_directory() { diff --git a/src/update_metadata/offline_tests.rs b/src/update_metadata/offline_tests.rs index a5b848152..38555e69a 100644 --- a/src/update_metadata/offline_tests.rs +++ b/src/update_metadata/offline_tests.rs @@ -27,17 +27,6 @@ fn verify_offline_for_package( ) } -#[test] -fn accepts_valid_signed_offline_artifact() { - let artifact = verify_offline(&valid_fixture(), "rustdesk-1.4.6-x86_64.exe") - .expect("verified offline artifact"); - - assert_eq!("rustdesk-1.4.6-x86_64.exe", artifact.file_name); - assert_eq!(123456, artifact.size); - assert_eq!(SHA256, artifact.sha256); - assert_eq!("1.4.6", artifact.version); -} - #[test] fn offline_metadata_requires_the_expected_package_identity() { const CUSTOM_PACKAGE_ID: &str = "com.example.rustdesk-custom"; diff --git a/src/update_metadata_tests.rs b/src/update_metadata_tests.rs index 911388f8d..dca28f334 100644 --- a/src/update_metadata_tests.rs +++ b/src/update_metadata_tests.rs @@ -97,25 +97,6 @@ fn verify(fixture: &Fixture) -> ResultType { verify_with_query(fixture, &query()) } -fn verify_for_package( - fixture: &Fixture, - expected_package_id: &str, -) -> ResultType { - let trusted_keys = [key(fixture.public_key)]; - verify_update_metadata_with_options( - &fixture.metadata, - &fixture.signature, - VerificationOptions { - requirements: requirements(query()), - expected_package_id, - artifact_url_policy: ArtifactUrlPolicy::ExactPrefix( - "https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/", - ), - trusted_keys: &trusted_keys, - }, - ) -} - #[test] fn accepts_valid_metadata_and_signature() { let artifact = verify(&valid_fixture()).expect("verified artifact"); @@ -125,17 +106,6 @@ fn accepts_valid_metadata_and_signature() { assert_eq!("1.4.6", artifact.version); } -#[test] -fn accepts_only_the_expected_package_identity() { - const CUSTOM_PACKAGE_ID: &str = "com.example.rustdesk-custom"; - let mut custom_metadata = metadata(); - custom_metadata["package_id"] = json!(CUSTOM_PACKAGE_ID); - let fixture = sign_fixture(custom_metadata, KEY_ID); - - assert!(verify_for_package(&fixture, CUSTOM_PACKAGE_ID).is_ok()); - assert!(verify_for_package(&fixture, "rustdesk").is_err()); -} - #[test] fn rejects_bad_signature_or_tampered_metadata() { let mut fixture = valid_fixture(); @@ -175,33 +145,6 @@ fn rejects_schema_policy_artifact_and_url_mismatches() { } } -#[test] -fn rejects_artifact_url_query_or_fragment() { - for suffix in ["?download=1", "#hash"] { - let mut invalid_metadata = metadata(); - invalid_metadata["artifacts"][0]["url"] = json!(format!( - "https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/rustdesk-1.4.6-x86_64.exe{suffix}" - )); - let fixture = sign_fixture(invalid_metadata, KEY_ID); - let error = verify(&fixture).err().expect("invalid artifact URL"); - - assert_eq!( - "update artifact URL must not contain query or fragment", - error.to_string() - ); - } -} - -#[test] -fn rejects_duplicate_artifact_matches() { - let mut duplicate_metadata = metadata(); - duplicate_metadata["artifacts"] - .as_array_mut() - .expect("artifacts") - .push(artifact()); - assert!(verify(&sign_fixture(duplicate_metadata, KEY_ID)).is_err()); -} - #[test] fn rejects_artifact_file_name_path_components() { for file_name in [ @@ -229,24 +172,6 @@ fn rejects_artifact_file_name_path_components() { } } -#[test] -fn rejects_path_components_in_unselected_artifact_file_name() { - let mut invalid_metadata = metadata(); - let mut unselected_artifact = artifact(); - unselected_artifact["platform"] = json!("linux"); - unselected_artifact["file_name"] = json!("nested/rustdesk-linux.exe"); - invalid_metadata["artifacts"] - .as_array_mut() - .expect("artifacts") - .push(unselected_artifact); - - let error = verify(&sign_fixture(invalid_metadata, KEY_ID)) - .err() - .expect("invalid file name"); - - assert_eq!("invalid update artifact file name", error.to_string()); -} - #[test] fn verifies_metadata_generated_by_python_release_script_fixture() { const PYTHON_KEY_ID: &str = "python-test-ed25519-main"; diff --git a/src/updater/artifact.rs b/src/updater/artifact.rs index 9759b1f66..39a8245f5 100644 --- a/src/updater/artifact.rs +++ b/src/updater/artifact.rs @@ -242,18 +242,4 @@ mod tests { assert!(get_update_download_file_from_url(url).is_none(), "{url}"); } } - - #[test] - fn limited_sidecar_reader_rejects_oversized_payloads() { - let mut payload: &[u8] = b"rustdesk"; - assert_eq!( - read_limited_response_bytes(&mut payload, 8, "sidecar") - .unwrap() - .len(), - 8 - ); - - let mut oversized: &[u8] = b"too-large"; - assert!(read_limited_response_bytes(&mut oversized, 4, "sidecar").is_err()); - } } diff --git a/src/updater/download/tests.rs b/src/updater/download/tests.rs index 661c51153..599164b0e 100644 --- a/src/updater/download/tests.rs +++ b/src/updater/download/tests.rs @@ -31,25 +31,6 @@ fn copy_and_verify_update_artifact_hashes_open_file_handle() { assert_eq!(position, Some(0)); } -#[test] -fn remove_cached_update_artifact_rejects_directory() { - let test_dir = std::env::temp_dir().join(format!( - "rustdesk-updater-cache-dir-test-{}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&test_dir); - std::fs::create_dir_all(&test_dir).unwrap(); - let cache_path = test_dir.join("rustdesk-update.exe"); - std::fs::create_dir(&cache_path).unwrap(); - std::fs::write(cache_path.join("stale"), b"stale").unwrap(); - - let result = remove_cached_update_artifact(&cache_path); - - assert!(result.is_err()); - assert!(cache_path.exists()); - std::fs::remove_dir_all(&test_dir).unwrap(); -} - #[cfg(unix)] #[test] fn verified_download_replaces_symlink_without_touching_target() {