|
| 1 | +// The regression guard for `tool/patches/0001-semantics-attach-stale-children.patch`. |
| 2 | +// |
| 3 | +// Nothing in DPIP is under test here — this is the framework, reproduced at the |
| 4 | +// smallest shape that reaches the bug. It lives in the suite because the patch |
| 5 | +// has to be kept honest by something that runs on every machine and in CI, and |
| 6 | +// a test that fails on an unpatched SDK does that without a check script: the |
| 7 | +// patch is applied by `require_mise`, so a run that skipped it fails here. |
| 8 | +// |
| 9 | +// When upstream fixes flutter#189902 and mise.toml moves to a version that |
| 10 | +// carries the fix, the patch stops applying (and says so, loudly) — this test |
| 11 | +// should then pass on its own. Keep it. It is the thing that says the fix is |
| 12 | +// really in. |
| 13 | +// |
| 14 | +// Shape credit: the reduction in the upstream issue. Each of the three oddities |
| 15 | +// below is load-bearing; drop any one and the assert does not fire. |
| 16 | +import 'package:flutter/gestures.dart'; |
| 17 | +import 'package:flutter/material.dart'; |
| 18 | +import 'package:flutter_test/flutter_test.dart'; |
| 19 | + |
| 20 | +/// Two tooltips, one of which is rigged to reach the bug. |
| 21 | +/// |
| 22 | +/// Every [Tooltip] is an [OverlayPortal] — `RawTooltip` is built on |
| 23 | +/// `OverlayPortal.overlayChildLayoutBuilder` — which is why an app that never |
| 24 | +/// names `OverlayPortal` (DPIP does not) still hits an OverlayPortal defect. |
| 25 | +class _TwoTooltips extends StatelessWidget { |
| 26 | + const _TwoTooltips(); |
| 27 | + |
| 28 | + /// A border, so the two rows do not collapse into one render object and the |
| 29 | + /// semantics subtree has an interior node to lose track of. |
| 30 | + Widget _decorate(Widget child) => DecoratedBox( |
| 31 | + decoration: BoxDecoration(border: Border.all()), |
| 32 | + child: child, |
| 33 | + ); |
| 34 | + |
| 35 | + Widget _tooltip(String label) => Tooltip( |
| 36 | + message: '$label tooltip', |
| 37 | + // `explicitChildNodes` is what stops the child folding into the tooltip's |
| 38 | + // own node, so there is a separate SemanticsNode available to be stolen. |
| 39 | + child: Semantics(explicitChildNodes: true, child: Text('$label text')), |
| 40 | + ); |
| 41 | + |
| 42 | + @override |
| 43 | + Widget build(BuildContext context) { |
| 44 | + Widget bad = _tooltip('bad'); |
| 45 | + // The second Overlay is what lets one node end up parented under a subtree |
| 46 | + // that a later pass rebuilds from the bottom up — the "stealing". |
| 47 | + bad = Overlay.wrap(child: ExcludeSemantics(child: bad)); |
| 48 | + // A tight box: it stops the enclosing node being rebuilt on the pass that |
| 49 | + // would otherwise refresh the stale `_children` list and hide the bug. |
| 50 | + bad = SizedBox(width: 200, height: 100, child: bad); |
| 51 | + |
| 52 | + return Dialog( |
| 53 | + child: Column( |
| 54 | + spacing: 20, |
| 55 | + children: [_decorate(_tooltip('good')), _decorate(bad)], |
| 56 | + ), |
| 57 | + ); |
| 58 | + } |
| 59 | +} |
| 60 | + |
| 61 | +void main() { |
| 62 | + testWidgets('a tooltip shown and dismissed twice keeps the semantics tree ' |
| 63 | + 'consistent (flutter#189902)', (tester) async { |
| 64 | + // Semantics is only built when something asks for it — a screen reader on |
| 65 | + // a device, this handle in a test. Without it `flushSemantics` does no work |
| 66 | + // and the bug cannot be reached. |
| 67 | + final handle = tester.ensureSemantics(); |
| 68 | + |
| 69 | + await tester.pumpWidget( |
| 70 | + const MaterialApp(home: Scaffold(body: _TwoTooltips())), |
| 71 | + ); |
| 72 | + |
| 73 | + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); |
| 74 | + await gesture.addPointer(location: Offset.zero); |
| 75 | + addTearDown(gesture.removePointer); |
| 76 | + |
| 77 | + final target = tester.getCenter(find.text('bad text')); |
| 78 | + const away = Offset(5, 5); |
| 79 | + |
| 80 | + // Twice is the whole test. The first show/dismiss leaves a SemanticsNode |
| 81 | + // with `attached == true` and `parent == null`; the second dismiss walks |
| 82 | + // into it and trips `assert(!child.attached)` in `_replaceChildren`. |
| 83 | + for (var pass = 1; pass <= 2; pass++) { |
| 84 | + await gesture.moveTo(target); |
| 85 | + await tester.pump(); |
| 86 | + await tester.pump(const Duration(seconds: 1)); |
| 87 | + await tester.pumpAndSettle(); |
| 88 | + |
| 89 | + await gesture.moveTo(away); |
| 90 | + await tester.pump(); |
| 91 | + await tester.pump(const Duration(seconds: 1)); |
| 92 | + await tester.pumpAndSettle(); |
| 93 | + } |
| 94 | + |
| 95 | + // Disposed in the body, not `addTearDown`: the binding's own end-of-test |
| 96 | + // check for leaked handles runs before tear-downs do. |
| 97 | + handle.dispose(); |
| 98 | + }); |
| 99 | +} |
0 commit comments