Files
rustdesk/flutter/test/cursor_replay_test.dart
T
RustDeskandClaude Opus 5.5 9997b7a3b8 test: remove tests that do not pin user-visible behavior (#16539)
* test: remove tests that do not pin user-visible behavior

Apply the AGENTS.md test rules to the existing Rust and Flutter suites.
A test is kept when it is the only coverage of user- or API-visible
behavior, the regression test of a reported or fixed bug, a security
boundary (one test per rejection class), or a check against an external
reference. Tests that assert private state or the order of internal
steps, pin review-round corner cases, or repeat what a kept test already
covers are removed.

Rust: 679 -> 298 tests. Dart: 118 -> 45 tests.

Test-only helpers left without callers go with them
(applyLastOnlineStateForTesting, terminalKeyboardRowWidth,
debugBufferedInputCount, tokio's test-util dev feature), and cfg gates
and visibility that existed only for the deleted tests are narrowed.
Runtime behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WS1HTjp6UQuG43KEcQPq1R

* test: keep tests that pin security and user-visible behavior

Restore six tests that review showed are the only coverage of their
behavior:

- update metadata rejects an artifact file name with path components;
- ICE candidates from another session are dropped;
- KCP delivers every multi-segment frame queued before a close;
- set_files rejects an empty name in a multi-file transfer;
- a lopsided elongated cursor keeps its centre hotspot (#16242);
- toggling Show hidden during a pending same-path read issues a new read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WS1HTjp6UQuG43KEcQPq1R

* test: keep offline update and key-exchange downgrade rejections

Restore three tests that are the only coverage of their rejection class:

- offline update metadata rejects a signed artifact with an http or
  credential-bearing URL;
- offline verification rejects a renamed local file and a version that
  does not match the release;
- a key-exchange pick lowered in transit leaves both sides unable to
  decrypt, so a v0 fallback after a failed v1 decrypt cannot pass
  unnoticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WS1HTjp6UQuG43KEcQPq1R

* test: keep the decomposed-grapheme and reconnect-cancel tests

Each catches a plausible wrong change that passes every kept test:
mapping the first scalar under a locked Ctrl turns é into \x05,
and a reconnect that does not fail pending reads leaves them hanging
until the 30 s timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WS1HTjp6UQuG43KEcQPq1R

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:45:22 +08:00

226 lines
7.6 KiB
Dart

import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_custom_cursor/flutter_custom_cursor.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/generated_bridge.dart' show CursorShape;
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/native/custom_cursor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:uuid/uuid.dart';
/// Stands in for the core, which keeps every shape the peer sent.
class _Cursor extends CursorModel {
_Cursor(FFI ffi) : super(WeakReference(ffi));
final core = <String, CursorShape>{};
final fetched = <String>[];
@override
Future<CursorShape?> fetchCursorShape(String id) {
fetched.add(id);
return Future.value(core[id]);
}
@override
bool get showsRemoteCursor => false;
}
String? _key(MouseCursor cursor) =>
cursor is FlutterCustomMemoryImageCursor ? cursor.key : null;
class _FFI extends Fake implements FFI {
_FFI() {
ffiModel = FfiModel(WeakReference(this));
cursorModel = _Cursor(this);
}
@override
final SessionID sessionId = const Uuid().v4obj();
@override
late final FfiModel ffiModel;
@override
late final CursorModel cursorModel;
_Cursor get cursor => cursorModel as _Cursor;
}
Uint8List _pixels(int size, int seed) =>
Uint8List.fromList(List.generate(size * size * 4, (i) => (i + seed) % 256));
/// A shape as the core delivers it, and keeps it.
Future<void> _feed(_FFI ffi, String id, {int size = 8, int seed = 0}) {
final pixels = _pixels(size, seed);
ffi.cursor.core[id] =
CursorShape(hotx: 0, hoty: 0, width: size, height: size, colors: pixels);
return ffi.ffiModel.handleCursorData(id, 0, 0, size, size, pixels);
}
void _select(_FFI ffi, String id) {
final evt = {'id': id};
ffi.ffiModel.updateLastCursorId(evt);
ffi.ffiModel.handleCursorId(evt);
}
Future<void> _settle() async {
for (var i = 0; i < 2; i++) {
await Future<void>.delayed(const Duration(milliseconds: 5));
}
}
void main() {
final binding = TestWidgetsFlutterBinding.ensureInitialized();
final channel = Platform.isWindows
? SystemChannels.mouseCursor
: const MethodChannel('flutter_custom_cursor');
final registered = <String>[];
final deleted = <String>[];
late _FFI ffi;
setUp(() {
registered.clear();
deleted.clear();
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
(call) async {
final args = call.arguments as Map<dynamic, dynamic>;
if (call.method.startsWith('createCustomCursor')) {
registered.add(args['name'] as String);
return args['name'];
}
if (call.method.startsWith('deleteCustomCursor')) {
deleted.add(args['name'] as String);
}
return null;
});
ffi = _FFI();
});
tearDown(() {
ffi.cursorModel.disposeImages();
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null);
});
test(
'only the shape in use keeps an image, and a shape its native cursor holds '
'keeps no pixels', () async {
final cursor = ffi.cursorModel;
for (var round = 0; round < 3; round++) {
for (final (id, seed) in [('1', 1), ('2', 2), ('3', 3)]) {
await _feed(ffi, id, seed: seed);
buildCursorOfCache(cursor, 1.0, cursor.cache);
await _settle();
}
}
expect(ffi.cursorModel.shapeIds, ['3']);
for (final id in ['1', '2']) {
expect(ffi.cursorModel.cachedShape(id)!.hasPixels, isFalse);
expect(ffi.cursorModel.cachedShape(id)!.data, isNull);
}
expect(ffi.cursor.fetched, isEmpty);
});
test('the shape in use keeps its pixels, and a new raster is made at once',
() async {
final cursor = ffi.cursorModel;
await _feed(ffi, '1', size: 32);
buildCursorOfCache(cursor, 1.0, cursor.cache);
await _settle();
expect(cursor.cache!.hasPixels, isTrue);
expect(buildCursorOfCache(cursor, 0.5, cursor.cache),
isNot(MouseCursor.defer));
await _settle();
expect(ffi.cursor.fetched, isEmpty, reason: 'nothing had to be decoded');
expect(registered.length, 2);
expect((cursor.cache!.rasterWidth, cursor.cache!.rasterHeight), (16, 16));
await _feed(ffi, '2');
expect(cursor.cachedShape('1')!.hasPixels, isFalse,
reason: 'switched away from, a shape keeps no pixels');
_select(ffi, '1');
expect(buildCursorOfCache(cursor, 0.5, cursor.cache),
isNot(MouseCursor.defer));
await _settle();
expect(registered.length, 2, reason: 'its native cursor is still there');
expect(ffi.cursor.fetched, isEmpty);
});
test('native cursors stay for the session, every raster of every shape',
() async {
// A delete frees nothing on Windows, so none is deleted before the session ends.
final cursor = ffi.cursorModel;
for (var i = 0; i < 100; i++) {
await _feed(ffi, '$i', size: 32);
buildCursorOfCache(cursor, 1.0, cursor.cache);
await _settle();
}
expect(deleted, isEmpty);
expect(cursor.cachedKeys.length, 100);
_select(ffi, '0');
expect(_key(buildCursorOfCache(cursor, 1.0, cursor.cache)),
cursor.nativeKey(cursor.cache!, 1.0));
await _settle();
expect(ffi.cursor.fetched, isEmpty, reason: 'its cursor is still there');
expect(registered.length, 100);
});
test('a new raster of a shape without pixels keeps the cursor shown before',
() async {
final cursor = ffi.cursorModel;
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();
_select(ffi, '1'); // its native cursor held its pixels, so they went
expect(_key(buildCursorOfCache(cursor, 0.5, cursor.cache)), shown);
await _settle();
expect(ffi.cursor.fetched, ['1']);
expect(_key(buildCursorOfCache(cursor, 0.5, cursor.cache)),
cursor.nativeKey(cursor.cache!, 0.5));
});
test('a shape painted again is decoded again, the last one shown meanwhile',
() async {
await _feed(ffi, '1', size: 16);
await _feed(ffi, '2');
_select(ffi, '1');
expect(ffi.cursorModel.image?.width, 8, reason: 'shape 2 until 1 is back');
await _settle();
expect(ffi.cursor.fetched, ['1']);
expect(ffi.cursorModel.image?.width, 16);
expect(ffi.cursorModel.shapeIds, ['1']);
});
test('a moved tab carries the id in use, and its window asks the core',
() async {
await _feed(ffi, '1', size: 16);
await _feed(ffi, '2');
await _feed(ffi, '1', size: 16);
final carried = ffi.ffiModel.cachedPeerData.toString();
expect(carried, isNot(contains('colors')));
expect(CachedPeerData.fromString(carried)!.lastCursorId['id'], '1',
reason: 'a shape arriving is the shape in use');
final moved = _FFI();
addTearDown(moved.cursorModel.disposeImages);
moved.cursor.core.addAll(ffi.cursor.core);
_select(moved, '1');
await _settle();
expect(moved.cursor.fetched, ['1']);
expect(moved.cursorModel.cache?.id, '1');
expect(moved.cursorModel.image?.width, 16);
});
test('a tab closing does not delete the cursors of another tab', () async {
final other = _FFI();
addTearDown(other.cursorModel.disposeImages);
while (preDefaultCursor.cache == null) {
await _settle();
}
buildCursorOfCache(ffi.cursorModel, 1.0, preDefaultCursor.cache);
buildCursorOfCache(other.cursorModel, 1.0, preDefaultCursor.cache);
await _settle();
expect(registered.toSet().length, 2, reason: 'each tab has its own');
ffi.cursorModel.clear();
await _settle();
expect(other.cursorModel.cachedKeys.single, isNot(isIn(deleted)));
});
}