// 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:collection'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'semantics_tester.dart'; typedef TraversalTestFunction = Future<void> Function(TraversalTester tester); const Size tenByTen = Size(10.0, 10.0); void main() { setUp(() { debugResetSemanticsIdCounter(); }); void testTraversal(String description, TraversalTestFunction testFunction) { testWidgets(description, (WidgetTester tester) async { final TraversalTester traversalTester = TraversalTester(tester); await testFunction(traversalTester); traversalTester.dispose(); }); } // ┌───┐ ┌───┐ // │ A │>│ B │ // └───┘ └───┘ testTraversal('Semantics traverses horizontally left-to-right', (TraversalTester tester) async { await tester.test( textDirection: TextDirection.ltr, children: <String, Rect>{ 'A': Offset.zero & tenByTen, 'B': const Offset(20.0, 0.0) & tenByTen, }, expectedTraversal: 'A B', ); }); // ┌───┐ ┌───┐ // │ A │<│ B │ // └───┘ └───┘ testTraversal('Semantics traverses horizontally right-to-left', (TraversalTester tester) async { await tester.test( textDirection: TextDirection.rtl, children: <String, Rect>{ 'A': Offset.zero & tenByTen, 'B': const Offset(20.0, 0.0) & tenByTen, }, expectedTraversal: 'B A', ); }); // ┌───┐ // │ A │ // └───┘ // V // ┌───┐ // │ B │ // └───┘ testTraversal('Semantics traverses vertically top-to-bottom', (TraversalTester tester) async { for (final TextDirection textDirection in TextDirection.values) { await tester.test( textDirection: textDirection, children: <String, Rect>{ 'A': Offset.zero & tenByTen, 'B': const Offset(0.0, 20.0) & tenByTen, }, expectedTraversal: 'A B', ); } }); // ┌───┐ ┌───┐ // │ A │>│ B │ // └───┘ └───┘ // ┌─────┘ // V // ┌───┐ ┌───┐ // │ C │>│ D │ // └───┘ └───┘ testTraversal('Semantics traverses a grid left-to-right', (TraversalTester tester) async { await tester.test( textDirection: TextDirection.ltr, children: <String, Rect>{ 'A': Offset.zero & tenByTen, 'B': const Offset(20.0, 0.0) & tenByTen, 'C': const Offset(0.0, 20.0) & tenByTen, 'D': const Offset(20.0, 20.0) & tenByTen, }, expectedTraversal: 'A B C D', ); }); // ┌───┐ ┌───┐ // │ A │<│ B │ // └───┘ └───┘ // └─────┐ // V // ┌───┐ ┌───┐ // │ C │<│ D │ // └───┘ └───┘ testTraversal('Semantics traverses a grid right-to-left', (TraversalTester tester) async { await tester.test( textDirection: TextDirection.rtl, children: <String, Rect>{ 'A': Offset.zero & tenByTen, 'B': const Offset(20.0, 0.0) & tenByTen, 'C': const Offset(0.0, 20.0) & tenByTen, 'D': const Offset(20.0, 20.0) & tenByTen, }, expectedTraversal: 'B A D C', ); }); // ┌───┐ ┌───┐ // │ A │ │ C │ // └───┘<->┌───┐<->└───┘ // │ B │ // └───┘ testTraversal('Semantics traverses vertically overlapping nodes horizontally', (TraversalTester tester) async { final Map<String, Rect> children = <String, Rect>{ 'A': Offset.zero & tenByTen, 'B': const Offset(20.0, 5.0) & tenByTen, 'C': const Offset(40.0, 0.0) & tenByTen, }; await tester.test( textDirection: TextDirection.ltr, children: children, expectedTraversal: 'A B C', ); await tester.test( textDirection: TextDirection.rtl, children: children, expectedTraversal: 'C B A', ); }); // LTR: // ┌───┐ ┌───┐ ┌───┐ ┌───┐ // │ A │>│ B │>│ C │>│ D │ // └───┘ └───┘ └───┘ └───┘ // ┌─────────────────┘ // V // ┌───┐ ┌─────────┐ ┌───┐ // │ E │>│ │>│ G │ // └───┘ │ F │ └───┘ // ┌───|─────────|───┘ // ┌───┐ │ │ ┌───┐ // │ H │─|─────────|>│ I │ // └───┘ └─────────┘ └───┘ // ┌─────────────────┘ // V // ┌───┐ ┌───┐ ┌───┐ ┌───┐ // │ J │>│ K │>│ L │>│ M │ // └───┘ └───┘ └───┘ └───┘ // // RTL: // ┌───┐ ┌───┐ ┌───┐ ┌───┐ // │ A │<│ B │<│ C │<│ D │ // └───┘ └───┘ └───┘ └───┘ // └─────────────────┐ // V // ┌───┐ ┌─────────┐ ┌───┐ // │ E │<│ │<│ G │ // └───┘ │ F │ └───┘ // └──|─────────|────┐ // ┌───┐ │ │ ┌───┐ // │ H │<|─────────|─│ I │ // └───┘ └─────────┘ └───┘ // └─────────────────┐ // V // ┌───┐ ┌───┐ ┌───┐ ┌───┐ // │ J │<│ K │<│ L │<│ M │ // └───┘ └───┘ └───┘ └───┘ testTraversal('Semantics traverses vertical groups, then horizontal groups, then knots', (TraversalTester tester) async { final Map<String, Rect> children = <String, Rect>{ 'A': Offset.zero & tenByTen, 'B': const Offset(20.0, 0.0) & tenByTen, 'C': const Offset(40.0, 0.0) & tenByTen, 'D': const Offset(60.0, 0.0) & tenByTen, 'E': const Offset(0.0, 20.0) & tenByTen, 'F': const Offset(20.0, 20.0) & (tenByTen * 2.0), 'G': const Offset(60.0, 20.0) & tenByTen, 'H': const Offset(0.0, 40.0) & tenByTen, 'I': const Offset(60.0, 40.0) & tenByTen, 'J': const Offset(0.0, 60.0) & tenByTen, 'K': const Offset(20.0, 60.0) & tenByTen, 'L': const Offset(40.0, 60.0) & tenByTen, 'M': const Offset(60.0, 60.0) & tenByTen, }; await tester.test( textDirection: TextDirection.ltr, children: children, expectedTraversal: 'A B C D E F G H I J K L M', ); await tester.test( textDirection: TextDirection.rtl, children: children, expectedTraversal: 'D C B A G F E I H M L K J', ); }); // The following test tests traversal of the simplest "knot", which is two // nodes overlapping both vertically and horizontally. For example: // // ┌─────────┐ // │ │ // │ A │ // │ ┌───┼─────┐ // │ │ │ │ // └─────┼───┘ │ // │ B │ // │ │ // └─────────┘ // // The outcome depends on the relative positions of the centers of `Rect`s of // their respective boxes, specifically the direction (i.e. angle) of the // vector pointing from A to B. We test different angles, one for each octant: // // -3π/4 -π/2 -π/4 // ╲ │ ╱ // ╲ 1│2 ╱ // ╲ │ ╱ // i=0 ╲│╱ 3 // π ──────┼────── 0 // 7 ╱│╲ 4 // ╱ │ ╲ // ╱ 6│5 ╲ // ╱ │ ╲ // 3π/4 π/2 π/4 // // For LTR, angles falling into octants 3, 4, 5, and 6, produce A -> B, all // others produce B -> A. // // For RTL, angles falling into octants 5, 6, 7, and 0, produce A -> B, all // others produce B -> A. testTraversal('Semantics sorts knots', (TraversalTester tester) async { const double start = -math.pi + math.pi / 8.0; for (int i = 0; i < 8; i += 1) { final double angle = start + i.toDouble() * math.pi / 4.0; // These values should be truncated so that double precision rounding // issues won't impact the heights/widths and throw off the traversal // ordering. final double dx = (math.cos(angle) * 15.0) / 10.0; final double dy = (math.sin(angle) * 15.0) / 10.0; final Map<String, Rect> children = <String, Rect>{ 'A': const Offset(10.0, 10.0) & tenByTen, 'B': Offset(10.0 + dx, 10.0 + dy) & tenByTen, }; try { await tester.test( textDirection: TextDirection.ltr, children: children, expectedTraversal: 3 <= i && i <= 6 ? 'A B' : 'B A', ); await tester.test( textDirection: TextDirection.rtl, children: children, expectedTraversal: 1 <= i && i <= 4 ? 'B A' : 'A B', ); } catch (error) { fail( 'Test failed with i == $i, angle == ${angle / math.pi}π\n' '$error' ); } } }); } class TraversalTester { TraversalTester(this.tester) : semantics = SemanticsTester(tester); final WidgetTester tester; final SemanticsTester semantics; Future<void> test({ required TextDirection textDirection, required Map<String, Rect> children, required String expectedTraversal, }) async { assert(children is LinkedHashMap); await tester.pumpWidget( Container( child: Directionality( textDirection: textDirection, child: Semantics( textDirection: textDirection, child: CustomMultiChildLayout( delegate: TestLayoutDelegate(children), children: children.keys.map<Widget>((String label) { return LayoutId( id: label, child: Semantics( container: true, explicitChildNodes: true, label: label, child: SizedBox( width: children[label]!.width, height: children[label]!.height, ), ), ); }).toList(), ), ), ), ), ); expect(semantics, hasSemantics( TestSemantics.root( children: <TestSemantics>[ TestSemantics.rootChild( textDirection: textDirection, children: expectedTraversal.split(' ').map<TestSemantics>((String label) { return TestSemantics( label: label, ); }).toList(), ), ], ), ignoreTransform: true, ignoreRect: true, ignoreId: true, childOrder: DebugSemanticsDumpOrder.traversalOrder, )); } void dispose() { semantics.dispose(); } } class TestLayoutDelegate extends MultiChildLayoutDelegate { TestLayoutDelegate(this.children); final Map<String, Rect> children; @override void performLayout(Size size) { children.forEach((String label, Rect rect) { layoutChild(label, BoxConstraints.loose(size)); positionChild(label, rect.topLeft); }); } @override bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) => oldDelegate == this; }