// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';

const List<Widget> fooBarTexts = <Text>[
  Text('foo', textDirection: TextDirection.ltr),
  Text('bar', textDirection: TextDirection.ltr),
];

void main() {
  group('image', () {
    testWidgets('finds Image widgets', (WidgetTester tester) async {
      await tester
          .pumpWidget(_boilerplate(Image(image: FileImage(File('test')))));
      expect(find.image(FileImage(File('test'))), findsOneWidget);
    });

    testWidgets('finds Button widgets with Image', (WidgetTester tester) async {
      await tester.pumpWidget(_boilerplate(ElevatedButton(
        onPressed: null,
        child: Image(image: FileImage(File('test'))),
      )));
      expect(find.widgetWithImage(ElevatedButton, FileImage(File('test'))),
          findsOneWidget);
    });
  });

  group('text', () {
    testWidgets('finds Text widgets', (WidgetTester tester) async {
      await tester.pumpWidget(_boilerplate(
        const Text('test'),
      ));
      expect(find.text('test'), findsOneWidget);
    });

    testWidgets('finds Text.rich widgets', (WidgetTester tester) async {
      await tester.pumpWidget(_boilerplate(const Text.rich(
        TextSpan(
          text: 't',
          children: <TextSpan>[
            TextSpan(text: 'e'),
            TextSpan(text: 'st'),
          ],
        ),
      )));

      expect(find.text('test'), findsOneWidget);
    });

    group('findRichText', () {
      testWidgets('finds RichText widgets when enabled',
          (WidgetTester tester) async {
        await tester.pumpWidget(_boilerplate(RichText(
          text: const TextSpan(
            text: 't',
            children: <TextSpan>[
              TextSpan(text: 'est'),
            ],
          ),
        )));

        expect(find.text('test', findRichText: true), findsOneWidget);
      });

      testWidgets('finds Text widgets once when enabled',
          (WidgetTester tester) async {
        await tester.pumpWidget(_boilerplate(const Text('test2')));

        expect(find.text('test2', findRichText: true), findsOneWidget);
      });

      testWidgets('does not find RichText widgets when disabled',
          (WidgetTester tester) async {
        await tester.pumpWidget(_boilerplate(RichText(
          text: const TextSpan(
            text: 't',
            children: <TextSpan>[
              TextSpan(text: 'est'),
            ],
          ),
        )));

        expect(find.text('test'), findsNothing);
      });

      testWidgets(
          'does not find Text and RichText separated by semantics widgets twice',
          (WidgetTester tester) async {
        // If rich: true found both Text and RichText, this would find two widgets.
        await tester.pumpWidget(_boilerplate(
          const Text('test', semanticsLabel: 'foo'),
        ));

        expect(find.text('test'), findsOneWidget);
      });

      testWidgets('finds Text.rich widgets when enabled',
          (WidgetTester tester) async {
        await tester.pumpWidget(_boilerplate(const Text.rich(
          TextSpan(
            text: 't',
            children: <TextSpan>[
              TextSpan(text: 'est'),
              TextSpan(text: '3'),
            ],
          ),
        )));

        expect(find.text('test3', findRichText: true), findsOneWidget);
      });

      testWidgets('finds Text.rich widgets when disabled',
          (WidgetTester tester) async {
        await tester.pumpWidget(_boilerplate(const Text.rich(
          TextSpan(
            text: 't',
            children: <TextSpan>[
              TextSpan(text: 'est'),
              TextSpan(text: '3'),
            ],
          ),
        )));

        expect(find.text('test3'), findsOneWidget);
      });
    });
  });

  group('textContaining', () {
    testWidgets('finds Text widgets', (WidgetTester tester) async {
      await tester.pumpWidget(_boilerplate(
        const Text('this is a test'),
      ));
      expect(find.textContaining(RegExp(r'test')), findsOneWidget);
      expect(find.textContaining('test'), findsOneWidget);
      expect(find.textContaining('a'), findsOneWidget);
      expect(find.textContaining('s'), findsOneWidget);
    });

    testWidgets('finds Text.rich widgets', (WidgetTester tester) async {
      await tester.pumpWidget(_boilerplate(const Text.rich(
        TextSpan(
          text: 'this',
          children: <TextSpan>[
            TextSpan(text: 'is'),
            TextSpan(text: 'a'),
            TextSpan(text: 'test'),
          ],
        ),
      )));

      expect(find.textContaining(RegExp(r'isatest')), findsOneWidget);
      expect(find.textContaining('isatest'), findsOneWidget);
    });

    testWidgets('finds EditableText widgets', (WidgetTester tester) async {
      await tester.pumpWidget(MaterialApp(
        home: Scaffold(
          body: _boilerplate(TextField(
            controller: TextEditingController()..text = 'this is test',
          )),
        ),
      ));

      expect(find.textContaining(RegExp(r'test')), findsOneWidget);
      expect(find.textContaining('test'), findsOneWidget);
    });

    group('findRichText', () {
      testWidgets('finds RichText widgets when enabled',
          (WidgetTester tester) async {
        await tester.pumpWidget(_boilerplate(RichText(
          text: const TextSpan(
            text: 't',
            children: <TextSpan>[
              TextSpan(text: 'est'),
            ],
          ),
        )));

        expect(find.textContaining('te', findRichText: true), findsOneWidget);
      });

      testWidgets('finds Text widgets once when enabled',
          (WidgetTester tester) async {
        await tester.pumpWidget(_boilerplate(const Text('test2')));

        expect(find.textContaining('tes', findRichText: true), findsOneWidget);
      });

      testWidgets('does not find RichText widgets when disabled',
          (WidgetTester tester) async {
        await tester.pumpWidget(_boilerplate(RichText(
          text: const TextSpan(
            text: 't',
            children: <TextSpan>[
              TextSpan(text: 'est'),
            ],
          ),
        )));

        expect(find.textContaining('te'), findsNothing);
      });

      testWidgets(
          'does not find Text and RichText separated by semantics widgets twice',
          (WidgetTester tester) async {
        // If rich: true found both Text and RichText, this would find two widgets.
        await tester.pumpWidget(_boilerplate(
          const Text('test', semanticsLabel: 'foo'),
        ));

        expect(find.textContaining('tes'), findsOneWidget);
      });

      testWidgets('finds Text.rich widgets when enabled',
          (WidgetTester tester) async {
        await tester.pumpWidget(_boilerplate(const Text.rich(
          TextSpan(
            text: 't',
            children: <TextSpan>[
              TextSpan(text: 'est'),
              TextSpan(text: '3'),
            ],
          ),
        )));

        expect(find.textContaining('t3', findRichText: true), findsOneWidget);
      });

      testWidgets('finds Text.rich widgets when disabled',
          (WidgetTester tester) async {
        await tester.pumpWidget(_boilerplate(const Text.rich(
          TextSpan(
            text: 't',
            children: <TextSpan>[
              TextSpan(text: 'est'),
              TextSpan(text: '3'),
            ],
          ),
        )));

        expect(find.textContaining('t3'), findsOneWidget);
      });
    });
  });

  group('semantics', () {
    testWidgets('Throws StateError if semantics are not enabled',
        (WidgetTester tester) async {
      expect(() => find.bySemanticsLabel('Add'), throwsStateError);
    }, semanticsEnabled: false);

    testWidgets('finds Semantically labeled widgets',
        (WidgetTester tester) async {
      final SemanticsHandle semanticsHandle = tester.ensureSemantics();
      await tester.pumpWidget(_boilerplate(
        Semantics(
          label: 'Add',
          button: true,
          child: const TextButton(
            onPressed: null,
            child: Text('+'),
          ),
        ),
      ));
      expect(find.bySemanticsLabel('Add'), findsOneWidget);
      semanticsHandle.dispose();
    });

    testWidgets('finds Semantically labeled widgets by RegExp',
        (WidgetTester tester) async {
      final SemanticsHandle semanticsHandle = tester.ensureSemantics();
      await tester.pumpWidget(_boilerplate(
        Semantics(
          container: true,
          child: const Row(children: <Widget>[
            Text('Hello'),
            Text('World'),
          ]),
        ),
      ));
      expect(find.bySemanticsLabel('Hello'), findsNothing);
      expect(find.bySemanticsLabel(RegExp(r'^Hello')), findsOneWidget);
      semanticsHandle.dispose();
    });

    testWidgets('finds Semantically labeled widgets without explicit Semantics',
        (WidgetTester tester) async {
      final SemanticsHandle semanticsHandle = tester.ensureSemantics();
      await tester
          .pumpWidget(_boilerplate(const SimpleCustomSemanticsWidget('Foo')));
      expect(find.bySemanticsLabel('Foo'), findsOneWidget);
      semanticsHandle.dispose();
    });
  });

  group('hitTestable', () {
    testWidgets('excludes non-hit-testable widgets',
        (WidgetTester tester) async {
      await tester.pumpWidget(
        _boilerplate(IndexedStack(
          sizing: StackFit.expand,
          children: <Widget>[
            GestureDetector(
              key: const ValueKey<int>(0),
              behavior: HitTestBehavior.opaque,
              onTap: () {},
              child: const SizedBox.expand(),
            ),
            GestureDetector(
              key: const ValueKey<int>(1),
              behavior: HitTestBehavior.opaque,
              onTap: () {},
              child: const SizedBox.expand(),
            ),
          ],
        )),
      );
      expect(find.byType(GestureDetector), findsOneWidget);
      expect(find.byType(GestureDetector, skipOffstage: false), findsNWidgets(2));
      final Finder hitTestable = find.byType(GestureDetector, skipOffstage: false).hitTestable();
      expect(hitTestable, findsOneWidget);
      expect(tester.widget(hitTestable).key, const ValueKey<int>(0));
    });
  });

  testWidgets('ChainedFinders chain properly', (WidgetTester tester) async {
    final GlobalKey key1 = GlobalKey();
    await tester.pumpWidget(
      _boilerplate(Column(
        children: <Widget>[
          Container(
            key: key1,
            child: const Text('1'),
          ),
          const Text('2'),
        ],
      )),
    );

    // Get the text back. By correctly chaining the descendant finder's
    // candidates, it should find 1 instead of 2. If the _LastFinder wasn't
    // correctly chained after the descendant's candidates, the last element
    // with a Text widget would have been 2.
    final Text text = find
        .descendant(
          of: find.byKey(key1),
          matching: find.byType(Text),
        )
        .last
        .evaluate()
        .single
        .widget as Text;

    expect(text.data, '1');
  });

  testWidgets('finds multiple subtypes', (WidgetTester tester) async {
    await tester.pumpWidget(_boilerplate(
      Row(children: <Widget>[
        const Column(children: <Widget>[
          Text('Hello'),
          Text('World'),
        ]),
        Column(children: <Widget>[
          Image(image: FileImage(File('test'))),
        ]),
        const Column(children: <Widget>[
          SimpleGenericWidget<int>(child: Text('one')),
          SimpleGenericWidget<double>(child: Text('pi')),
          SimpleGenericWidget<String>(child: Text('two')),
        ]),
      ]),
    ));

    expect(find.bySubtype<Row>(), findsOneWidget);
    expect(find.bySubtype<Column>(), findsNWidgets(3));
    // Finds both rows and columns.
    expect(find.bySubtype<Flex>(), findsNWidgets(4));

    // Finds only the requested generic subtypes.
    expect(find.bySubtype<SimpleGenericWidget<int>>(), findsOneWidget);
    expect(find.bySubtype<SimpleGenericWidget<num>>(), findsNWidgets(2));
    expect(find.bySubtype<SimpleGenericWidget<Object>>(), findsNWidgets(3));

    // Finds all widgets.
    final int totalWidgetCount =
        find.byWidgetPredicate((_) => true).evaluate().length;
    expect(find.bySubtype<Widget>(), findsNWidgets(totalWidgetCount));
  });

  group('find.byElementPredicate', () {
    testWidgets('fails with a custom description in the message', (WidgetTester tester) async {
      await tester.pumpWidget(const Text('foo', textDirection: TextDirection.ltr));

      const String customDescription = 'custom description';
      late TestFailure failure;
      try {
        expect(find.byElementPredicate((_) => false, description: customDescription), findsOneWidget);
      } on TestFailure catch (e) {
        failure = e;
      }

      expect(failure, isNotNull);
      expect(failure.message, contains('Actual: _ElementPredicateWidgetFinder:<Found 0 widgets with $customDescription'));
    });
  });

  group('find.byWidgetPredicate', () {
    testWidgets('fails with a custom description in the message', (WidgetTester tester) async {
      await tester.pumpWidget(const Text('foo', textDirection: TextDirection.ltr));

      const String customDescription = 'custom description';
      late TestFailure failure;
      try {
        expect(find.byWidgetPredicate((_) => false, description: customDescription), findsOneWidget);
      } on TestFailure catch (e) {
        failure = e;
      }

      expect(failure, isNotNull);
      expect(failure.message, contains('Actual: _WidgetPredicateWidgetFinder:<Found 0 widgets with $customDescription'));
    });
  });

  group('find.descendant', () {
    testWidgets('finds one descendant', (WidgetTester tester) async {
      await tester.pumpWidget(const Row(
        textDirection: TextDirection.ltr,
        children: <Widget>[
          Column(children: fooBarTexts),
        ],
      ));

      expect(find.descendant(
        of: find.widgetWithText(Row, 'foo'),
        matching: find.text('bar'),
      ), findsOneWidget);
    });

    testWidgets('finds two descendants with different ancestors', (WidgetTester tester) async {
      await tester.pumpWidget(const Row(
        textDirection: TextDirection.ltr,
        children: <Widget>[
          Column(children: fooBarTexts),
          Column(children: fooBarTexts),
        ],
      ));

      expect(find.descendant(
        of: find.widgetWithText(Column, 'foo'),
        matching: find.text('bar'),
      ), findsNWidgets(2));
    });

    testWidgets('fails with a descriptive message', (WidgetTester tester) async {
      await tester.pumpWidget(const Row(
        textDirection: TextDirection.ltr,
        children: <Widget>[
          Column(children: <Text>[Text('foo', textDirection: TextDirection.ltr)]),
          Text('bar', textDirection: TextDirection.ltr),
        ],
      ));

      late TestFailure failure;
      try {
        expect(find.descendant(
          of: find.widgetWithText(Column, 'foo'),
          matching: find.text('bar'),
        ), findsOneWidget);
      } on TestFailure catch (e) {
        failure = e;
      }

      expect(failure, isNotNull);
      expect(
        failure.message,
        contains(
          'Actual: _DescendantWidgetFinder:<Found 0 widgets with text "bar" descending from widgets with type "Column" that are ancestors of widgets with text "foo"',
        ),
      );
    });
  });

  group('find.ancestor', () {
    testWidgets('finds one ancestor', (WidgetTester tester) async {
      await tester.pumpWidget(const Row(
        textDirection: TextDirection.ltr,
        children: <Widget>[
          Column(children: fooBarTexts),
        ],
      ));

      expect(find.ancestor(
        of: find.text('bar'),
        matching: find.widgetWithText(Row, 'foo'),
      ), findsOneWidget);
    });

    testWidgets('finds two matching ancestors, one descendant', (WidgetTester tester) async {
      await tester.pumpWidget(
        const Directionality(
          textDirection: TextDirection.ltr,
          child: Row(
            children: <Widget>[
              Row(children: fooBarTexts),
            ],
          ),
        ),
      );

      expect(find.ancestor(
        of: find.text('bar'),
        matching: find.byType(Row),
      ), findsNWidgets(2));
    });

    testWidgets('fails with a descriptive message', (WidgetTester tester) async {
      await tester.pumpWidget(const Row(
        textDirection: TextDirection.ltr,
        children: <Widget>[
          Column(children: <Text>[Text('foo', textDirection: TextDirection.ltr)]),
          Text('bar', textDirection: TextDirection.ltr),
        ],
      ));

      late TestFailure failure;
      try {
        expect(find.ancestor(
          of: find.text('bar'),
          matching: find.widgetWithText(Column, 'foo'),
        ), findsOneWidget);
      } on TestFailure catch (e) {
        failure = e;
      }

      expect(failure, isNotNull);
      expect(
        failure.message,
        contains(
          'Actual: _AncestorWidgetFinder:<Found 0 widgets with type "Column" that are ancestors of widgets with text "foo" that are ancestors of widgets with text "bar"',
        ),
      );
    });

    testWidgets('Root not matched by default', (WidgetTester tester) async {
      await tester.pumpWidget(const Row(
        textDirection: TextDirection.ltr,
        children: <Widget>[
          Column(children: fooBarTexts),
        ],
      ));

      expect(find.ancestor(
        of: find.byType(Column),
        matching: find.widgetWithText(Column, 'foo'),
      ), findsNothing);
    });

    testWidgets('Match the root', (WidgetTester tester) async {
      await tester.pumpWidget(const Row(
        textDirection: TextDirection.ltr,
        children: <Widget>[
          Column(children: fooBarTexts),
        ],
      ));

      expect(find.descendant(
        of: find.byType(Column),
        matching: find.widgetWithText(Column, 'foo'),
        matchRoot: true,
      ), findsOneWidget);
    });

    testWidgets('is fast in deep tree', (WidgetTester tester) async {
      await tester.pumpWidget(
        Directionality(
          textDirection: TextDirection.ltr,
          child: _deepWidgetTree(
            depth: 1000,
            child: Row(
              children: <Widget>[
                _deepWidgetTree(
                  depth: 1000,
                  child: const Column(children: fooBarTexts),
                ),
              ],
            ),
          ),
        ),
      );

      expect(find.ancestor(
        of: find.text('bar'),
        matching: find.byType(Row),
      ), findsOneWidget);
    });
  });

  group('CommonSemanticsFinders', () {
    final Widget semanticsTree = _boilerplate(
      Semantics(
        container: true,
        header: true,
        readOnly: true,
        onCopy: () {},
        onLongPress: () {},
        value: 'value1',
        hint: 'hint1',
        label: 'label1',
        child: Semantics(
          container: true,
          textField: true,
          onSetText: (_) { },
          onPaste: () { },
          onLongPress: () { },
          value: 'value2',
          hint: 'hint2',
          label: 'label2',
          child: Semantics(
            container: true,
            readOnly: true,
            onCopy: () {},
            value: 'value3',
            hint: 'hint3',
            label: 'label3',
            child: Semantics(
              container: true,
              readOnly: true,
              onLongPress: () { },
              value: 'value4',
              hint: 'hint4',
              label: 'label4',
              child: Semantics(
                container: true,
                onLongPress: () { },
                onCopy: () {},
                value: 'value5',
                hint: 'hint5',
                label: 'label5'
              ),
            ),
          )
        ),
      ),
    );

    group('ancestor', () {
      testWidgets('finds matching ancestor nodes', (WidgetTester tester) async {
        await tester.pumpWidget(semanticsTree);

        final FinderBase<SemanticsNode> finder = find.semantics.ancestor(
          of: find.semantics.byLabel('label4'),
          matching: find.semantics.byAction(SemanticsAction.copy),
        );

        expect(finder, findsExactly(2));
      });

      testWidgets('fails with descriptive message', (WidgetTester tester) async {
        late TestFailure failure;
        await tester.pumpWidget(semanticsTree);

        final FinderBase<SemanticsNode> finder = find.semantics.ancestor(
          of: find.semantics.byLabel('label4'),
          matching: find.semantics.byAction(SemanticsAction.copy),
        );

        try {
          expect(finder, findsExactly(3));
        } on TestFailure catch (e) {
          failure = e;
        }

        expect(failure.message, contains('Actual: _AncestorSemanticsFinder:<Found 2 SemanticsNodes with action "SemanticsAction.copy" that are ancestors of SemanticsNodes with label "label4"'));
      });
    });

    group('descendant', () {
      testWidgets('finds matching descendant nodes', (WidgetTester tester) async {
        await tester.pumpWidget(semanticsTree);

        final FinderBase<SemanticsNode> finder = find.semantics.descendant(
          of: find.semantics.byLabel('label4'),
          matching: find.semantics.byAction(SemanticsAction.copy),
        );

        expect(finder, findsOne);
      });

      testWidgets('fails with descriptive message', (WidgetTester tester) async {
        late TestFailure failure;
        await tester.pumpWidget(semanticsTree);

        final FinderBase<SemanticsNode> finder = find.semantics.descendant(
          of: find.semantics.byLabel('label4'),
          matching: find.semantics.byAction(SemanticsAction.copy),
        );

        try {
          expect(finder, findsNothing);
        } on TestFailure catch (e) {
          failure = e;
        }

        expect(failure.message, contains('Actual: _DescendantSemanticsFinder:<Found 1 SemanticsNode with action "SemanticsAction.copy" descending from SemanticsNode with label "label4"'));
      });
    });

    group('byPredicate', () {
      testWidgets('finds nodes matching given predicate', (WidgetTester tester) async {
        final RegExp replaceRegExp = RegExp(r'^[^\d]+');
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byPredicate(
          (SemanticsNode node) {
            final int labelNum = int.tryParse(node.label.replaceAll(replaceRegExp, '')) ?? -1;
            return labelNum > 1;
          },
        );

        expect(finder, findsExactly(4));
      });

      testWidgets('fails with default message', (WidgetTester tester) async {
        late TestFailure failure;
        final RegExp replaceRegExp = RegExp(r'^[^\d]+');
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byPredicate(
          (SemanticsNode node) {
            final int labelNum = int.tryParse(node.label.replaceAll(replaceRegExp, '')) ?? -1;
            return labelNum > 1;
          },
        );
        try {
          expect(finder, findsExactly(5));
        } on TestFailure catch (e) {
          failure = e;
        }

        expect(failure.message, contains('Actual: _PredicateSemanticsFinder:<Found 4 matching semantics predicate'));
      });

      testWidgets('fails with given message', (WidgetTester tester) async {
        late TestFailure failure;
        const String expected = 'custom error message';
        final RegExp replaceRegExp = RegExp(r'^[^\d]+');
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byPredicate(
          (SemanticsNode node) {
            final int labelNum = int.tryParse(node.label.replaceAll(replaceRegExp, '')) ?? -1;
            return labelNum > 1;
          },
          describeMatch: (_) => expected,
        );
        try {
          expect(finder, findsExactly(5));
        } on TestFailure catch (e) {
          failure = e;
        }

        expect(failure.message, contains(expected));
      });
    });

    group('byLabel', () {
      testWidgets('finds nodes with matching label using String', (WidgetTester tester) async {
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byLabel('label3');

        expect(finder, findsOne);
        expect(finder.found.first.label, 'label3');
      });

      testWidgets('finds nodes with matching label using RegEx', (WidgetTester tester) async {
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byLabel(RegExp('^label.*'));

        expect(finder, findsExactly(5));
        expect(finder.found.every((SemanticsNode node) => node.label.startsWith('label')), isTrue);
      });

      testWidgets('fails with descriptive message', (WidgetTester tester) async {
        late TestFailure failure;
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byLabel('label3');

        try {
          expect(finder, findsNothing);
        } on TestFailure catch (e) {
          failure = e;
        }

        expect(failure.message, contains('Actual: _PredicateSemanticsFinder:<Found 1 SemanticsNode with label "label3"'));
      });
    });

    group('byValue', () {
      testWidgets('finds nodes with matching value using String', (WidgetTester tester) async {
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byValue('value3');

        expect(finder, findsOne);
        expect(finder.found.first.value, 'value3');
      });

      testWidgets('finds nodes with matching value using RegEx', (WidgetTester tester) async {
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byValue(RegExp('^value.*'));

        expect(finder, findsExactly(5));
        expect(finder.found.every((SemanticsNode node) => node.value.startsWith('value')), isTrue);
      });

      testWidgets('fails with descriptive message', (WidgetTester tester) async {
        late TestFailure failure;
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byValue('value3');

        try {
          expect(finder, findsNothing);
        } on TestFailure catch (e) {
          failure = e;
        }

        expect(failure.message, contains('Actual: _PredicateSemanticsFinder:<Found 1 SemanticsNode with value "value3"'));
      });
    });

    group('byHint', () {
      testWidgets('finds nodes with matching hint using String', (WidgetTester tester) async {
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byHint('hint3');

        expect(finder, findsOne);
        expect(finder.found.first.hint, 'hint3');
      });

      testWidgets('finds nodes with matching hint using RegEx', (WidgetTester tester) async {
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byHint(RegExp('^hint.*'));

        expect(finder, findsExactly(5));
        expect(finder.found.every((SemanticsNode node) => node.hint.startsWith('hint')), isTrue);
      });

      testWidgets('fails with descriptive message', (WidgetTester tester) async {
        late TestFailure failure;
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byHint('hint3');

        try {
          expect(finder, findsNothing);
        } on TestFailure catch (e) {
          failure = e;
        }

        expect(failure.message, contains('Actual: _PredicateSemanticsFinder:<Found 1 SemanticsNode with hint "hint3"'));
      });
    });

    group('byAction', () {
      testWidgets('finds nodes with matching action', (WidgetTester tester) async {
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byAction(SemanticsAction.copy);

        expect(finder, findsExactly(3));
      });

      testWidgets('fails with descriptive message', (WidgetTester tester) async {
        late TestFailure failure;
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byAction(SemanticsAction.copy);

        try {
          expect(finder, findsExactly(4));
        } on TestFailure catch (e) {
          failure = e;
        }

        expect(failure.message, contains('Actual: _PredicateSemanticsFinder:<Found 3 SemanticsNodes with action "SemanticsAction.copy"'));
      });
    });

    group('byAnyAction', () {
      testWidgets('finds nodes with any matching actions', (WidgetTester tester) async {
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byAnyAction(<SemanticsAction>[
          SemanticsAction.paste,
          SemanticsAction.longPress,
        ]);

        expect(finder, findsExactly(4));
      });

      testWidgets('fails with descriptive message', (WidgetTester tester) async {
        late TestFailure failure;
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byAnyAction(<SemanticsAction>[
          SemanticsAction.paste,
          SemanticsAction.longPress,
        ]);

        try {
          expect(finder, findsExactly(5));
        } on TestFailure catch (e) {
          failure = e;
        }

        expect(failure.message, contains('Actual: _PredicateSemanticsFinder:<Found 4 SemanticsNodes with any of the following actions: [SemanticsAction.paste, SemanticsAction.longPress]:'));
      });
    });

    group('byFlag', () {
      testWidgets('finds nodes with matching flag', (WidgetTester tester) async {
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byFlag(SemanticsFlag.isReadOnly);

        expect(finder, findsExactly(3));
      });

      testWidgets('fails with descriptive message', (WidgetTester tester) async {
        late TestFailure failure;
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byFlag(SemanticsFlag.isReadOnly);

        try {
          expect(finder, findsExactly(4));
        } on TestFailure catch (e) {
          failure = e;
        }

        expect(failure.message, contains('_PredicateSemanticsFinder:<Found 3 SemanticsNodes with flag "SemanticsFlag.isReadOnly":'));
      });
    });

    group('byAnyFlag', () {
      testWidgets('finds nodes with any matching flag', (WidgetTester tester) async {
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byAnyFlag(<SemanticsFlag>[
          SemanticsFlag.isHeader,
          SemanticsFlag.isTextField,
        ]);

        expect(finder, findsExactly(2));
      });

      testWidgets('fails with descriptive message', (WidgetTester tester) async {
        late TestFailure failure;
        await tester.pumpWidget(semanticsTree);

        final SemanticsFinder finder = find.semantics.byAnyFlag(<SemanticsFlag>[
          SemanticsFlag.isHeader,
          SemanticsFlag.isTextField,
        ]);

        try {
          expect(finder, findsExactly(3));
        } on TestFailure catch (e) {
          failure = e;
        }

        expect(failure.message, contains('Actual: _PredicateSemanticsFinder:<Found 2 SemanticsNodes with any of the following flags: [SemanticsFlag.isHeader, SemanticsFlag.isTextField]:'));
      });
    });

    group('scrollable', () {
      testWidgets('can find node that can scroll up', (WidgetTester tester) async {
        final ScrollController controller = ScrollController();
        await tester.pumpWidget(MaterialApp(
          home: SingleChildScrollView(
            controller: controller,
            child: const SizedBox(width: 100, height: 1000),
          ),
        ));

        expect(find.semantics.scrollable(), containsSemantics(
          hasScrollUpAction: true,
          hasScrollDownAction: false,
        ));
      });

      testWidgets('can find node that can scroll down', (WidgetTester tester) async {
        final ScrollController controller = ScrollController(initialScrollOffset: 400);
        await tester.pumpWidget(MaterialApp(
          home: SingleChildScrollView(
            controller: controller,
            child: const SizedBox(width: 100, height: 1000),
          ),
        ));

        expect(find.semantics.scrollable(), containsSemantics(
          hasScrollUpAction: false,
          hasScrollDownAction: true,
        ));
      });

      testWidgets('can find node that can scroll left', (WidgetTester tester) async {
        final ScrollController controller = ScrollController();
        await tester.pumpWidget(MaterialApp(
          home: SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            controller: controller,
            child: const SizedBox(width: 1000, height: 100),
          ),
        ));

        expect(find.semantics.scrollable(), containsSemantics(
          hasScrollLeftAction: true,
          hasScrollRightAction: false,
        ));
      });

      testWidgets('can find node that can scroll right', (WidgetTester tester) async {
        final ScrollController controller = ScrollController(initialScrollOffset: 200);
        await tester.pumpWidget(MaterialApp(
          home: SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            controller: controller,
            child: const SizedBox(width: 1000, height: 100),
          ),
        ));

        expect(find.semantics.scrollable(), containsSemantics(
          hasScrollLeftAction: false,
          hasScrollRightAction: true,
        ));
      });

      testWidgets('can exclusively find node that scrolls horizontally', (WidgetTester tester) async {
        await tester.pumpWidget(const MaterialApp(
          home: Column(
            children: <Widget>[
              SingleChildScrollView(
                scrollDirection: Axis.horizontal,
                child: SizedBox(width: 1000, height: 100),
              ),
              Expanded(
                child: SingleChildScrollView(
                  child: SizedBox(width: 100, height: 1000),
                ),
              ),
            ],
          )
        ));

        expect(find.semantics.scrollable(axis: Axis.horizontal), findsOne);
      });

      testWidgets('can exclusively find node that scrolls vertically', (WidgetTester tester) async {
        await tester.pumpWidget(const MaterialApp(
          home: Column(
            children: <Widget>[
              SingleChildScrollView(
                scrollDirection: Axis.horizontal,
                child: SizedBox(width: 1000, height: 100),
              ),
              Expanded(
                child: SingleChildScrollView(
                  child: SizedBox(width: 100, height: 1000),
                ),
              ),
            ],
          )
        ));

        expect(find.semantics.scrollable(axis: Axis.vertical), findsOne);
      });
    });
  });

  group('FinderBase', () {
    group('describeMatch', () {
      test('is used for Finder and results', () {
        const String expected = 'Fake finder describe match';
        final _FakeFinder finder = _FakeFinder(describeMatchCallback: (_) {
          return expected;
        });

        expect(finder.evaluate().toString(), contains(expected));
        expect(finder.toString(describeSelf: true), contains(expected));
      });

      for (int i = 0; i < 4; i++) {
        test('gets expected plurality for $i when reporting results from find', () {
          final Plurality expected = switch (i) {
            0 => Plurality.zero,
            1 => Plurality.one,
            _ => Plurality.many,
          };
          late final Plurality actual;
          final _FakeFinder finder = _FakeFinder(
            describeMatchCallback: (Plurality plurality) {
              actual = plurality;
              return 'Fake description';
            },
            findInCandidatesCallback: (_) => Iterable<String>.generate(i, (int index) => index.toString()),
          );
          finder.evaluate().toString();

          expect(actual, expected);
        });

        test('gets expected plurality for $i when reporting results from toString', () {
          final Plurality expected = switch (i) {
            0 => Plurality.zero,
            1 => Plurality.one,
            _ => Plurality.many,
          };
          late final Plurality actual;
          final _FakeFinder finder = _FakeFinder(
            describeMatchCallback: (Plurality plurality) {
              actual = plurality;
              return 'Fake description';
            },
            findInCandidatesCallback: (_) => Iterable<String>.generate(i, (int index) => index.toString()),
          );
          finder.toString();

          expect(actual, expected);
        });

        test('always gets many when describing finder', () {
          const Plurality expected = Plurality.many;
          late final Plurality actual;
          final _FakeFinder finder = _FakeFinder(
            describeMatchCallback: (Plurality plurality) {
              actual = plurality;
              return 'Fake description';
            },
            findInCandidatesCallback: (_) => Iterable<String>.generate(i, (int index) => index.toString()),
          );
          finder.toString(describeSelf: true);

          expect(actual, expected);
        });
      }
    });

    test('findInCandidates gets allCandidates', () {
      final List<String> expected = <String>['Test1', 'Test2', 'Test3', 'Test4'];
      late final List<String> actual;
      final _FakeFinder finder = _FakeFinder(
        allCandidatesCallback: () => expected,
        findInCandidatesCallback: (Iterable<String> candidates) {
          actual = candidates.toList();
          return candidates;
        },
      );
      finder.evaluate();

      expect(actual, expected);
    });

    test('allCandidates calculated for each find', () {
      const int expectedCallCount = 3;
      int actualCallCount = 0;
      final _FakeFinder finder = _FakeFinder(
        allCandidatesCallback: () {
          actualCallCount++;
          return <String>['test'];
        },
      );
      for (int i = 0; i < expectedCallCount; i++) {
        finder.evaluate();
      }

      expect(actualCallCount, expectedCallCount);
    });

    test('allCandidates only called once while caching', () {
      int actualCallCount = 0;
      final _FakeFinder finder = _FakeFinder(
        allCandidatesCallback: () {
          actualCallCount++;
          return <String>['test'];
        },
      );
      finder.runCached(() {
        for (int i = 0; i < 5; i++) {
          finder.evaluate();
          finder.tryEvaluate();
          final FinderResult<String> _ = finder.found;
        }
      });

      expect(actualCallCount, 1);
    });

    group('tryFind', () {
      test('returns false if no results', () {
        final _FakeFinder finder = _FakeFinder(
          findInCandidatesCallback: (_) => <String>[],
        );

        expect(finder.tryEvaluate(), false);
      });

      test('returns true if results are available', () {
        final _FakeFinder finder = _FakeFinder(
          findInCandidatesCallback: (_) => <String>['Results'],
        );

        expect(finder.tryEvaluate(), true);
      });
    });

    group('found', () {
      test('throws before any calls to evaluate or tryEvaluate', () {
        final _FakeFinder finder = _FakeFinder();

        expect(finder.hasFound, false);
        expect(() => finder.found, throwsAssertionError);
      });

      test('has same results as evaluate after call to evaluate', () {
        final _FakeFinder finder = _FakeFinder();
        final FinderResult<String> expected = finder.evaluate();

        expect(finder.hasFound, true);
        expect(finder.found, expected);
      });

      test('has expected results after call to tryFind', () {
        final Iterable<String> expected = Iterable<String>.generate(10, (int i) => i.toString());
        final _FakeFinder finder = _FakeFinder(findInCandidatesCallback: (_) => expected);
        finder.tryEvaluate();


        expect(finder.hasFound, true);
        expect(finder.found, orderedEquals(expected));
      });
    });
  });
}

Widget _boilerplate(Widget child) {
  return Directionality(
    textDirection: TextDirection.ltr,
    child: child,
  );
}

class SimpleCustomSemanticsWidget extends LeafRenderObjectWidget {
  const SimpleCustomSemanticsWidget(this.label, {super.key});

  final String label;

  @override
  RenderObject createRenderObject(BuildContext context) =>
      SimpleCustomSemanticsRenderObject(label);
}

class SimpleCustomSemanticsRenderObject extends RenderBox {
  SimpleCustomSemanticsRenderObject(this.label);

  final String label;

  @override
  bool get sizedByParent => true;

  @override
  Size computeDryLayout(BoxConstraints constraints) {
    return constraints.smallest;
  }

  @override
  void describeSemanticsConfiguration(SemanticsConfiguration config) {
    super.describeSemanticsConfiguration(config);
    config
      ..label = label
      ..textDirection = TextDirection.ltr;
  }
}

class SimpleGenericWidget<T> extends StatelessWidget {
  const SimpleGenericWidget({required Widget child, super.key})
      : _child = child;

  final Widget _child;

  @override
  Widget build(BuildContext context) {
    return _child;
  }
}

/// Wraps [child] in [depth] layers of [SizedBox]
Widget _deepWidgetTree({required int depth, required Widget child}) {
  Widget tree = child;
  for (int i = 0; i < depth; i += 1) {
    tree = SizedBox(child: tree);
  }
  return tree;
}

class _FakeFinder extends FinderBase<String> {
  _FakeFinder({
    this.allCandidatesCallback,
    this.describeMatchCallback,
    this.findInCandidatesCallback,
  });

  final Iterable<String> Function()? allCandidatesCallback;
  final DescribeMatchCallback? describeMatchCallback;
  final Iterable<String> Function(Iterable<String> candidates)? findInCandidatesCallback;


  @override
  Iterable<String> get allCandidates {
    return allCandidatesCallback?.call() ?? <String>[
      'String 1', 'String 2', 'String 3',
    ];
  }

  @override
  String describeMatch(Plurality plurality) {
    return describeMatchCallback?.call(plurality) ?? switch (plurality) {
      Plurality.one => 'String',
      Plurality.many || Plurality.zero => 'Strings',
    };
  }

  @override
  Iterable<String> findInCandidates(Iterable<String> candidates) {
    return findInCandidatesCallback?.call(candidates) ?? candidates;
  }
}
