1/*
2 * Copyright (C) 2012 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.inputmethod.latin;
18
19import android.content.res.Resources;
20import android.inputmethodservice.InputMethodService;
21import android.os.Parcel;
22import android.test.AndroidTestCase;
23import android.test.MoreAsserts;
24import android.test.suitebuilder.annotation.SmallTest;
25import android.text.SpannableString;
26import android.text.TextUtils;
27import android.text.style.SuggestionSpan;
28import android.view.inputmethod.ExtractedText;
29import android.view.inputmethod.ExtractedTextRequest;
30import android.view.inputmethod.InputConnection;
31import android.view.inputmethod.InputConnectionWrapper;
32
33import com.android.inputmethod.latin.common.Constants;
34import com.android.inputmethod.latin.common.StringUtils;
35import com.android.inputmethod.latin.settings.SpacingAndPunctuations;
36import com.android.inputmethod.latin.utils.NgramContextUtils;
37import com.android.inputmethod.latin.utils.RunInLocale;
38import com.android.inputmethod.latin.utils.ScriptUtils;
39import com.android.inputmethod.latin.utils.TextRange;
40
41import java.util.Locale;
42
43@SmallTest
44public class RichInputConnectionAndTextRangeTests extends AndroidTestCase {
45
46    // The following is meant to be a reasonable default for
47    // the "word_separators" resource.
48    private SpacingAndPunctuations mSpacingAndPunctuations;
49
50    @Override
51    protected void setUp() throws Exception {
52        super.setUp();
53        final RunInLocale<SpacingAndPunctuations> job = new RunInLocale<SpacingAndPunctuations>() {
54            @Override
55            protected SpacingAndPunctuations job(final Resources res) {
56                return new SpacingAndPunctuations(res);
57            }
58        };
59        final Resources res = getContext().getResources();
60        mSpacingAndPunctuations = job.runInLocale(res, Locale.ENGLISH);
61    }
62
63    private class MockConnection extends InputConnectionWrapper {
64        final CharSequence mTextBefore;
65        final CharSequence mTextAfter;
66        final ExtractedText mExtractedText;
67
68        public MockConnection(final CharSequence text, final int cursorPosition) {
69            super(null, false);
70            // Interaction of spans with Parcels is completely non-trivial, but in the actual case
71            // the CharSequences do go through Parcels because they go through IPC. There
72            // are some significant differences between the behavior of Spanned objects that
73            // have and that have not gone through parceling, so it's much easier to simulate
74            // the environment with Parcels than try to emulate things by hand.
75            final Parcel p = Parcel.obtain();
76            TextUtils.writeToParcel(text.subSequence(0, cursorPosition), p, 0 /* flags */);
77            TextUtils.writeToParcel(text.subSequence(cursorPosition, text.length()), p,
78                    0 /* flags */);
79            final byte[] marshalled = p.marshall();
80            p.unmarshall(marshalled, 0, marshalled.length);
81            p.setDataPosition(0);
82            mTextBefore = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(p);
83            mTextAfter = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(p);
84            mExtractedText = null;
85            p.recycle();
86        }
87
88        public MockConnection(String textBefore, String textAfter, ExtractedText extractedText) {
89            super(null, false);
90            mTextBefore = textBefore;
91            mTextAfter = textAfter;
92            mExtractedText = extractedText;
93        }
94
95        public int cursorPos() {
96            return mTextBefore.length();
97        }
98
99        /* (non-Javadoc)
100         * @see android.view.inputmethod.InputConnectionWrapper#getTextBeforeCursor(int, int)
101         */
102        @Override
103        public CharSequence getTextBeforeCursor(int n, int flags) {
104            return mTextBefore;
105        }
106
107        /* (non-Javadoc)
108         * @see android.view.inputmethod.InputConnectionWrapper#getTextAfterCursor(int, int)
109         */
110        @Override
111        public CharSequence getTextAfterCursor(int n, int flags) {
112            return mTextAfter;
113        }
114
115        /* (non-Javadoc)
116         * @see android.view.inputmethod.InputConnectionWrapper#getExtractedText(
117         *         ExtractedTextRequest, int)
118         */
119        @Override
120        public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) {
121            return mExtractedText;
122        }
123
124        @Override
125        public boolean beginBatchEdit() {
126            return true;
127        }
128
129        @Override
130        public boolean endBatchEdit() {
131            return true;
132        }
133
134        @Override
135        public boolean finishComposingText() {
136            return true;
137        }
138    }
139
140    static class MockInputMethodService extends InputMethodService {
141        private MockConnection mMockConnection;
142        public void setInputConnection(final MockConnection mockConnection) {
143            mMockConnection = mockConnection;
144        }
145        public int cursorPos() {
146            return mMockConnection.cursorPos();
147        }
148        @Override
149        public InputConnection getCurrentInputConnection() {
150            return mMockConnection;
151        }
152    }
153
154    /************************** Tests ************************/
155
156    /**
157     * Test for getting previous word (for bigram suggestions)
158     */
159    public void testGetPreviousWord() {
160        // If one of the following cases breaks, the bigram suggestions won't work.
161        assertEquals(NgramContextUtils.getNgramContextFromNthPreviousWord(
162                "abc def", mSpacingAndPunctuations, 2).getNthPrevWord(1), "abc");
163        assertEquals(NgramContextUtils.getNgramContextFromNthPreviousWord(
164                "abc", mSpacingAndPunctuations, 2), NgramContext.BEGINNING_OF_SENTENCE);
165        assertEquals(NgramContextUtils.getNgramContextFromNthPreviousWord(
166                "abc. def", mSpacingAndPunctuations, 2), NgramContext.BEGINNING_OF_SENTENCE);
167
168        assertFalse(NgramContextUtils.getNgramContextFromNthPreviousWord(
169                "abc def", mSpacingAndPunctuations, 2).isBeginningOfSentenceContext());
170        assertTrue(NgramContextUtils.getNgramContextFromNthPreviousWord(
171                "abc", mSpacingAndPunctuations, 2).isBeginningOfSentenceContext());
172
173        // For n-gram
174        assertEquals(NgramContextUtils.getNgramContextFromNthPreviousWord(
175                "abc def", mSpacingAndPunctuations, 1).getNthPrevWord(1), "def");
176        assertEquals(NgramContextUtils.getNgramContextFromNthPreviousWord(
177                "abc def", mSpacingAndPunctuations, 1).getNthPrevWord(2), "abc");
178        assertTrue(NgramContextUtils.getNgramContextFromNthPreviousWord(
179                "abc def", mSpacingAndPunctuations, 2).isNthPrevWordBeginningOfSentence(2));
180
181        // The following tests reflect the current behavior of the function
182        // RichInputConnection#getNthPreviousWord.
183        // TODO: However at this time, the code does never go
184        // into such a path, so it should be safe to change the behavior of
185        // this function if needed - especially since it does not seem very
186        // logical. These tests are just there to catch any unintentional
187        // changes in the behavior of the RichInputConnection#getPreviousWord method.
188        assertEquals(NgramContextUtils.getNgramContextFromNthPreviousWord(
189                "abc def ", mSpacingAndPunctuations, 2).getNthPrevWord(1), "abc");
190        assertEquals(NgramContextUtils.getNgramContextFromNthPreviousWord(
191                "abc def.", mSpacingAndPunctuations, 2).getNthPrevWord(1), "abc");
192        assertEquals(NgramContextUtils.getNgramContextFromNthPreviousWord(
193                "abc def .", mSpacingAndPunctuations, 2).getNthPrevWord(1), "def");
194        assertTrue(NgramContextUtils.getNgramContextFromNthPreviousWord(
195                "abc ", mSpacingAndPunctuations, 2).isBeginningOfSentenceContext());
196
197        assertEquals(NgramContextUtils.getNgramContextFromNthPreviousWord(
198                "abc def", mSpacingAndPunctuations, 1).getNthPrevWord(1), "def");
199        assertEquals(NgramContextUtils.getNgramContextFromNthPreviousWord(
200                "abc def ", mSpacingAndPunctuations, 1).getNthPrevWord(1), "def");
201        assertEquals(NgramContextUtils.getNgramContextFromNthPreviousWord(
202                "abc 'def", mSpacingAndPunctuations, 1).getNthPrevWord(1), "'def");
203        assertEquals(NgramContextUtils.getNgramContextFromNthPreviousWord(
204                "abc def.", mSpacingAndPunctuations, 1), NgramContext.BEGINNING_OF_SENTENCE);
205        assertEquals(NgramContextUtils.getNgramContextFromNthPreviousWord(
206                "abc def .", mSpacingAndPunctuations, 1), NgramContext.BEGINNING_OF_SENTENCE);
207        assertEquals(NgramContextUtils.getNgramContextFromNthPreviousWord(
208                "abc, def", mSpacingAndPunctuations, 2), NgramContext.EMPTY_PREV_WORDS_INFO);
209        // question mark is treated as the end of the sentence. Hence, beginning of the
210        // sentence is expected.
211        assertEquals(NgramContextUtils.getNgramContextFromNthPreviousWord(
212                "abc? def", mSpacingAndPunctuations, 2), NgramContext.BEGINNING_OF_SENTENCE);
213        // Exclamation mark is treated as the end of the sentence. Hence, beginning of the
214        // sentence is expected.
215        assertEquals(NgramContextUtils.getNgramContextFromNthPreviousWord(
216                "abc! def", mSpacingAndPunctuations, 2), NgramContext.BEGINNING_OF_SENTENCE);
217        assertEquals(NgramContextUtils.getNgramContextFromNthPreviousWord(
218                "abc 'def", mSpacingAndPunctuations, 2), NgramContext.EMPTY_PREV_WORDS_INFO);
219    }
220
221    public void testGetWordRangeAtCursor() {
222        /**
223         * Test logic in getting the word range at the cursor.
224         */
225        final SpacingAndPunctuations SPACE = new SpacingAndPunctuations(
226                mSpacingAndPunctuations, new int[] { Constants.CODE_SPACE });
227        final SpacingAndPunctuations TAB = new SpacingAndPunctuations(
228                mSpacingAndPunctuations, new int[] { Constants.CODE_TAB });
229        // A character that needs surrogate pair to represent its code point (U+2008A).
230        final String SUPPLEMENTARY_CHAR_STRING = "\uD840\uDC8A";
231        final SpacingAndPunctuations SUPPLEMENTARY_CHAR = new SpacingAndPunctuations(
232                mSpacingAndPunctuations, StringUtils.toSortedCodePointArray(
233                        SUPPLEMENTARY_CHAR_STRING));
234        final String HIRAGANA_WORD = "\u3042\u3044\u3046\u3048\u304A"; // あいうえお
235        final String GREEK_WORD = "\u03BA\u03B1\u03B9"; // και
236
237        ExtractedText et = new ExtractedText();
238        final MockInputMethodService mockInputMethodService = new MockInputMethodService();
239        final RichInputConnection ic = new RichInputConnection(mockInputMethodService);
240        mockInputMethodService.setInputConnection(new MockConnection("word wo", "rd", et));
241        et.startOffset = 0;
242        et.selectionStart = 7;
243        TextRange r;
244
245        ic.beginBatchEdit();
246        // basic case
247        r = ic.getWordRangeAtCursor(SPACE, ScriptUtils.SCRIPT_LATIN);
248        assertTrue(TextUtils.equals("word", r.mWord));
249
250        // tab character instead of space
251        mockInputMethodService.setInputConnection(new MockConnection("one\tword\two", "rd", et));
252        ic.beginBatchEdit();
253        r = ic.getWordRangeAtCursor(TAB, ScriptUtils.SCRIPT_LATIN);
254        ic.endBatchEdit();
255        assertTrue(TextUtils.equals("word", r.mWord));
256
257        // splitting on supplementary character
258        mockInputMethodService.setInputConnection(
259                new MockConnection("one word" + SUPPLEMENTARY_CHAR_STRING + "wo", "rd", et));
260        ic.beginBatchEdit();
261        r = ic.getWordRangeAtCursor(SUPPLEMENTARY_CHAR, ScriptUtils.SCRIPT_LATIN);
262        ic.endBatchEdit();
263        assertTrue(TextUtils.equals("word", r.mWord));
264
265        // split on chars outside the specified script
266        mockInputMethodService.setInputConnection(
267                new MockConnection(HIRAGANA_WORD + "wo", "rd" + GREEK_WORD, et));
268        ic.beginBatchEdit();
269        r = ic.getWordRangeAtCursor(SUPPLEMENTARY_CHAR, ScriptUtils.SCRIPT_LATIN);
270        ic.endBatchEdit();
271        assertTrue(TextUtils.equals("word", r.mWord));
272
273        // likewise for greek
274        mockInputMethodService.setInputConnection(
275                new MockConnection("text" + GREEK_WORD, "text", et));
276        ic.beginBatchEdit();
277        r = ic.getWordRangeAtCursor(SUPPLEMENTARY_CHAR, ScriptUtils.SCRIPT_GREEK);
278        ic.endBatchEdit();
279        assertTrue(TextUtils.equals(GREEK_WORD, r.mWord));
280    }
281
282    /**
283     * Test logic in getting the word range at the cursor.
284     */
285    public void testGetSuggestionSpansAtWord() {
286        helpTestGetSuggestionSpansAtWord(10);
287        helpTestGetSuggestionSpansAtWord(12);
288        helpTestGetSuggestionSpansAtWord(15);
289        helpTestGetSuggestionSpansAtWord(16);
290    }
291
292    private void helpTestGetSuggestionSpansAtWord(final int cursorPos) {
293        final SpacingAndPunctuations SPACE = new SpacingAndPunctuations(
294                mSpacingAndPunctuations, new int[] { Constants.CODE_SPACE });
295        final MockInputMethodService mockInputMethodService = new MockInputMethodService();
296        final RichInputConnection ic = new RichInputConnection(mockInputMethodService);
297
298        final String[] SUGGESTIONS1 = { "swing", "strong" };
299        final String[] SUGGESTIONS2 = { "storing", "strung" };
300
301        // Test the usual case.
302        SpannableString text = new SpannableString("This is a string for test");
303        text.setSpan(new SuggestionSpan(Locale.ENGLISH, SUGGESTIONS1, 0 /* flags */),
304                10 /* start */, 16 /* end */, 0 /* flags */);
305        mockInputMethodService.setInputConnection(new MockConnection(text, cursorPos));
306        TextRange r;
307        SuggestionSpan[] suggestions;
308
309        r = ic.getWordRangeAtCursor(SPACE, ScriptUtils.SCRIPT_LATIN);
310        suggestions = r.getSuggestionSpansAtWord();
311        assertEquals(suggestions.length, 1);
312        MoreAsserts.assertEquals(suggestions[0].getSuggestions(), SUGGESTIONS1);
313
314        // Test the case with 2 suggestion spans in the same place.
315        text = new SpannableString("This is a string for test");
316        text.setSpan(new SuggestionSpan(Locale.ENGLISH, SUGGESTIONS1, 0 /* flags */),
317                10 /* start */, 16 /* end */, 0 /* flags */);
318        text.setSpan(new SuggestionSpan(Locale.ENGLISH, SUGGESTIONS2, 0 /* flags */),
319                10 /* start */, 16 /* end */, 0 /* flags */);
320        mockInputMethodService.setInputConnection(new MockConnection(text, cursorPos));
321        r = ic.getWordRangeAtCursor(SPACE, ScriptUtils.SCRIPT_LATIN);
322        suggestions = r.getSuggestionSpansAtWord();
323        assertEquals(suggestions.length, 2);
324        MoreAsserts.assertEquals(suggestions[0].getSuggestions(), SUGGESTIONS1);
325        MoreAsserts.assertEquals(suggestions[1].getSuggestions(), SUGGESTIONS2);
326
327        // Test a case with overlapping spans, 2nd extending past the start of the word
328        text = new SpannableString("This is a string for test");
329        text.setSpan(new SuggestionSpan(Locale.ENGLISH, SUGGESTIONS1, 0 /* flags */),
330                10 /* start */, 16 /* end */, 0 /* flags */);
331        text.setSpan(new SuggestionSpan(Locale.ENGLISH, SUGGESTIONS2, 0 /* flags */),
332                5 /* start */, 16 /* end */, 0 /* flags */);
333        mockInputMethodService.setInputConnection(new MockConnection(text, cursorPos));
334        r = ic.getWordRangeAtCursor(SPACE, ScriptUtils.SCRIPT_LATIN);
335        suggestions = r.getSuggestionSpansAtWord();
336        assertEquals(suggestions.length, 1);
337        MoreAsserts.assertEquals(suggestions[0].getSuggestions(), SUGGESTIONS1);
338
339        // Test a case with overlapping spans, 2nd extending past the end of the word
340        text = new SpannableString("This is a string for test");
341        text.setSpan(new SuggestionSpan(Locale.ENGLISH, SUGGESTIONS1, 0 /* flags */),
342                10 /* start */, 16 /* end */, 0 /* flags */);
343        text.setSpan(new SuggestionSpan(Locale.ENGLISH, SUGGESTIONS2, 0 /* flags */),
344                10 /* start */, 20 /* end */, 0 /* flags */);
345        mockInputMethodService.setInputConnection(new MockConnection(text, cursorPos));
346        r = ic.getWordRangeAtCursor(SPACE, ScriptUtils.SCRIPT_LATIN);
347        suggestions = r.getSuggestionSpansAtWord();
348        assertEquals(suggestions.length, 1);
349        MoreAsserts.assertEquals(suggestions[0].getSuggestions(), SUGGESTIONS1);
350
351        // Test a case with overlapping spans, 2nd extending past both ends of the word
352        text = new SpannableString("This is a string for test");
353        text.setSpan(new SuggestionSpan(Locale.ENGLISH, SUGGESTIONS1, 0 /* flags */),
354                10 /* start */, 16 /* end */, 0 /* flags */);
355        text.setSpan(new SuggestionSpan(Locale.ENGLISH, SUGGESTIONS2, 0 /* flags */),
356                5 /* start */, 20 /* end */, 0 /* flags */);
357        mockInputMethodService.setInputConnection(new MockConnection(text, cursorPos));
358        r = ic.getWordRangeAtCursor(SPACE, ScriptUtils.SCRIPT_LATIN);
359        suggestions = r.getSuggestionSpansAtWord();
360        assertEquals(suggestions.length, 1);
361        MoreAsserts.assertEquals(suggestions[0].getSuggestions(), SUGGESTIONS1);
362
363        // Test a case with overlapping spans, none right on the word
364        text = new SpannableString("This is a string for test");
365        text.setSpan(new SuggestionSpan(Locale.ENGLISH, SUGGESTIONS1, 0 /* flags */),
366                5 /* start */, 16 /* end */, 0 /* flags */);
367        text.setSpan(new SuggestionSpan(Locale.ENGLISH, SUGGESTIONS2, 0 /* flags */),
368                5 /* start */, 20 /* end */, 0 /* flags */);
369        mockInputMethodService.setInputConnection(new MockConnection(text, cursorPos));
370        r = ic.getWordRangeAtCursor(SPACE, ScriptUtils.SCRIPT_LATIN);
371        suggestions = r.getSuggestionSpansAtWord();
372        assertEquals(suggestions.length, 0);
373    }
374
375    public void testCursorTouchingWord() {
376        final MockInputMethodService ims = new MockInputMethodService();
377        final RichInputConnection ic = new RichInputConnection(ims);
378        final SpacingAndPunctuations sap = mSpacingAndPunctuations;
379
380        ims.setInputConnection(new MockConnection("users", 5));
381        ic.resetCachesUponCursorMoveAndReturnSuccess(ims.cursorPos(), ims.cursorPos(), true);
382        assertTrue(ic.isCursorTouchingWord(sap, true /* checkTextAfter */));
383
384        ims.setInputConnection(new MockConnection("users'", 5));
385        ic.resetCachesUponCursorMoveAndReturnSuccess(ims.cursorPos(), ims.cursorPos(), true);
386        assertTrue(ic.isCursorTouchingWord(sap, true));
387
388        ims.setInputConnection(new MockConnection("users'", 6));
389        ic.resetCachesUponCursorMoveAndReturnSuccess(ims.cursorPos(), ims.cursorPos(), true);
390        assertTrue(ic.isCursorTouchingWord(sap, true));
391
392        ims.setInputConnection(new MockConnection("'users'", 6));
393        ic.resetCachesUponCursorMoveAndReturnSuccess(ims.cursorPos(), ims.cursorPos(), true);
394        assertTrue(ic.isCursorTouchingWord(sap, true));
395
396        ims.setInputConnection(new MockConnection("'users'", 7));
397        ic.resetCachesUponCursorMoveAndReturnSuccess(ims.cursorPos(), ims.cursorPos(), true);
398        assertTrue(ic.isCursorTouchingWord(sap, true));
399
400        ims.setInputConnection(new MockConnection("users '", 6));
401        ic.resetCachesUponCursorMoveAndReturnSuccess(ims.cursorPos(), ims.cursorPos(), true);
402        assertFalse(ic.isCursorTouchingWord(sap, true));
403
404        ims.setInputConnection(new MockConnection("users '", 7));
405        ic.resetCachesUponCursorMoveAndReturnSuccess(ims.cursorPos(), ims.cursorPos(), true);
406        assertFalse(ic.isCursorTouchingWord(sap, true));
407
408        ims.setInputConnection(new MockConnection("re-", 3));
409        ic.resetCachesUponCursorMoveAndReturnSuccess(ims.cursorPos(), ims.cursorPos(), true);
410        assertTrue(ic.isCursorTouchingWord(sap, true));
411
412        ims.setInputConnection(new MockConnection("re--", 4));
413        ic.resetCachesUponCursorMoveAndReturnSuccess(ims.cursorPos(), ims.cursorPos(), true);
414        assertFalse(ic.isCursorTouchingWord(sap, true));
415
416        ims.setInputConnection(new MockConnection("-", 1));
417        ic.resetCachesUponCursorMoveAndReturnSuccess(ims.cursorPos(), ims.cursorPos(), true);
418        assertFalse(ic.isCursorTouchingWord(sap, true));
419
420        ims.setInputConnection(new MockConnection("--", 2));
421        ic.resetCachesUponCursorMoveAndReturnSuccess(ims.cursorPos(), ims.cursorPos(), true);
422        assertFalse(ic.isCursorTouchingWord(sap, true));
423
424        ims.setInputConnection(new MockConnection(" -", 2));
425        ic.resetCachesUponCursorMoveAndReturnSuccess(ims.cursorPos(), ims.cursorPos(), true);
426        assertFalse(ic.isCursorTouchingWord(sap, true));
427
428        ims.setInputConnection(new MockConnection(" --", 3));
429        ic.resetCachesUponCursorMoveAndReturnSuccess(ims.cursorPos(), ims.cursorPos(), true);
430        assertFalse(ic.isCursorTouchingWord(sap, true));
431
432        ims.setInputConnection(new MockConnection(" users '", 1));
433        ic.resetCachesUponCursorMoveAndReturnSuccess(ims.cursorPos(), ims.cursorPos(), true);
434        assertTrue(ic.isCursorTouchingWord(sap, true));
435
436        ims.setInputConnection(new MockConnection(" users '", 3));
437        ic.resetCachesUponCursorMoveAndReturnSuccess(ims.cursorPos(), ims.cursorPos(), true);
438        assertTrue(ic.isCursorTouchingWord(sap, true));
439
440        ims.setInputConnection(new MockConnection(" users '", 7));
441        ic.resetCachesUponCursorMoveAndReturnSuccess(ims.cursorPos(), ims.cursorPos(), true);
442        assertFalse(ic.isCursorTouchingWord(sap, true));
443
444        ims.setInputConnection(new MockConnection(" users are", 7));
445        ic.resetCachesUponCursorMoveAndReturnSuccess(ims.cursorPos(), ims.cursorPos(), true);
446        assertTrue(ic.isCursorTouchingWord(sap, true));
447
448        ims.setInputConnection(new MockConnection(" users 'are", 7));
449        ic.resetCachesUponCursorMoveAndReturnSuccess(ims.cursorPos(), ims.cursorPos(), true);
450        assertFalse(ic.isCursorTouchingWord(sap, true));
451    }
452}
453