blob: 3181043de848ccf448057ee54c69ff4efd9298bf [file] [log] [blame]
// Copyright 2013 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:async';
import 'dart:html' as html;
import 'dart:typed_data';
import 'package:test/test.dart';
import 'package:ui/src/engine.dart' show flutterViewEmbedder, toMatrix32;
import 'package:ui/src/engine/browser_detection.dart';
import 'package:ui/src/engine/host_node.dart';
import 'package:ui/src/engine/semantics.dart';
import 'package:ui/src/engine/util.dart';
import 'package:ui/src/engine/vector_math.dart';
import 'package:ui/ui.dart' as ui;
import '../../matchers.dart';
/// Gets the DOM host where the Flutter app is being rendered.
///
/// This function returns the correct host for the flutter app under testing,
/// so we don't have to hardcode html.document across the test. (The host of a
/// normal flutter app used to be html.document, but now that the app is wrapped
/// in a Shadow DOM, that's not the case anymore.)
HostNode get appHostNode => flutterViewEmbedder.glassPaneShadow!;
/// CSS style applied to the root of the semantics tree.
// TODO(yjbanov): this should be handled internally by [expectSemanticsTree].
// No need for every test to inject it.
final String rootSemanticStyle = browserEngine != BrowserEngine.edge
? 'filter: opacity(0%); color: rgba(0, 0, 0, 0)'
: 'color: rgba(0, 0, 0, 0); filter: opacity(0%)';
/// A convenience wrapper of the semantics API for building and inspecting the
/// semantics tree in unit tests.
class SemanticsTester {
SemanticsTester(this.owner);
final EngineSemanticsOwner owner;
final List<SemanticsNodeUpdate> _nodeUpdates = <SemanticsNodeUpdate>[];
/// Updates one semantics node.
///
/// Provides reasonable defaults for the missing attributes, and conveniences
/// for specifying flags, such as [isTextField].
SemanticsNodeUpdate updateNode({
required int id,
// Flags
int flags = 0,
bool? hasCheckedState,
bool? isChecked,
bool? isSelected,
bool? isButton,
bool? isLink,
bool? isTextField,
bool? isReadOnly,
bool? isFocusable,
bool? isFocused,
bool? hasEnabledState,
bool? isEnabled,
bool? isInMutuallyExclusiveGroup,
bool? isHeader,
bool? isObscured,
bool? scopesRoute,
bool? namesRoute,
bool? isHidden,
bool? isImage,
bool? isLiveRegion,
bool? hasToggledState,
bool? isToggled,
bool? hasImplicitScrolling,
bool? isMultiline,
bool? isSlider,
bool? isKeyboardKey,
// Actions
int actions = 0,
bool? hasTap,
bool? hasLongPress,
bool? hasScrollLeft,
bool? hasScrollRight,
bool? hasScrollUp,
bool? hasScrollDown,
bool? hasIncrease,
bool? hasDecrease,
bool? hasShowOnScreen,
bool? hasMoveCursorForwardByCharacter,
bool? hasMoveCursorBackwardByCharacter,
bool? hasSetSelection,
bool? hasCopy,
bool? hasCut,
bool? hasPaste,
bool? hasDidGainAccessibilityFocus,
bool? hasDidLoseAccessibilityFocus,
bool? hasCustomAction,
bool? hasDismiss,
bool? hasMoveCursorForwardByWord,
bool? hasMoveCursorBackwardByWord,
bool? hasSetText,
// Other attributes
int? maxValueLength,
int? currentValueLength,
int? textSelectionBase,
int? textSelectionExtent,
int? platformViewId,
int? scrollChildren,
int? scrollIndex,
double? scrollPosition,
double? scrollExtentMax,
double? scrollExtentMin,
double? elevation,
double? thickness,
ui.Rect? rect,
String? label,
List<ui.StringAttribute>? labelAttributes,
String? hint,
List<ui.StringAttribute>? hintAttributes,
String? value,
List<ui.StringAttribute>? valueAttributes,
String? increasedValue,
List<ui.StringAttribute>? increasedValueAttributes,
String? decreasedValue,
List<ui.StringAttribute>? decreasedValueAttributes,
String? tooltip,
ui.TextDirection? textDirection,
Float64List? transform,
Int32List? additionalActions,
List<SemanticsNodeUpdate>? children,
}) {
// Flags
if (hasCheckedState == true) {
flags |= ui.SemanticsFlag.hasCheckedState.index;
}
if (isChecked == true) {
flags |= ui.SemanticsFlag.isChecked.index;
}
if (isSelected == true) {
flags |= ui.SemanticsFlag.isSelected.index;
}
if (isButton == true) {
flags |= ui.SemanticsFlag.isButton.index;
}
if (isLink == true) {
flags |= ui.SemanticsFlag.isLink.index;
}
if (isTextField == true) {
flags |= ui.SemanticsFlag.isTextField.index;
}
if (isReadOnly == true) {
flags |= ui.SemanticsFlag.isReadOnly.index;
}
if (isFocusable == true) {
flags |= ui.SemanticsFlag.isFocusable.index;
}
if (isFocused == true) {
flags |= ui.SemanticsFlag.isFocused.index;
}
if (hasEnabledState == true) {
flags |= ui.SemanticsFlag.hasEnabledState.index;
}
if (isEnabled == true) {
flags |= ui.SemanticsFlag.isEnabled.index;
}
if (isInMutuallyExclusiveGroup == true) {
flags |= ui.SemanticsFlag.isInMutuallyExclusiveGroup.index;
}
if (isHeader == true) {
flags |= ui.SemanticsFlag.isHeader.index;
}
if (isObscured == true) {
flags |= ui.SemanticsFlag.isObscured.index;
}
if (scopesRoute == true) {
flags |= ui.SemanticsFlag.scopesRoute.index;
}
if (namesRoute == true) {
flags |= ui.SemanticsFlag.namesRoute.index;
}
if (isHidden == true) {
flags |= ui.SemanticsFlag.isHidden.index;
}
if (isImage == true) {
flags |= ui.SemanticsFlag.isImage.index;
}
if (isLiveRegion == true) {
flags |= ui.SemanticsFlag.isLiveRegion.index;
}
if (hasToggledState == true) {
flags |= ui.SemanticsFlag.hasToggledState.index;
}
if (isToggled == true) {
flags |= ui.SemanticsFlag.isToggled.index;
}
if (hasImplicitScrolling == true) {
flags |= ui.SemanticsFlag.hasImplicitScrolling.index;
}
if (isMultiline == true) {
flags |= ui.SemanticsFlag.isMultiline.index;
}
if (isSlider == true) {
flags |= ui.SemanticsFlag.isSlider.index;
}
if (isKeyboardKey == true) {
flags |= ui.SemanticsFlag.isKeyboardKey.index;
}
// Actions
if (hasTap == true) {
actions |= ui.SemanticsAction.tap.index;
}
if (hasLongPress == true) {
actions |= ui.SemanticsAction.longPress.index;
}
if (hasScrollLeft == true) {
actions |= ui.SemanticsAction.scrollLeft.index;
}
if (hasScrollRight == true) {
actions |= ui.SemanticsAction.scrollRight.index;
}
if (hasScrollUp == true) {
actions |= ui.SemanticsAction.scrollUp.index;
}
if (hasScrollDown == true) {
actions |= ui.SemanticsAction.scrollDown.index;
}
if (hasIncrease == true) {
actions |= ui.SemanticsAction.increase.index;
}
if (hasDecrease == true) {
actions |= ui.SemanticsAction.decrease.index;
}
if (hasShowOnScreen == true) {
actions |= ui.SemanticsAction.showOnScreen.index;
}
if (hasMoveCursorForwardByCharacter == true) {
actions |= ui.SemanticsAction.moveCursorForwardByCharacter.index;
}
if (hasMoveCursorBackwardByCharacter == true) {
actions |= ui.SemanticsAction.moveCursorBackwardByCharacter.index;
}
if (hasSetSelection == true) {
actions |= ui.SemanticsAction.setSelection.index;
}
if (hasCopy == true) {
actions |= ui.SemanticsAction.copy.index;
}
if (hasCut == true) {
actions |= ui.SemanticsAction.cut.index;
}
if (hasPaste == true) {
actions |= ui.SemanticsAction.paste.index;
}
if (hasDidGainAccessibilityFocus == true) {
actions |= ui.SemanticsAction.didGainAccessibilityFocus.index;
}
if (hasDidLoseAccessibilityFocus == true) {
actions |= ui.SemanticsAction.didLoseAccessibilityFocus.index;
}
if (hasCustomAction == true) {
actions |= ui.SemanticsAction.customAction.index;
}
if (hasDismiss == true) {
actions |= ui.SemanticsAction.dismiss.index;
}
if (hasMoveCursorForwardByWord == true) {
actions |= ui.SemanticsAction.moveCursorForwardByWord.index;
}
if (hasMoveCursorBackwardByWord == true) {
actions |= ui.SemanticsAction.moveCursorBackwardByWord.index;
}
if (hasSetText == true) {
actions |= ui.SemanticsAction.setText.index;
}
// Other attributes
ui.Rect childRect(SemanticsNodeUpdate child) {
return transformRect(Matrix4.fromFloat32List(child.transform), child.rect);
}
// If a rect is not provided, generate one than covers all children.
ui.Rect effectiveRect = rect ?? ui.Rect.zero;
if (children != null && children.isNotEmpty) {
effectiveRect = childRect(children.first);
for (final SemanticsNodeUpdate child in children.skip(1)) {
effectiveRect = effectiveRect.expandToInclude(childRect(child));
}
}
final Int32List childIds = Int32List(children?.length ?? 0);
if (children != null) {
for (int i = 0; i < children.length; i++) {
childIds[i] = children[i].id;
}
}
final SemanticsNodeUpdate update = SemanticsNodeUpdate(
id: id,
flags: flags,
actions: actions,
maxValueLength: maxValueLength ?? 0,
currentValueLength: currentValueLength ?? 0,
textSelectionBase: textSelectionBase ?? 0,
textSelectionExtent: textSelectionExtent ?? 0,
platformViewId: platformViewId ?? 0,
scrollChildren: scrollChildren ?? 0,
scrollIndex: scrollIndex ?? 0,
scrollPosition: scrollPosition ?? 0,
scrollExtentMax: scrollExtentMax ?? 0,
scrollExtentMin: scrollExtentMin ?? 0,
rect: effectiveRect,
label: label ?? '',
labelAttributes: labelAttributes ?? const <ui.StringAttribute>[],
hint: hint ?? '',
hintAttributes: hintAttributes ?? const <ui.StringAttribute>[],
value: value ?? '',
valueAttributes: valueAttributes ?? const <ui.StringAttribute>[],
increasedValue: increasedValue ?? '',
increasedValueAttributes: increasedValueAttributes ?? const <ui.StringAttribute>[],
decreasedValue: decreasedValue ?? '',
decreasedValueAttributes: decreasedValueAttributes ?? const <ui.StringAttribute>[],
tooltip: tooltip ?? '',
transform: transform != null ? toMatrix32(transform) : Matrix4.identity().storage,
elevation: elevation ?? 0,
thickness: thickness ?? 0,
childrenInTraversalOrder: childIds,
childrenInHitTestOrder: childIds,
additionalActions: additionalActions ?? Int32List(0),
);
_nodeUpdates.add(update);
return update;
}
/// Updates the HTML tree from semantics updates accumulated by this builder.
///
/// This builder forgets previous updates and may be reused in future updates.
Map<int, SemanticsObject> apply() {
owner.updateSemantics(SemanticsUpdate(nodeUpdates: _nodeUpdates));
_nodeUpdates.clear();
return owner.debugSemanticsTree!;
}
/// Locates the semantics object with the given [id].
SemanticsObject getSemanticsObject(int id) {
return owner.debugSemanticsTree![id]!;
}
/// Locates the role manager of the semantics object with the give [id].
RoleManager? getRoleManager(int id, Role role) {
return getSemanticsObject(id).debugRoleManagerFor(role);
}
/// Locates the [TextField] role manager of the semantics object with the give [id].
TextField getTextField(int id) {
return getRoleManager(id, Role.textField)! as TextField;
}
}
/// Verifies the HTML structure of the current semantics tree.
void expectSemanticsTree(String semanticsHtml) {
const List<String> ignoredAttributes = <String>['pointer-events'];
expect(
canonicalizeHtml(appHostNode.querySelector('flt-semantics')!.outerHtml!, ignoredAttributes: ignoredAttributes),
canonicalizeHtml(semanticsHtml),
);
}
/// Finds the first HTML element in the semantics tree used for scrolling.
html.Element? findScrollable() {
return appHostNode.querySelectorAll('flt-semantics').firstWhereOrNull(
(html.Element element) {
return element.style.overflow == 'hidden' ||
element.style.overflowY == 'scroll' ||
element.style.overflowX == 'scroll';
},
);
}
/// Logs semantics actions dispatched to [ui.window].
class SemanticsActionLogger {
late StreamController<int> _idLogController;
late StreamController<ui.SemanticsAction> _actionLogController;
/// Semantics object ids that dispatched the actions.
Stream<int> get idLog => _idLog;
late Stream<int> _idLog;
/// The actions that were dispatched to [ui.window].
Stream<ui.SemanticsAction> get actionLog => _actionLog;
late Stream<ui.SemanticsAction> _actionLog;
SemanticsActionLogger() {
_idLogController = StreamController<int>();
_actionLogController = StreamController<ui.SemanticsAction>();
_idLog = _idLogController.stream.asBroadcastStream();
_actionLog = _actionLogController.stream.asBroadcastStream();
// The browser kicks us out of the test zone when the browser event happens.
// We memorize the test zone so we can call expect when the callback is
// fired.
final Zone testZone = Zone.current;
ui.window.onSemanticsAction =
(int id, ui.SemanticsAction action, ByteData? args) {
_idLogController.add(id);
_actionLogController.add(action);
testZone.run(() {
expect(args, null);
});
};
}
}