| // 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 'package:test/bootstrap/browser.dart'; |
| import 'package:test/test.dart'; |
| import 'package:ui/src/engine/browser_detection.dart'; |
| |
| import 'package:ui/src/engine/dom.dart'; |
| import 'package:ui/src/engine/initialization.dart'; |
| import 'package:ui/src/engine/text_editing/composition_aware_mixin.dart'; |
| import 'package:ui/src/engine/text_editing/text_editing.dart'; |
| |
| void main() { |
| internalBootstrapBrowserTest(() => testMain); |
| } |
| |
| class _MockWithCompositionAwareMixin with CompositionAwareMixin { |
| // These variables should be equal to their counterparts in CompositionAwareMixin. |
| // Separate so the counterparts in CompositionAwareMixin can be private. |
| static const String _kCompositionUpdate = 'compositionupdate'; |
| static const String _kCompositionStart = 'compositionstart'; |
| static const String _kCompositionEnd = 'compositionend'; |
| } |
| |
| DomHTMLInputElement get _inputElement { |
| return defaultTextEditingRoot.querySelectorAll('input').first as DomHTMLInputElement; |
| } |
| |
| GloballyPositionedTextEditingStrategy _enableEditingStrategy({ |
| required bool deltaModel, |
| void Function(EditingState?, TextEditingDeltaState?)? onChange, |
| }) { |
| final HybridTextEditing owner = HybridTextEditing(); |
| |
| owner.configuration = InputConfiguration(enableDeltaModel: deltaModel); |
| |
| final GloballyPositionedTextEditingStrategy editingStrategy = |
| GloballyPositionedTextEditingStrategy(owner); |
| |
| owner.debugTextEditingStrategyOverride = editingStrategy; |
| |
| editingStrategy.enable(owner.configuration!, onChange: onChange ?? (_, __) {}, onAction: (_) {}); |
| return editingStrategy; |
| } |
| |
| Future<void> testMain() async { |
| await initializeEngine(); |
| |
| const String fakeComposingText = 'ImComposingText'; |
| |
| group('$CompositionAwareMixin', () { |
| late TextEditingStrategy editingStrategy; |
| |
| setUp(() { |
| editingStrategy = _enableEditingStrategy(deltaModel: false); |
| }); |
| |
| tearDown(() { |
| editingStrategy.disable(); |
| }); |
| |
| group('composition end', () { |
| test('should reset composing text on handle composition end', () { |
| final _MockWithCompositionAwareMixin mockWithCompositionAwareMixin = |
| _MockWithCompositionAwareMixin(); |
| mockWithCompositionAwareMixin.composingText = fakeComposingText; |
| mockWithCompositionAwareMixin.addCompositionEventHandlers(_inputElement); |
| |
| _inputElement.dispatchEvent(createDomEvent('Event', _MockWithCompositionAwareMixin._kCompositionEnd)); |
| |
| expect(mockWithCompositionAwareMixin.composingText, null); |
| }); |
| }); |
| |
| group('composition start', () { |
| test('should reset composing text on handle composition start', () { |
| final _MockWithCompositionAwareMixin mockWithCompositionAwareMixin = |
| _MockWithCompositionAwareMixin(); |
| mockWithCompositionAwareMixin.composingText = fakeComposingText; |
| mockWithCompositionAwareMixin.addCompositionEventHandlers(_inputElement); |
| |
| _inputElement.dispatchEvent(createDomEvent('Event', _MockWithCompositionAwareMixin._kCompositionStart)); |
| |
| expect(mockWithCompositionAwareMixin.composingText, null); |
| }); |
| }); |
| |
| group('composition update', () { |
| test('should set composing text to event composing text', () { |
| const String fakeEventText = 'IAmComposingThis'; |
| final _MockWithCompositionAwareMixin mockWithCompositionAwareMixin = |
| _MockWithCompositionAwareMixin(); |
| mockWithCompositionAwareMixin.composingText = fakeComposingText; |
| mockWithCompositionAwareMixin.addCompositionEventHandlers(_inputElement); |
| |
| _inputElement.dispatchEvent(createDomCompositionEvent( |
| _MockWithCompositionAwareMixin._kCompositionUpdate, |
| <Object?, Object?>{ 'data': fakeEventText } |
| )); |
| |
| expect(mockWithCompositionAwareMixin.composingText, fakeEventText); |
| }); |
| }); |
| |
| group('determine composition state', () { |
| test('should return new composition state if valid new composition', () { |
| const int baseOffset = 100; |
| const String composingText = 'composeMe'; |
| |
| final EditingState editingState = EditingState( |
| extentOffset: baseOffset, |
| text: 'testing', |
| baseOffset: baseOffset, |
| ); |
| |
| final _MockWithCompositionAwareMixin mockWithCompositionAwareMixin = |
| _MockWithCompositionAwareMixin(); |
| mockWithCompositionAwareMixin.composingText = composingText; |
| |
| const int expectedComposingBase = baseOffset - composingText.length; |
| |
| expect( |
| mockWithCompositionAwareMixin.determineCompositionState(editingState), |
| editingState.copyWith( |
| composingBaseOffset: expectedComposingBase, |
| composingExtentOffset: expectedComposingBase + composingText.length)); |
| }); |
| }); |
| }); |
| |
| group('composing range', () { |
| late GloballyPositionedTextEditingStrategy editingStrategy; |
| |
| setUp(() { |
| editingStrategy = _enableEditingStrategy(deltaModel: false); |
| }); |
| |
| tearDown(() { |
| editingStrategy.disable(); |
| }); |
| |
| test('should be [0, compostionStrLength] on new composition', () { |
| const String composingText = 'hi'; |
| |
| _inputElement.dispatchEvent(createDomCompositionEvent( |
| _MockWithCompositionAwareMixin._kCompositionUpdate, |
| <Object?, Object?>{'data': composingText})); |
| |
| // Set the selection text. |
| _inputElement.value = composingText; |
| _inputElement.dispatchEvent(createDomEvent('Event', 'input')); |
| |
| expect( |
| editingStrategy.lastEditingState, |
| isA<EditingState>() |
| .having((EditingState editingState) => editingState.composingBaseOffset, |
| 'composingBaseOffset', 0) |
| .having((EditingState editingState) => editingState.composingExtentOffset, |
| 'composingExtentOffset', composingText.length)); |
| }); |
| |
| test( |
| 'should be [beforeComposingText - composingText, compostionStrLength] on composition in the middle of text', |
| () { |
| const String composingText = 'hi'; |
| const String beforeComposingText = 'beforeComposingText'; |
| const String afterComposingText = 'afterComposingText'; |
| |
| // Type in the text box, then move cursor to the middle. |
| _inputElement.value = '$beforeComposingText$afterComposingText'; |
| _inputElement.setSelectionRange(beforeComposingText.length, beforeComposingText.length); |
| |
| _inputElement.dispatchEvent(createDomCompositionEvent( |
| _MockWithCompositionAwareMixin._kCompositionUpdate, |
| <Object?, Object?>{ 'data': composingText } |
| )); |
| |
| // Flush editing state (since we did not compositionend). |
| _inputElement.dispatchEvent(createDomEvent('Event', 'input')); |
| |
| expect( |
| editingStrategy.lastEditingState, |
| isA<EditingState>() |
| .having((EditingState editingState) => editingState.composingBaseOffset, |
| 'composingBaseOffset', beforeComposingText.length - composingText.length) |
| .having((EditingState editingState) => editingState.composingExtentOffset, |
| 'composingExtentOffset', beforeComposingText.length)); |
| }); |
| }); |
| |
| group('Text Editing Delta Model', () { |
| late GloballyPositionedTextEditingStrategy editingStrategy; |
| |
| final StreamController<TextEditingDeltaState?> deltaStream = |
| StreamController<TextEditingDeltaState?>.broadcast(); |
| |
| setUp(() { |
| editingStrategy = _enableEditingStrategy( |
| deltaModel: true, |
| onChange: (_, TextEditingDeltaState? deltaState) => deltaStream.add(deltaState) |
| ); |
| }); |
| |
| tearDown(() { |
| editingStrategy.disable(); |
| }); |
| |
| test('should have newly entered composing characters', () async { |
| const String newComposingText = 'n'; |
| |
| editingStrategy.setEditingState(EditingState(text: newComposingText, baseOffset: 1, extentOffset: 1)); |
| |
| final Future<dynamic> containExpect = expectLater( |
| deltaStream.stream.first, |
| completion(isA<TextEditingDeltaState>() |
| .having((TextEditingDeltaState deltaState) => deltaState.composingOffset, 'composingOffset', 0) |
| .having((TextEditingDeltaState deltaState) => deltaState.composingExtent, 'composingExtent', newComposingText.length) |
| )); |
| |
| |
| _inputElement.dispatchEvent(createDomCompositionEvent( |
| _MockWithCompositionAwareMixin._kCompositionUpdate, |
| <Object?, Object?>{ 'data': newComposingText })); |
| |
| await containExpect; |
| }); |
| |
| test('should emit changed composition', () async { |
| const String newComposingCharsInOrder = 'hiCompose'; |
| |
| for (int currCharIndex = 0; currCharIndex < newComposingCharsInOrder.length; currCharIndex++) { |
| final String currComposingSubstr = newComposingCharsInOrder.substring(0, currCharIndex + 1); |
| |
| editingStrategy.setEditingState( |
| EditingState(text: currComposingSubstr, baseOffset: currCharIndex + 1, extentOffset: currCharIndex + 1) |
| ); |
| |
| final Future<dynamic> containExpect = expectLater( |
| deltaStream.stream.first, |
| completion(isA<TextEditingDeltaState>() |
| .having((TextEditingDeltaState deltaState) => deltaState.composingOffset, 'composingOffset', 0) |
| .having((TextEditingDeltaState deltaState) => deltaState.composingExtent, 'composingExtent', currCharIndex + 1) |
| )); |
| |
| _inputElement.dispatchEvent(createDomCompositionEvent( |
| _MockWithCompositionAwareMixin._kCompositionUpdate, |
| <Object?, Object?>{ 'data': currComposingSubstr })); |
| |
| await containExpect; |
| } |
| }, |
| // TODO(antholeole): This test fails on Firefox because of how it orders events; |
| // it's likely that this will be fixed by https://github.com/flutter/flutter/issues/105243. |
| // Until the refactor gets merged, this test should run on all other browsers to prevent |
| // regressions in the meantime. |
| skip: browserEngine == BrowserEngine.firefox); |
| }); |
| } |