|
| 1 | +// Copyright 2026 The Flutter Authors |
| 2 | +// Use of this source code is governed by a BSD-style license that can be |
| 3 | +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. |
| 4 | + |
| 5 | +import 'package:devtools_app/src/shared/ui/hover.dart'; |
| 6 | +import 'package:devtools_test/helpers.dart'; |
| 7 | +import 'package:flutter/gestures.dart'; |
| 8 | +import 'package:flutter/material.dart'; |
| 9 | +import 'package:flutter_test/flutter_test.dart'; |
| 10 | +import 'package:provider/provider.dart'; |
| 11 | + |
| 12 | +void main() { |
| 13 | + Future<void> pumpHoverCardTooltip( |
| 14 | + WidgetTester tester, { |
| 15 | + required Alignment alignment, |
| 16 | + String? title, |
| 17 | + }) async { |
| 18 | + await tester.pumpWidget( |
| 19 | + wrapSimple( |
| 20 | + Align( |
| 21 | + alignment: alignment, |
| 22 | + child: HoverCardTooltip.sync( |
| 23 | + enabled: () => true, |
| 24 | + generateHoverCardData: (event) => HoverCardData( |
| 25 | + title: title, |
| 26 | + contents: const SizedBox( |
| 27 | + width: 200, |
| 28 | + height: 250, |
| 29 | + child: Text('Hover Content'), |
| 30 | + ), |
| 31 | + ), |
| 32 | + child: const Text('Hover Me'), |
| 33 | + ), |
| 34 | + ), |
| 35 | + ), |
| 36 | + ); |
| 37 | + |
| 38 | + // Trigger hover |
| 39 | + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); |
| 40 | + await gesture.addPointer(location: Offset.zero); |
| 41 | + final center = tester.getCenter(find.text('Hover Me')); |
| 42 | + await gesture.moveTo(center); |
| 43 | + await tester.pump(const Duration(milliseconds: 500)); |
| 44 | + await tester.pumpAndSettle(); |
| 45 | + } |
| 46 | + |
| 47 | + testWidgetsWithWindowSize( |
| 48 | + 'HoverCard at the bottom of the window should not overflow', |
| 49 | + const Size(800, 600), |
| 50 | + (WidgetTester tester) async { |
| 51 | + // Use a title to increase the height beyond the base content height. |
| 52 | + await pumpHoverCardTooltip( |
| 53 | + tester, |
| 54 | + alignment: Alignment.bottomCenter, |
| 55 | + title: 'A Very Important Title', |
| 56 | + ); |
| 57 | + |
| 58 | + final hoverContentFinder = find.text('Hover Content'); |
| 59 | + expect(hoverContentFinder, findsOneWidget); |
| 60 | + |
| 61 | + final overlayContainer = find |
| 62 | + .ancestor(of: hoverContentFinder, matching: find.byType(Container)) |
| 63 | + .last; // The outermost container of the HoverCard |
| 64 | + |
| 65 | + final renderBox = tester.renderObject(overlayContainer) as RenderBox; |
| 66 | + final position = renderBox.localToGlobal(Offset.zero); |
| 67 | + final size = renderBox.size; |
| 68 | + |
| 69 | + // _hoverMargin = 16.0 |
| 70 | + expect(position.dy + size.height, lessThanOrEqualTo(600.0 - 16.0)); |
| 71 | + }, |
| 72 | + ); |
| 73 | + |
| 74 | + testWidgetsWithWindowSize( |
| 75 | + 'HoverCard at the right of the window should not overflow', |
| 76 | + const Size(800, 600), |
| 77 | + (WidgetTester tester) async { |
| 78 | + await pumpHoverCardTooltip(tester, alignment: Alignment.centerRight); |
| 79 | + |
| 80 | + final hoverContentFinder = find.text('Hover Content'); |
| 81 | + expect(hoverContentFinder, findsOneWidget); |
| 82 | + |
| 83 | + final overlayContainer = find |
| 84 | + .ancestor(of: hoverContentFinder, matching: find.byType(Container)) |
| 85 | + .last; |
| 86 | + |
| 87 | + final renderBox = tester.renderObject(overlayContainer) as RenderBox; |
| 88 | + final position = renderBox.localToGlobal(Offset.zero); |
| 89 | + final size = renderBox.size; |
| 90 | + |
| 91 | + // _hoverMargin = 16.0 |
| 92 | + expect(position.dx + size.width, lessThanOrEqualTo(800.0 - 16.0)); |
| 93 | + }, |
| 94 | + ); |
| 95 | + |
| 96 | + testWidgetsWithWindowSize( |
| 97 | + 'HoverCard in very small window should not crash', |
| 98 | + const Size(100, 100), // Smaller than tooltip |
| 99 | + (WidgetTester tester) async { |
| 100 | + await pumpHoverCardTooltip(tester, alignment: Alignment.center); |
| 101 | + |
| 102 | + final hoverContentFinder = find.text('Hover Content'); |
| 103 | + expect(hoverContentFinder, findsOneWidget); |
| 104 | + |
| 105 | + final overlayContainer = find |
| 106 | + .ancestor(of: hoverContentFinder, matching: find.byType(Container)) |
| 107 | + .last; |
| 108 | + |
| 109 | + expect(overlayContainer, findsOneWidget); |
| 110 | + }, |
| 111 | + ); |
| 112 | + |
| 113 | + testWidgetsWithWindowSize( |
| 114 | + 'HoverCard height clamping with title', |
| 115 | + const Size(800, 600), |
| 116 | + (WidgetTester tester) async { |
| 117 | + await pumpHoverCardTooltip( |
| 118 | + tester, |
| 119 | + alignment: Alignment.bottomCenter, |
| 120 | + title: 'An Important Title', |
| 121 | + ); |
| 122 | + |
| 123 | + final hoverContentFinderWithTitle = find.text('Hover Content'); |
| 124 | + expect(hoverContentFinderWithTitle, findsOneWidget); |
| 125 | + |
| 126 | + final containerWithTitle = find |
| 127 | + .ancestor( |
| 128 | + of: hoverContentFinderWithTitle, |
| 129 | + matching: find.byType(Container), |
| 130 | + ) |
| 131 | + .last; |
| 132 | + |
| 133 | + final renderBoxWithTitle = |
| 134 | + tester.renderObject(containerWithTitle) as RenderBox; |
| 135 | + final positionWithTitle = renderBoxWithTitle.localToGlobal(Offset.zero); |
| 136 | + |
| 137 | + // Clamps strictly at y = 274.0 because of dynamic height containing title/divider. |
| 138 | + expect(positionWithTitle.dy, equals(274.0)); |
| 139 | + }, |
| 140 | + ); |
| 141 | + |
| 142 | + testWidgetsWithWindowSize( |
| 143 | + 'HoverCard height clamping without title', |
| 144 | + const Size(800, 600), |
| 145 | + (WidgetTester tester) async { |
| 146 | + await pumpHoverCardTooltip(tester, alignment: Alignment.bottomCenter); |
| 147 | + |
| 148 | + final hoverContentFinderNoTitle = find.text('Hover Content'); |
| 149 | + expect(hoverContentFinderNoTitle, findsOneWidget); |
| 150 | + |
| 151 | + final containerNoTitle = find |
| 152 | + .ancestor( |
| 153 | + of: hoverContentFinderNoTitle, |
| 154 | + matching: find.byType(Container), |
| 155 | + ) |
| 156 | + .last; |
| 157 | + |
| 158 | + final renderBoxNoTitle = |
| 159 | + tester.renderObject(containerNoTitle) as RenderBox; |
| 160 | + final positionNoTitle = renderBoxNoTitle.localToGlobal(Offset.zero); |
| 161 | + |
| 162 | + // Clamps lower down at y = 314.0 because max height is smaller without title gaps. |
| 163 | + expect(positionNoTitle.dy, equals(314.0)); |
| 164 | + }, |
| 165 | + ); |
| 166 | + |
| 167 | + testWidgetsWithWindowSize( |
| 168 | + 'HoverCard translates global coordinates to local coordinates for offset overlays', |
| 169 | + const Size(800, 600), |
| 170 | + (WidgetTester tester) async { |
| 171 | + final overlayKey = GlobalKey(); |
| 172 | + |
| 173 | + await tester.pumpWidget( |
| 174 | + MaterialApp( |
| 175 | + home: Scaffold( |
| 176 | + body: Padding( |
| 177 | + padding: const EdgeInsets.only(left: 50.0, top: 100.0), |
| 178 | + child: Provider<HoverCardController>.value( |
| 179 | + value: HoverCardController(), |
| 180 | + child: Overlay( |
| 181 | + key: overlayKey, |
| 182 | + initialEntries: [ |
| 183 | + OverlayEntry( |
| 184 | + builder: (context) => Align( |
| 185 | + alignment: Alignment.topLeft, |
| 186 | + child: HoverCardTooltip.sync( |
| 187 | + enabled: () => true, |
| 188 | + generateHoverCardData: (event) => HoverCardData( |
| 189 | + contents: const SizedBox( |
| 190 | + width: 200, |
| 191 | + height: 250, |
| 192 | + child: Text('Hover Content'), |
| 193 | + ), |
| 194 | + ), |
| 195 | + child: const Text('Hover Me Offset'), |
| 196 | + ), |
| 197 | + ), |
| 198 | + ), |
| 199 | + ], |
| 200 | + ), |
| 201 | + ), |
| 202 | + ), |
| 203 | + ), |
| 204 | + ), |
| 205 | + ); |
| 206 | + |
| 207 | + // Trigger hover |
| 208 | + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); |
| 209 | + await gesture.addPointer(location: Offset.zero); |
| 210 | + |
| 211 | + final center = tester.getCenter(find.text('Hover Me Offset')); |
| 212 | + await gesture.moveTo(center); |
| 213 | + await tester.pump(const Duration(milliseconds: 500)); |
| 214 | + await tester.pumpAndSettle(); |
| 215 | + |
| 216 | + final hoverContentFinder = find.text('Hover Content'); |
| 217 | + expect(hoverContentFinder, findsOneWidget); |
| 218 | + |
| 219 | + final overlayContainer = find |
| 220 | + .ancestor(of: hoverContentFinder, matching: find.byType(Container)) |
| 221 | + .last; |
| 222 | + |
| 223 | + final renderBox = tester.renderObject(overlayContainer) as RenderBox; |
| 224 | + final position = renderBox.localToGlobal(Offset.zero); |
| 225 | + |
| 226 | + // Dynamic margin is 16.0. Since overlay is offset by 50px globally at the left, |
| 227 | + // dynamic local X is 16.0, mapped to global X = 50.0 + 16.0 = 66.0. |
| 228 | + expect(position.dx, equals(66.0)); |
| 229 | + }, |
| 230 | + ); |
| 231 | +} |
0 commit comments