From fdd67a875b641a3032776eaa9d2950da31870ee4 Mon Sep 17 00:00:00 2001 From: fufesou Date: Fri, 11 Sep 2026 16:02:52 +0800 Subject: [PATCH] test(flutter): cover local cursor sizing and mode switches --- flutter/test/cursor_local_size_test.dart | 271 +++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 flutter/test/cursor_local_size_test.dart diff --git a/flutter/test/cursor_local_size_test.dart b/flutter/test/cursor_local_size_test.dart new file mode 100644 index 000000000..f98876c16 --- /dev/null +++ b/flutter/test/cursor_local_size_test.dart @@ -0,0 +1,271 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/desktop/pages/remote_page.dart'; +import 'package:flutter_hbb/models/input_model.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/utils/cursor_size.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:image/image.dart' as img; +import 'package:provider/provider.dart'; + +const _viewport = Size(200, 160); + +class _Image extends ChangeNotifier implements ImageModel { + @override + bool get useTextureRender => false; + @override + ui.Image? get image => null; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Canvas extends ChangeNotifier implements CanvasModel { + _Canvas(this.devicePixelRatio); + + @override + final double devicePixelRatio; + @override + final imageOverflow = false.obs; + @override + final viewStyle = ViewStyle( + style: kRemoteViewStyleAdaptive, + width: _viewport.width, + height: _viewport.height, + displayWidth: 400, + displayHeight: 320, + ); + @override + bool get cursorEmbedded => false; + @override + Size get size => _viewport; + @override + double get scale => 0.5; + @override + double get x => 0; + @override + double get y => 0; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Cursor extends ChangeNotifier implements CursorModel { + _Cursor(this.cache); + + @override + CursorData cache; + @override + ui.Image? get image => null; + @override + double get hotx => cache.hotxOrigin; + @override + double get hoty => cache.hotyOrigin; + @override + final Set cachedKeys = {}; + @override + void addKey(String key) => cachedKeys.add(key); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Input extends Fake implements InputModel { + @override + final relativeMouseMode = false.obs; +} + +class _Peer extends Fake implements FfiModel { + @override + final pi = PeerInfo(); + @override + bool get isPeerLinux => false; +} + +class _FFI extends Fake implements FFI { + @override + final inputModel = _Input(); + @override + final ffiModel = _Peer(); +} + +CursorData _cursor(img.Image image, + {String id = 'sprite', double hotspot = 0}) { + return CursorData( + peerId: 'local-size-test', + id: id, + image: image, + scale: 1, + data: Platform.isWindows + ? image.getBytes(order: img.ChannelOrder.bgra) + : Uint8List.fromList(img.encodePng(image)), + hotxOrigin: hotspot, + hotyOrigin: hotspot, + width: image.width, + height: image.height, + ); +} + +CursorData _arrow(int density) { + final image = + img.Image(width: 17 * density, height: 23 * density, numChannels: 4); + img.fill(image, color: img.ColorRgba8(255, 255, 255, 255)); + return _cursor(image, id: 'arrow-$density', hotspot: 4.0 * density); +} + +void main() { + test('local size preserves remote pixels, padding and asymmetric hotspot', + () { + final artwork = img.Image(width: 40, height: 48, numChannels: 4); + img.fillRect(artwork, + x1: 8, + y1: 12, + x2: 22, + y2: 35, + color: img.ColorRgba8(180, 90, 45, 128), + alphaBlend: false); + for (final density in [1, 2, 3]) { + final source = img.copyResize(artwork, + width: artwork.width * density, + height: artwork.height * density, + interpolation: img.Interpolation.nearest); + expect(cursorVisibleSize(source), 24 * density); + final cursor = CursorData( + peerId: 'local-size-test', + id: 'padded-$density', + image: source, + scale: 1, + data: Platform.isWindows + ? source.getBytes(order: img.ChannelOrder.bgra) + : Uint8List.fromList(img.encodePng(source)), + hotxOrigin: 10.0 * density, + hotyOrigin: 17.0 * density, + width: source.width, + height: source.height, + )..localSize = 24; + cursor.updateGetKey(0.01); + final output = Platform.isWindows + ? img.Image.fromBytes( + width: cursor.scaledWidth, + height: cursor.scaledHeight, + bytes: cursor.data!.buffer, + order: img.ChannelOrder.bgra) + : img.decodePng(cursor.data!)!; + expect(cursorVisibleSize(output), 24); + expect(output.getBytes(), artwork.getBytes()); + expect((cursor.hotx, cursor.hoty), (10, 17)); + } + }); + + test('local cursor minimum keeps a thin remote cursor visible', () { + final thin = img.Image(width: 1, height: 48, numChannels: 4); + img.fill(thin, color: img.ColorRgba8(255, 255, 255, 255)); + final cursor = _cursor(thin)..localSize = 2; + cursor.updateGetKey(1); + expect(cursor.scaledWidth, 1); + expect(cursor.scaledHeight, kMinCursorSize); + final blank = _cursor(img.Image(width: 17, height: 23, numChannels: 4)) + ..localSize = 24; + blank.updateGetKey(1); + final output = Platform.isWindows + ? img.Image.fromBytes( + width: 17, + height: 23, + bytes: blank.data!.buffer, + order: img.ChannelOrder.bgra) + : img.decodePng(blank.data!)!; + expect(cursorVisibleSize(output), 0); + expect(blank.scale, 1); + }); + + test('switching local sizing keeps bitmap data and cache geometry consistent', + () { + final pending = _arrow(1)..data = null; + pending.updateGetKey(1); + expect(pending.data, isNull); + final cursor = _arrow(1); + const scale = 1.5; + final originalKey = cursor.updateGetKey(scale); + cursor.localSize = 23 * scale; + final localKey = cursor.updateGetKey(scale); + expect(cursor.scaledWidth, 26); + if (Platform.isWindows) { + expect(localKey, isNot(originalKey)); + expect(cursor.data!.length, 26 * 35 * 4); + } + cursor.localSize = null; + expect(cursor.updateGetKey(scale), originalKey); + if (Platform.isWindows) expect(cursor.data!.length, 25 * 34 * 4); + }); + + for (final dpr in [1.0, 2.0, 3.0]) { + testWidgets('Zoom off at local DPR $dpr ignores remote raster density', + (tester) async { + final channel = Platform.isWindows + ? SystemChannels.mouseCursor + : const MethodChannel('flutter_custom_cursor'); + final registered = >[]; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, + (call) async { + if (call.method.startsWith('createCustomCursor')) { + final arguments = call.arguments as Map; + registered.add(arguments); + return arguments['name']; + } + return null; + }); + addTearDown(() => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null)); + const sizeChannel = MethodChannel('org.rustdesk.rustdesk/cursor'); + tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(sizeChannel, (call) async { + expect(call.method, 'getSystemCursorSize'); + return Platform.isWindows ? 23.0 * dpr : 23.0; + }); + addTearDown(() => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(sizeChannel, null)); + final cursor = _Cursor(_arrow(1)); + await tester.pumpWidget(MediaQuery( + data: MediaQueryData(devicePixelRatio: dpr), + child: MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => _Image()), + ChangeNotifierProvider(create: (_) => _Canvas(dpr)), + ChangeNotifierProvider.value(value: cursor), + ], + child: ImagePaint( + ffi: _FFI(), + id: 'local-size-test', + zoomCursor: false.obs, + cursorOverImage: true.obs, + keyboardEnabled: true.obs, + remoteCursorMoved: false.obs, + ), + ), + )); + await tester.pump(); + expect(registered, hasLength(1)); + final original = registered.single; + cursor.cache = _arrow(2); + cursor.notifyListeners(); + await tester.pump(); + expect(registered, hasLength(2)); + final retina = registered.last; + expect( + [retina['width'], retina['height'], retina['hotX'], retina['hotY']], + [ + original['width'], + original['height'], + original['hotX'], + original['hotY'] + ], + reason: 'Only the remote raster density changed; the local cursor must ' + 'retain its size and hotspot.', + ); + await tester.pumpWidget(const SizedBox.shrink()); + cursor.dispose(); + }); + } +}