// 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 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; class _ConsistentTextRangeImplementationMatcher extends Matcher { _ConsistentTextRangeImplementationMatcher(int length) : range = TextRange(start: -1, end: length + 1), assert(length >= 0); final TextRange range; @override Description describe(Description description) { return description.add( 'The implementation of TextBoundary.getTextBoundaryAt is consistent with its other methods.', ); } @override Description describeMismatch( dynamic item, Description mismatchDescription, Map matchState, bool verbose, ) { final boundary = matchState['textBoundary'] as TextBoundary; final position = matchState['position'] as int; final int leading = boundary.getLeadingTextBoundaryAt(position) ?? -1; final int trailing = boundary.getTrailingTextBoundaryAt(position) ?? -1; return mismatchDescription.add( 'at position $position, expected ${TextRange(start: leading, end: trailing)} but got ${boundary.getTextBoundaryAt(position)}', ); } @override bool matches(dynamic item, Map matchState) { for (int i = range.start; i <= range.end; i++) { final int? leading = (item as TextBoundary).getLeadingTextBoundaryAt(i); final int? trailing = item.getTrailingTextBoundaryAt(i); final TextRange boundary = item.getTextBoundaryAt(i); final bool consistent = boundary.start == (leading ?? -1) && boundary.end == (trailing ?? -1); if (!consistent) { matchState['textBoundary'] = item; matchState['position'] = i; return false; } } return true; } } Matcher _hasConsistentTextRangeImplementationWithinRange(int length) => _ConsistentTextRangeImplementationMatcher(length); void main() { test('Character boundary works', () { const boundary = CharacterBoundary('abc'); expect(boundary, _hasConsistentTextRangeImplementationWithinRange(3)); expect(boundary.getLeadingTextBoundaryAt(-1), null); expect(boundary.getTrailingTextBoundaryAt(-1), 0); expect(boundary.getLeadingTextBoundaryAt(0), 0); expect(boundary.getTrailingTextBoundaryAt(0), 1); expect(boundary.getLeadingTextBoundaryAt(1), 1); expect(boundary.getTrailingTextBoundaryAt(1), 2); expect(boundary.getLeadingTextBoundaryAt(2), 2); expect(boundary.getTrailingTextBoundaryAt(2), 3); expect(boundary.getLeadingTextBoundaryAt(3), 3); expect(boundary.getTrailingTextBoundaryAt(3), null); expect(boundary.getLeadingTextBoundaryAt(4), 3); expect(boundary.getTrailingTextBoundaryAt(4), null); }); test('Character boundary works with grapheme', () { const text = 'a❄︎c'; const boundary = CharacterBoundary(text); expect(boundary, _hasConsistentTextRangeImplementationWithinRange(text.length)); expect(boundary.getLeadingTextBoundaryAt(-1), null); expect(boundary.getTrailingTextBoundaryAt(-1), 0); expect(boundary.getLeadingTextBoundaryAt(0), 0); expect(boundary.getTrailingTextBoundaryAt(0), 1); // The `❄` takes two character length. expect(boundary.getLeadingTextBoundaryAt(1), 1); expect(boundary.getTrailingTextBoundaryAt(1), 3); expect(boundary.getLeadingTextBoundaryAt(2), 1); expect(boundary.getTrailingTextBoundaryAt(2), 3); expect(boundary.getLeadingTextBoundaryAt(3), 3); expect(boundary.getTrailingTextBoundaryAt(3), 4); expect(boundary.getLeadingTextBoundaryAt(text.length), text.length); expect(boundary.getTrailingTextBoundaryAt(text.length), null); }); test('wordBoundary.moveByWordBoundary', () { const text = 'ABC ABC\n' // [0, 10) 'AÁ Á\n' // [10, 20) ' \n' // [20, 30) 'ABC!!!ABC\n' // [30, 40) ' !ABC !!\n' // [40, 50) 'A 𑗋𑗋 A\n'; // [50, 60) final textPainter = TextPainter() ..textDirection = TextDirection.ltr ..text = const TextSpan(text: text) ..layout(); final TextBoundary boundary = textPainter.wordBoundaries.moveByWordBoundary; // 4 points to the 2nd whitespace in the first line. // Don't break between horizontal spaces and letters/numbers. expect(boundary.getLeadingTextBoundaryAt(4), 0); expect(boundary.getTrailingTextBoundaryAt(4), 9); // Works when words are starting/ending with a combining diacritical mark. expect(boundary.getLeadingTextBoundaryAt(14), 10); expect(boundary.getTrailingTextBoundaryAt(14), 19); // Do break before and after newlines. expect(boundary.getLeadingTextBoundaryAt(24), 20); expect(boundary.getTrailingTextBoundaryAt(24), 29); // Do not break on punctuations. expect(boundary.getLeadingTextBoundaryAt(34), 30); expect(boundary.getTrailingTextBoundaryAt(34), 39); // Ok to break if next to punctuations or separating spaces. expect(boundary.getLeadingTextBoundaryAt(44), 43); expect(boundary.getTrailingTextBoundaryAt(44), 46); // 44 points to a low surrogate of a punctuation. expect(boundary.getLeadingTextBoundaryAt(54), 50); expect(boundary.getTrailingTextBoundaryAt(54), 59); }); test('line boundary works', () { final boundary = LineBoundary(TestTextLayoutMetrics()); expect(boundary.getLeadingTextBoundaryAt(3), TestTextLayoutMetrics.lineAt3.start); expect(boundary.getTrailingTextBoundaryAt(3), TestTextLayoutMetrics.lineAt3.end); expect(boundary.getTextBoundaryAt(3), TestTextLayoutMetrics.lineAt3); }); group('paragraph boundary', () { test('works for simple cases', () { const textA = 'abcd efg hi\njklmno\npqrstuv'; const boundaryA = ParagraphBoundary(textA); // Position enclosed inside of paragraph, 'abcd efg h|i\n'. const position = 10; // The range includes the line terminator. expect(boundaryA.getLeadingTextBoundaryAt(position), 0); expect(boundaryA.getTrailingTextBoundaryAt(position), 12); // This text includes a carriage return followed by a line feed. const textB = 'abcd efg hi\r\njklmno\npqrstuv'; const boundaryB = ParagraphBoundary(textB); expect(boundaryB.getLeadingTextBoundaryAt(position), 0); expect(boundaryB.getTrailingTextBoundaryAt(position), 13); const textF = 'Now is the time for\n' // 20 'all good people\n' // 20 + 16 => 36 'to come to the aid\n' // 36 + 19 => 55 'of their country.'; // 55 + 17 => 72 const boundaryF = ParagraphBoundary(textF); const positionF = 11; expect(boundaryF.getLeadingTextBoundaryAt(positionF), 0); expect(boundaryF.getTrailingTextBoundaryAt(positionF), 20); }); test('works for consecutive line terminators involving CRLF', () { const textI = 'Now is the time for\n' // 20 'all good people\n\r\n' // 20 + 16 => 38 'to come to the aid\n' // 38 + 19 => 57 'of their country.'; // 57 + 17 => 74 const boundaryI = ParagraphBoundary(textI); const positionI = 56; // \n at the end of the third line. const positionJ = 38; // t at beginning of third line. const positionK = 37; // \n at end of second line. expect(boundaryI.getLeadingTextBoundaryAt(positionI), 38); expect(boundaryI.getTrailingTextBoundaryAt(positionI), 57); expect(boundaryI.getLeadingTextBoundaryAt(positionJ), 38); expect(boundaryI.getTrailingTextBoundaryAt(positionJ), 57); expect(boundaryI.getLeadingTextBoundaryAt(positionK), 36); expect(boundaryI.getTrailingTextBoundaryAt(positionK), 38); }); test('works for consecutive line terminators', () { const textI = 'Now is the time for\n' // 20 'all good people\n\n' // 20 + 16 => 37 'to come to the aid\n' // 37 + 19 => 56 'of their country.'; // 56 + 17 => 73 const boundaryI = ParagraphBoundary(textI); const positionI = 55; // \n at the end of the third line. const positionJ = 37; // t at beginning of third line. const positionK = 36; // \n at end of second line. expect(boundaryI.getLeadingTextBoundaryAt(positionI), 37); expect(boundaryI.getTrailingTextBoundaryAt(positionI), 56); expect(boundaryI.getLeadingTextBoundaryAt(positionJ), 37); expect(boundaryI.getTrailingTextBoundaryAt(positionJ), 56); expect(boundaryI.getLeadingTextBoundaryAt(positionK), 36); expect(boundaryI.getTrailingTextBoundaryAt(positionK), 37); }); test('leading boundary works for consecutive CRLF', () { // This text includes multiple consecutive carriage returns followed by line feeds (CRLF). const textH = 'abcd efg hi\r\n\r\n\r\n\r\n\r\n\r\n\r\n\n\n\n\n\njklmno\npqrstuv'; const boundaryH = ParagraphBoundary(textH); const positionH = 18; expect(boundaryH.getLeadingTextBoundaryAt(positionH), 17); expect(boundaryH.getTrailingTextBoundaryAt(positionH), 19); }); test('trailing boundary works for consecutive CRLF', () { // This text includes multiple consecutive carriage returns followed by line feeds (CRLF). const textG = 'abcd efg hi\r\n\n\n\n\n\n\r\n\r\n\r\n\r\n\n\n\n\n\njklmno\npqrstuv'; const boundaryG = ParagraphBoundary(textG); const positionG = 18; expect(boundaryG.getLeadingTextBoundaryAt(positionG), 18); expect(boundaryG.getTrailingTextBoundaryAt(positionG), 20); }); test('works when position is between two CRLF', () { const textE = 'abcd efg hi\r\nhello\r\n\n'; const boundaryE = ParagraphBoundary(textE); // Position enclosed inside of paragraph, 'abcd efg hi\r\nhello\r\n\n'. const positionE = 16; expect(boundaryE.getLeadingTextBoundaryAt(positionE), 13); expect(boundaryE.getTrailingTextBoundaryAt(positionE), 20); }); test('works for multiple consecutive line terminators', () { // This text includes multiple consecutive line terminators. const textC = 'abcd efg hi\r\n\n\n\n\n\n\n\n\n\n\n\njklmno\npqrstuv'; const boundaryC = ParagraphBoundary(textC); // Position enclosed inside of paragraph, 'abcd efg hi\r\n\n\n\n\n\n|\n\n\n\n\n\njklmno\npqrstuv'. const positionC = 18; expect(boundaryC.getLeadingTextBoundaryAt(positionC), 18); expect(boundaryC.getTrailingTextBoundaryAt(positionC), 19); const textD = 'abcd efg hi\r\n\n\n\n'; const boundaryD = ParagraphBoundary(textD); // Position enclosed inside of paragraph, 'abcd efg hi\r\n\n|\n\n'. const positionD = 14; expect(boundaryD.getLeadingTextBoundaryAt(positionD), 14); expect(boundaryD.getTrailingTextBoundaryAt(positionD), 15); }); }); test('document boundary works', () { const text = 'abcd efg hi\njklmno\npqrstuv'; const boundary = DocumentBoundary(text); expect(boundary, _hasConsistentTextRangeImplementationWithinRange(text.length)); expect(boundary.getLeadingTextBoundaryAt(-1), null); expect(boundary.getTrailingTextBoundaryAt(-1), text.length); expect(boundary.getLeadingTextBoundaryAt(0), 0); expect(boundary.getTrailingTextBoundaryAt(0), text.length); expect(boundary.getLeadingTextBoundaryAt(10), 0); expect(boundary.getTrailingTextBoundaryAt(10), text.length); expect(boundary.getLeadingTextBoundaryAt(text.length), 0); expect(boundary.getTrailingTextBoundaryAt(text.length), null); expect(boundary.getLeadingTextBoundaryAt(text.length + 1), 0); expect(boundary.getTrailingTextBoundaryAt(text.length + 1), null); }); } class TestTextLayoutMetrics extends TextLayoutMetrics { static const TextSelection lineAt3 = TextSelection(baseOffset: 0, extentOffset: 10); static const TextRange wordBoundaryAt3 = TextRange(start: 4, end: 7); @override TextSelection getLineAtOffset(TextPosition position) { if (position.offset == 3) { return lineAt3; } throw UnimplementedError(); } @override TextPosition getTextPositionAbove(TextPosition position) { throw UnimplementedError(); } @override TextPosition getTextPositionBelow(TextPosition position) { throw UnimplementedError(); } @override TextRange getWordBoundary(TextPosition position) { if (position.offset == 3) { return wordBoundaryAt3; } throw UnimplementedError(); } }