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/native/custom_cursor.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, {required this.style, required this.scale}); final String style; @override final double devicePixelRatio; @override final imageOverflow = false.obs; @override late final viewStyle = ViewStyle( style: style, width: _viewport.width, height: _viewport.height, displayWidth: 400, displayHeight: 320, ); @override bool get cursorEmbedded => false; @override Size get size => _viewport; @override final double scale; @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, this._ffi); final FFI _ffi; @override WeakReference get parent => WeakReference(_ffi); @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 { _FFI(this.canvasModel); @override final CanvasModel canvasModel; @override final inputModel = _Input(); @override final ffiModel = _Peer(); } Future _data(int density, String id) async { final bitmapDensity = density == 0 ? 1 : density; final image = await createTestImage( width: 9 * bitmapDensity, height: 18 * bitmapDensity); return CursorData( peerId: 'dpi-policy', id: id, image: img.Image(width: image.width, height: image.height, numChannels: 4), nativeImage: image, scale: 1, data: Uint8List.fromList([1, 2]), hotxOrigin: 4.0 * bitmapDensity, hotyOrigin: 9.0 * bitmapDensity, width: image.width, height: image.height, pixelRatio: density.toDouble(), ); } void main() { final binding = TestWidgetsFlutterBinding.ensureInitialized(); final view = binding.platformDispatcher.views.single; final channel = Platform.isWindows ? SystemChannels.mouseCursor : const MethodChannel('flutter_custom_cursor'); final windows = Platform.isWindows; final registrations = >[]; setUp(() { registrations.clear(); binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { if (!call.method.startsWith('createCustomCursor')) return null; final args = call.arguments as Map; registrations.add(args); return args['name']; }); }); tearDown(() { view.resetDevicePixelRatio(); binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); }); for (final testCase in [ (kRemoteViewStyleAdaptive, false, 0, 0.25, windows ? 1.0 : 4 / 3), (kRemoteViewStyleCustom, false, 0, 0.25, windows ? 1.0 : 4 / 3), (kRemoteViewStyleAdaptive, false, 1, 0.25, windows ? 2.0 : 1.0), (kRemoteViewStyleAdaptive, false, 2, 0.25, windows ? 1.0 : 0.5), (kRemoteViewStyleCustom, false, 2, 0.25, windows ? 1.0 : 0.5), (kRemoteViewStyleAdaptive, true, 2, 0.25, windows ? 2 / 3 : 1 / 3), (kRemoteViewStyleCustom, true, 2, 0.25, windows ? 2 / 3 : 1 / 3), (kRemoteViewStyleOriginal, false, 2, 0.5, windows ? 1.0 : 2 / 3), ]) { testWidgets( '${testCase.$1} zoom=${testCase.$2} peerDPR=${testCase.$3}', (tester) => tester .runAsync(() => _checkPolicy(tester, testCase, registrations))); } test('live DPR changes invalidate a cached native cursor', () => _checkDprChange(view, registrations)); } Future _checkPolicy( WidgetTester tester, (String, bool, int, double, double) testCase, List> registrations) async { final (style, zoom, density, viewScale, expectedScale) = testCase; tester.view.devicePixelRatio = 2; final data = await _data(density, '$style-$zoom-$density'); final originalBytes = data.data; // A stale cached DPR must not affect the cursor when the window moves. final canvas = _Canvas(1, style: style, scale: viewScale); final ffi = _FFI(canvas); final cursor = _Cursor(data, ffi); await tester.pumpWidget(MediaQuery( data: const MediaQueryData(devicePixelRatio: 2), child: MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => _Image()), ChangeNotifierProvider.value(value: canvas), ChangeNotifierProvider.value(value: cursor), ], child: ImagePaint( ffi: ffi, id: 'dpi-policy', zoomCursor: zoom.obs, cursorOverImage: true.obs, keyboardEnabled: true.obs, remoteCursorMoved: false.obs, )), )); await tester.pumpWidget(const SizedBox.shrink()); for (final key in cursor.cachedKeys) { await deleteCustomCursor(key); } data.nativeImage.dispose(); cursor.dispose(); canvas.dispose(); expect(data.scale, closeTo(expectedScale, 1e-9)); expect(data.hotx, closeTo(data.hotxOrigin * expectedScale, 1e-9)); expect(data.hoty, closeTo(data.hotyOrigin * expectedScale, 1e-9)); expect(data.data, same(originalBytes)); if (style == kRemoteViewStyleAdaptive && !zoom && density > 0) { final args = registrations.single; expect((args['width'], args['height']), (Platform.isLinux ? 36 : 18, 36)); expect((args['hotX'], args['hotY']), (8.0, 18.0)); expect(args['imagePixelRatio'], 2.0); } } Future _checkDprChange( TestFlutterView view, List> registrations) async { // Keep the scale above the minimum so only DPR invalidates the cache. final data = await _data(2, 'dpr-cache'); final canvas = _Canvas(1, style: kRemoteViewStyleAdaptive, scale: 1); final cursor = _Cursor(data, _FFI(canvas)); for (final dpr in [2.0, 1.0]) { view.devicePixelRatio = dpr; buildCursorOfCache(cursor, 1, data); await deleteCustomCursor(cursor.cachedKeys.last); } expect(registrations, hasLength(2)); expect(registrations[0]['name'], isNot(registrations[1]['name'])); data.nativeImage.dispose(); cursor.dispose(); canvas.dispose(); }